diff --git a/agents/rap-node-agent/cmd/mesh-live-smoke/main.go b/agents/rap-node-agent/cmd/mesh-live-smoke/main.go index eab9208..34733f7 100644 --- a/agents/rap-node-agent/cmd/mesh-live-smoke/main.go +++ b/agents/rap-node-agent/cmd/mesh-live-smoke/main.go @@ -140,15 +140,12 @@ func run(ctx context.Context) (smokeReport, error) { return smokeReport{}, fmt.Errorf("test service: %w", err) } fabricSessionStartedAt := time.Now() - fabricSession, _, err := mesh.NewClient(nodeB.URL).OpenFabricSession(ctx, mesh.FabricSessionDialOptions{ - Token: "rap_fsn_mesh_live_smoke", - Timeout: 3 * time.Second, - }) + fabricSession, fabricQUICEndpoint, fabricQUICPressure, err := smokeQUICFabricSession(ctx) if err != nil { - return smokeReport{}, fmt.Errorf("fabric session open: %w", err) + return smokeReport{}, fmt.Errorf("fabric quic session open: %w", err) } defer fabricSession.Close() - firstFabricSessionResponse, err := fabricSession.RoundTrip(ctx, fabricproto.Frame{ + firstFabricSessionResponse, err := smokeFabricSessionRoundTrip(ctx, fabricSession, fabricproto.Frame{ Type: fabricproto.FramePing, Sequence: uint64(fabricSessionStartedAt.UnixNano()), Payload: []byte("mesh-live-smoke-fabric-session"), @@ -156,7 +153,7 @@ func run(ctx context.Context) (smokeReport, error) { if err != nil { return smokeReport{}, fmt.Errorf("fabric session first round trip: %w", err) } - secondFabricSessionResponse, err := fabricSession.RoundTrip(ctx, fabricproto.Frame{ + secondFabricSessionResponse, err := smokeFabricSessionRoundTrip(ctx, fabricSession, fabricproto.Frame{ Type: fabricproto.FramePing, Sequence: uint64(fabricSessionStartedAt.UnixNano()) + 1, Payload: []byte("mesh-live-smoke-fabric-session-2"), @@ -175,13 +172,9 @@ func run(ctx context.Context) (smokeReport, error) { } fabricVPNBulkPressure, fabricVPNBulkChannels, fabricVPNInteractiveChannels, fabricVPNBulkWindow, fabricVPNInteractiveWindow, fabricVPNPressureLevel, fabricVPNPressureScore, fabricVPNPressureReasons, fabricVPNPressureAction := smokeVPNFlowSchedulerBulkPressure() fabricVPNRouteRecovered, fabricVPNRouteSwitches, fabricVPNRecoveryMS, fabricVPNRecoveryMaxMS, fabricVPNRecoveryAvgMS, fabricVPNRecoveryReason := smokeVPNFlowSchedulerRouteRecovery() - fabricQUICAccepted, fabricQUICEndpoint, fabricQUICPressure, err := smokeQUICFabricSession(ctx) - if err != nil { - return smokeReport{}, fmt.Errorf("fabric quic smoke: %w", err) - } return smokeReport{ - Stage: "C17F scoped synthetic config plus live HTTP transport", + Stage: "C17F scoped synthetic config plus live QUIC fabric transport", ProductionForwarding: false, ScopedConfigLoaded: nodeAConfig.ConfigVersion == "smoke-config-v1", DirectProbeAccepted: directAck.MessageType == mesh.SyntheticMessageProbeAck, @@ -210,11 +203,11 @@ func run(ctx context.Context) (smokeReport, error) { FabricVPNRecoveryMaxMS: fabricVPNRecoveryMaxMS, FabricVPNRecoveryAvgMS: fabricVPNRecoveryAvgMS, FabricVPNRecoveryReason: fabricVPNRecoveryReason, - FabricQUICAccepted: fabricQUICAccepted, + FabricQUICAccepted: fabricSessionAccepted, FabricQUICEndpoint: fabricQUICEndpoint, FabricQUICPressure: fabricQUICPressure, FabricSessionLatencyMS: fabricSessionLatency.Milliseconds(), - FabricSessionEndpoint: nodeB.URL + "/mesh/v1/fabric/session/ws", + FabricSessionEndpoint: "quic://" + fabricQUICEndpoint, PeerEndpoints: map[string]any{ "node-a": nodeA.URL, "node-r": nodeR.URL, @@ -269,18 +262,16 @@ func smokeVPNFlowSchedulerRouteRecovery() (bool, uint64, int64, int64, int64, st stat.LastRouteSwitchReason } -func smokeQUICFabricSession(ctx context.Context) (bool, string, int, error) { +func smokeQUICFabricSession(ctx context.Context) (mesh.FabricTransportSession, string, int, error) { server, err := mesh.StartQUICFabricServer(ctx, mesh.QUICFabricServerConfig{ ListenAddr: "127.0.0.1:0", TLSConfig: smokeQUICTLSConfig(), }) if err != nil { - return false, "", 0, err + return nil, "", 0, err } - defer server.Close() endpoint := server.Addr().String() transport := mesh.NewQUICFabricTransport(nil) - defer transport.Close() session, err := transport.Connect(ctx, mesh.FabricTransportTarget{ PeerID: "node-b", Endpoint: endpoint, @@ -293,31 +284,12 @@ func smokeQUICFabricSession(ctx context.Context) (bool, string, int, error) { ErrorBuffer: 4, }) if err != nil { - return false, endpoint, 0, err - } - defer session.Close() - if err := session.Send(ctx, fabricproto.Frame{ - Type: fabricproto.FramePing, - Sequence: uint64(time.Now().UnixNano()), - Payload: []byte("mesh-live-smoke-quic"), - }); err != nil { - return false, endpoint, 0, err - } - timer := time.NewTimer(3 * time.Second) - defer timer.Stop() - for { - select { - case frame := <-session.Frames(): - snapshot := transport.Snapshot() - return frame.Type == fabricproto.FramePong && string(frame.Payload) == "mesh-live-smoke-quic", endpoint, snapshot.CapacityPressurePercent, nil - case err := <-session.Errors(): - return false, endpoint, 0, err - case <-timer.C: - return false, endpoint, 0, fmt.Errorf("timed out waiting for quic pong") - case <-ctx.Done(): - return false, endpoint, 0, ctx.Err() - } + _ = transport.Close() + _ = server.Close() + return nil, endpoint, 0, err } + snapshot := transport.Snapshot() + return &smokeManagedFabricSession{session: session, transport: transport, server: server}, endpoint, snapshot.CapacityPressurePercent, nil } func smokeQUICTLSConfig() *tls.Config { @@ -341,25 +313,20 @@ func smokeQUICTLSConfig() *tls.Config { } } -func smokeFabricVPNPacketOverSession(ctx context.Context, fabricSession *mesh.FabricSessionClient) (bool, bool, int, error) { +func smokeFabricVPNPacketOverSession(ctx context.Context, fabricSession mesh.FabricTransportSession) (bool, bool, int, error) { const interactiveStreamID uint64 = 4400 const bulkStreamID uint64 = 4401 - pump := fabricSession.StartPump(ctx, mesh.FabricSessionPumpOptions{ - OutboundBuffer: 4, - InboundBuffer: 4, - ErrorBuffer: 4, - }) - defer pump.Close() for _, frame := range []fabricproto.Frame{ {Type: fabricproto.FrameOpenStream, StreamID: interactiveStreamID, TrafficClass: fabricproto.TrafficClassInteractive}, {Type: fabricproto.FrameOpenStream, StreamID: bulkStreamID, TrafficClass: fabricproto.TrafficClassBulk}, } { - if err := pump.Send(ctx, frame); err != nil { + if err := fabricSession.Send(ctx, frame); err != nil { return false, false, 0, err } } transport := &vpnruntime.FabricSessionPacketTransport{ - Sender: pump, + Sender: fabricSession, + Receiver: fabricSession, StreamID: interactiveStreamID, VPNConnectionID: "vpn-smoke", SendDirection: vpnruntime.FabricDirectionGatewayToClient, @@ -378,7 +345,7 @@ func smokeFabricVPNPacketOverSession(ctx context.Context, fabricSession *mesh.Fa acked := map[uint64]bool{} for { select { - case frame := <-pump.Frames(): + case frame := <-fabricSession.Frames(): if frame.Type == fabricproto.FrameAck && frame.Sequence == 1 { acked[frame.StreamID] = true if acked[interactiveStreamID] && acked[bulkStreamID] { @@ -393,7 +360,7 @@ func smokeFabricVPNPacketOverSession(ctx context.Context, fabricSession *mesh.Fa return true, sharded, int(fanout), nil } } - case err := <-pump.Errors(): + case err := <-fabricSession.Errors(): return false, false, 0, err case <-timer.C: return false, false, 0, fmt.Errorf("timed out waiting for fabric vpn packet ack") @@ -403,6 +370,68 @@ func smokeFabricVPNPacketOverSession(ctx context.Context, fabricSession *mesh.Fa } } +type smokeManagedFabricSession struct { + session mesh.FabricTransportSession + transport *mesh.QUICFabricTransport + server *mesh.QUICFabricServer +} + +func (s *smokeManagedFabricSession) Send(ctx context.Context, frame fabricproto.Frame) error { + return s.session.Send(ctx, frame) +} + +func (s *smokeManagedFabricSession) Frames() <-chan fabricproto.Frame { + return s.session.Frames() +} + +func (s *smokeManagedFabricSession) Errors() <-chan error { + return s.session.Errors() +} + +func (s *smokeManagedFabricSession) Closed() bool { + return s.session.Closed() +} + +func (s *smokeManagedFabricSession) Close() error { + var firstErr error + if s.session != nil { + firstErr = s.session.Close() + } + if s.transport != nil { + if err := s.transport.Close(); firstErr == nil { + firstErr = err + } + } + if s.server != nil { + if err := s.server.Close(); firstErr == nil { + firstErr = err + } + } + return firstErr +} + +func smokeFabricSessionRoundTrip(ctx context.Context, session mesh.FabricTransportSession, frame fabricproto.Frame) (fabricproto.Frame, error) { + if err := session.Send(ctx, frame); err != nil { + return fabricproto.Frame{}, err + } + timer := time.NewTimer(3 * time.Second) + defer timer.Stop() + for { + select { + case response := <-session.Frames(): + if response.Sequence == frame.Sequence { + return response, nil + } + case err := <-session.Errors(): + return fabricproto.Frame{}, err + case <-timer.C: + return fabricproto.Frame{}, fmt.Errorf("timed out waiting for fabric session response") + case <-ctx.Done(): + return fabricproto.Frame{}, ctx.Err() + } + } +} + func smokeIPv4TCPPacket(src [4]byte, dst [4]byte, srcPort uint16, dstPort uint16, flags byte) []byte { packet := make([]byte, 40) packet[0] = 0x45 @@ -445,7 +474,7 @@ func writeSmokeScopedConfig(local mesh.PeerIdentity, peers map[string]string, ro func newSmokeNode(local mesh.PeerIdentity) *smokeNode { node := &smokeNode{Local: local} node.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - mesh.Server{Local: node.Local, SyntheticRuntime: node.Runtime, FabricSessionEnabled: true, FabricSessionWebSocketEnabled: true}.Handler().ServeHTTP(w, r) + mesh.Server{Local: node.Local, SyntheticRuntime: node.Runtime}.Handler().ServeHTTP(w, r) })) node.URL = node.server.URL return node diff --git a/agents/rap-node-agent/cmd/rap-host-agent/main.go b/agents/rap-node-agent/cmd/rap-host-agent/main.go index ec475b1..24c3c9b 100644 --- a/agents/rap-node-agent/cmd/rap-host-agent/main.go +++ b/agents/rap-node-agent/cmd/rap-host-agent/main.go @@ -6,7 +6,6 @@ import ( "flag" "fmt" "log" - "net/http" "os" "os/signal" "runtime" @@ -15,9 +14,7 @@ import ( "time" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/agent" - "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/hostagent" - "github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh" ) type installCommandConfig struct { @@ -82,10 +79,6 @@ func main() { if err := runUpdateHostAgentLoop(ctx, os.Args[2:]); err != nil { log.Fatalf("update-host-agent-loop failed: %v", err) } - case "fabric-session-smoke": - if err := runFabricSessionSmoke(ctx, os.Args[2:]); err != nil { - log.Fatalf("fabric-session-smoke failed: %v", err) - } default: usage() os.Exit(2) @@ -117,78 +110,6 @@ func applyStagedSelfUpdate() { _ = os.Remove(backup) } -func runFabricSessionSmoke(ctx context.Context, args []string) error { - fs := flag.NewFlagSet("fabric-session-smoke", flag.ContinueOnError) - var meshURL string - var token string - var timeoutSeconds int - var payload string - var authorityPayload string - var authoritySignature string - fs.StringVar(&meshURL, "mesh-url", getenv("RAP_MESH_SMOKE_URL", ""), "Mesh base URL, for example http://node:19131.") - fs.StringVar(&token, "token", getenv("RAP_FABRIC_SESSION_TOKEN", ""), "Fabric session token starting with rap_fsn_.") - fs.IntVar(&timeoutSeconds, "timeout-seconds", getenvInt("RAP_FABRIC_SESSION_SMOKE_TIMEOUT_SECONDS", 5), "Smoke timeout in seconds.") - fs.StringVar(&payload, "payload", getenv("RAP_FABRIC_SESSION_SMOKE_PAYLOAD", "rap-fabric-session-smoke"), "Ping payload.") - fs.StringVar(&authorityPayload, "authority-payload", getenv("RAP_FABRIC_SESSION_AUTHORITY_PAYLOAD", ""), "Base64 or JSON fabric session authority payload header.") - fs.StringVar(&authoritySignature, "authority-signature", getenv("RAP_FABRIC_SESSION_AUTHORITY_SIGNATURE", ""), "Base64 or JSON fabric session authority signature header.") - if err := fs.Parse(args); err != nil { - return err - } - if strings.TrimSpace(meshURL) == "" { - return fmt.Errorf("mesh-url is required") - } - if strings.TrimSpace(token) == "" { - return fmt.Errorf("token is required") - } - if timeoutSeconds <= 0 { - timeoutSeconds = 5 - } - smokeCtx, cancel := context.WithTimeout(ctx, time.Duration(timeoutSeconds)*time.Second) - defer cancel() - header := make(http.Header) - if strings.TrimSpace(authorityPayload) != "" { - header.Set("X-RAP-Fabric-Session-Authority-Payload", strings.TrimSpace(authorityPayload)) - } - if strings.TrimSpace(authoritySignature) != "" { - header.Set("X-RAP-Fabric-Session-Authority-Signature", strings.TrimSpace(authoritySignature)) - } - startedAt := time.Now() - response, err := mesh.NewClient(meshURL).SendFabricSessionFrame(smokeCtx, mesh.FabricSessionDialOptions{ - Token: token, - Header: header, - Timeout: time.Duration(timeoutSeconds) * time.Second, - }, fabricproto.Frame{ - Type: fabricproto.FramePing, - Sequence: uint64(startedAt.UnixNano()), - Payload: []byte(payload), - }) - duration := time.Since(startedAt) - result := map[string]any{ - "schema_version": "rap.fabric_session_smoke_result.v1", - "mesh_url": strings.TrimSpace(meshURL), - "ok": err == nil && response.Type == fabricproto.FramePong && string(response.Payload) == payload, - "latency_ms": duration.Milliseconds(), - "response_type": response.Type, - "sequence": response.Sequence, - "authority": strings.TrimSpace(authorityPayload) != "" || strings.TrimSpace(authoritySignature) != "", - } - if err != nil { - result["error"] = err.Error() - } - encoded, marshalErr := json.MarshalIndent(result, "", " ") - if marshalErr != nil { - return marshalErr - } - fmt.Println(string(encoded)) - if err != nil { - return err - } - if response.Type != fabricproto.FramePong || string(response.Payload) != payload { - return fmt.Errorf("fabric session smoke returned unexpected response type=%d payload=%q", response.Type, string(response.Payload)) - } - return nil -} - func runInstallLinux(ctx context.Context, args []string) error { fs := flag.NewFlagSet("install-linux", flag.ContinueOnError) cfg := hostagent.LinuxInstallConfig{} @@ -215,16 +136,15 @@ func runInstallLinux(ctx context.Context, args []string) error { fs.IntVar(&cfg.AutoUpdateHealthTimeoutSeconds, "auto-update-health-timeout-seconds", getenvInt("RAP_UPDATE_HEALTH_TIMEOUT_SECONDS", 30), "Updated service health timeout in seconds.") fs.StringVar(&cfg.HostAgentSourcePath, "host-agent-source-path", getenv("RAP_HOST_AGENT_SOURCE_PATH", ""), "Source rap-host-agent path copied to the persistent updater location.") fs.BoolVar(&cfg.RuntimeConfig.WorkloadSupervisionEnabled, "workload-supervision-enabled", getenvBool("RAP_WORKLOAD_SUPERVISION_ENABLED", false), "Enable node-agent workload status reporting.") - fs.BoolVar(&cfg.RuntimeConfig.MeshSyntheticRuntimeEnabled, "mesh-synthetic-runtime-enabled", getenvBool("RAP_MESH_SYNTHETIC_RUNTIME_ENABLED", true), "Enable synthetic mesh runtime.") + fs.BoolVar(&cfg.RuntimeConfig.MeshSyntheticRuntimeEnabled, "mesh-synthetic-runtime-enabled", getenvBool("RAP_MESH_SYNTHETIC_RUNTIME_ENABLED", false), "Enable historical synthetic mesh runtime.") fs.BoolVar(&cfg.RuntimeConfig.MeshProductionForwardingEnabled, "mesh-production-forwarding-enabled", getenvBool("RAP_MESH_PRODUCTION_FORWARDING_ENABLED", false), "Enable production forwarding gate; runtime still fail-closed if unavailable.") - fs.BoolVar(&cfg.RuntimeConfig.MeshFabricSessionEnabled, "mesh-fabric-session-enabled", getenvBool("RAP_MESH_FABRIC_SESSION_ENABLED", false), "Enable authenticated fabric session endpoint.") fs.BoolVar(&cfg.RuntimeConfig.VPNFabricSessionTransportEnabled, "vpn-fabric-session-transport-enabled", getenvBool("RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED", false), "Route VPN packet transport over persistent fabric sessions.") fs.BoolVar(&cfg.RuntimeConfig.MeshQUICFabricEnabled, "mesh-quic-fabric-enabled", getenvBool("RAP_MESH_QUIC_FABRIC_ENABLED", false), "Enable QUIC/UDP fabric listener.") fs.StringVar(&cfg.RuntimeConfig.MeshQUICFabricListenAddr, "mesh-quic-fabric-listen-addr", getenv("RAP_MESH_QUIC_FABRIC_LISTEN_ADDR", ""), "QUIC/UDP fabric listen address.") fs.IntVar(&cfg.RuntimeConfig.VPNFabricSessionStreamShards, "vpn-fabric-session-stream-shards", getenvInt("RAP_VPN_FABRIC_SESSION_STREAM_SHARDS", 4), "VPN fabric-session stream shards per traffic class.") fs.IntVar(&cfg.RuntimeConfig.VPNFabricQUICMaxStreamsPerConn, "vpn-fabric-quic-max-streams-per-conn", getenvInt("RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN", 64), "Maximum logical fabric-session streams per cached VPN QUIC carrier connection.") fs.IntVar(&cfg.RuntimeConfig.VPNFabricQUICIdleTTLSeconds, "vpn-fabric-quic-idle-ttl-seconds", getenvInt("RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS", 300), "Idle TTL seconds for cached VPN QUIC carrier connections.") - fs.StringVar(&cfg.RuntimeConfig.MeshListenAddr, "mesh-listen-addr", getenv("RAP_MESH_LISTEN_ADDR", ":19131"), "Synthetic mesh HTTP listen address.") + fs.StringVar(&cfg.RuntimeConfig.MeshListenAddr, "mesh-listen-addr", getenv("RAP_MESH_LISTEN_ADDR", ""), "Historical synthetic mesh HTTP listen address.") fs.StringVar(&cfg.RuntimeConfig.MeshListenPortMode, "mesh-listen-port-mode", getenv("RAP_MESH_LISTEN_PORT_MODE", "auto"), "Mesh listen port behavior: manual, auto, or disabled.") fs.IntVar(&cfg.RuntimeConfig.MeshListenAutoPortStart, "mesh-listen-auto-port-start", getenvInt("RAP_MESH_LISTEN_AUTO_PORT_START", 19131), "First port used when mesh listen port mode is auto.") fs.IntVar(&cfg.RuntimeConfig.MeshListenAutoPortEnd, "mesh-listen-auto-port-end", getenvInt("RAP_MESH_LISTEN_AUTO_PORT_END", 19231), "Last port used when mesh listen port mode is auto.") @@ -303,16 +223,15 @@ func runInstallWindows(ctx context.Context, args []string) error { fs.IntVar(&cfg.AutoUpdateHealthTimeoutSeconds, "auto-update-health-timeout-seconds", getenvInt("RAP_UPDATE_HEALTH_TIMEOUT_SECONDS", 30), "Updated service health timeout in seconds.") fs.StringVar(&cfg.HostAgentSourcePath, "host-agent-source-path", getenv("RAP_HOST_AGENT_SOURCE_PATH", ""), "Source rap-host-agent.exe path copied to the persistent updater location.") fs.BoolVar(&cfg.RuntimeConfig.WorkloadSupervisionEnabled, "workload-supervision-enabled", getenvBool("RAP_WORKLOAD_SUPERVISION_ENABLED", false), "Enable node-agent workload status reporting.") - fs.BoolVar(&cfg.RuntimeConfig.MeshSyntheticRuntimeEnabled, "mesh-synthetic-runtime-enabled", getenvBool("RAP_MESH_SYNTHETIC_RUNTIME_ENABLED", true), "Enable synthetic mesh runtime.") + fs.BoolVar(&cfg.RuntimeConfig.MeshSyntheticRuntimeEnabled, "mesh-synthetic-runtime-enabled", getenvBool("RAP_MESH_SYNTHETIC_RUNTIME_ENABLED", false), "Enable historical synthetic mesh runtime.") fs.BoolVar(&cfg.RuntimeConfig.MeshProductionForwardingEnabled, "mesh-production-forwarding-enabled", getenvBool("RAP_MESH_PRODUCTION_FORWARDING_ENABLED", false), "Enable production forwarding gate; runtime still fail-closed if unavailable.") - fs.BoolVar(&cfg.RuntimeConfig.MeshFabricSessionEnabled, "mesh-fabric-session-enabled", getenvBool("RAP_MESH_FABRIC_SESSION_ENABLED", false), "Enable authenticated fabric session endpoint.") fs.BoolVar(&cfg.RuntimeConfig.VPNFabricSessionTransportEnabled, "vpn-fabric-session-transport-enabled", getenvBool("RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED", false), "Route VPN packet transport over persistent fabric sessions.") fs.BoolVar(&cfg.RuntimeConfig.MeshQUICFabricEnabled, "mesh-quic-fabric-enabled", getenvBool("RAP_MESH_QUIC_FABRIC_ENABLED", false), "Enable QUIC/UDP fabric listener.") fs.StringVar(&cfg.RuntimeConfig.MeshQUICFabricListenAddr, "mesh-quic-fabric-listen-addr", getenv("RAP_MESH_QUIC_FABRIC_LISTEN_ADDR", ""), "QUIC/UDP fabric listen address.") fs.IntVar(&cfg.RuntimeConfig.VPNFabricSessionStreamShards, "vpn-fabric-session-stream-shards", getenvInt("RAP_VPN_FABRIC_SESSION_STREAM_SHARDS", 4), "VPN fabric-session stream shards per traffic class.") fs.IntVar(&cfg.RuntimeConfig.VPNFabricQUICMaxStreamsPerConn, "vpn-fabric-quic-max-streams-per-conn", getenvInt("RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN", 64), "Maximum logical fabric-session streams per cached VPN QUIC carrier connection.") fs.IntVar(&cfg.RuntimeConfig.VPNFabricQUICIdleTTLSeconds, "vpn-fabric-quic-idle-ttl-seconds", getenvInt("RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS", 300), "Idle TTL seconds for cached VPN QUIC carrier connections.") - fs.StringVar(&cfg.RuntimeConfig.MeshListenAddr, "mesh-listen-addr", getenv("RAP_MESH_LISTEN_ADDR", ":19131"), "Synthetic mesh HTTP listen address.") + fs.StringVar(&cfg.RuntimeConfig.MeshListenAddr, "mesh-listen-addr", getenv("RAP_MESH_LISTEN_ADDR", ""), "Historical synthetic mesh HTTP listen address.") fs.StringVar(&cfg.RuntimeConfig.MeshListenPortMode, "mesh-listen-port-mode", getenv("RAP_MESH_LISTEN_PORT_MODE", "auto"), "Mesh listen port behavior: manual, auto, or disabled.") fs.IntVar(&cfg.RuntimeConfig.MeshListenAutoPortStart, "mesh-listen-auto-port-start", getenvInt("RAP_MESH_LISTEN_AUTO_PORT_START", 19131), "First port used when mesh listen port mode is auto.") fs.IntVar(&cfg.RuntimeConfig.MeshListenAutoPortEnd, "mesh-listen-auto-port-end", getenvInt("RAP_MESH_LISTEN_AUTO_PORT_END", 19231), "Last port used when mesh listen port mode is auto.") @@ -513,16 +432,19 @@ func runUpdateLoop(ctx context.Context, args []string) error { } cfg.HostAgentUpdateEnabled = hostAgentStatusEnabled cfg.HostAgentUpdateRequest = hostagent.HostAgentUpdateRequest{ - BackendURL: req.BackendURL, - ClusterID: req.ClusterID, - NodeID: req.NodeID, - StateDir: req.StateDir, - CurrentVersion: hostAgentVersion, - Channel: req.Channel, - OS: firstNonEmptyLocal(req.OS, runtime.GOOS), - Arch: firstNonEmptyLocal(req.Arch, runtime.GOARCH), - InstallType: hostagent.BinaryUpdateInstallType, - BinaryPath: hostAgentBinaryPath, + BackendURL: req.BackendURL, + ClusterID: req.ClusterID, + NodeID: req.NodeID, + StateDir: req.StateDir, + ClusterAuthorityPublicKey: req.ClusterAuthorityPublicKey, + FabricRegistryRecordsJSON: req.FabricRegistryRecordsJSON, + MeshRegion: req.MeshRegion, + CurrentVersion: hostAgentVersion, + Channel: req.Channel, + OS: firstNonEmptyLocal(req.OS, runtime.GOOS), + Arch: firstNonEmptyLocal(req.Arch, runtime.GOARCH), + InstallType: hostagent.BinaryUpdateInstallType, + BinaryPath: hostAgentBinaryPath, } if req.InstallType == hostagent.WindowsUpdateInstallType || runtime.GOOS == "windows" { cfg.HostAgentUpdateRequest.InstallType = "windows_binary" @@ -569,6 +491,9 @@ func parseMonitor(args []string) (hostagent.MonitorConfig, error) { fs.StringVar(&cfg.ClusterID, "cluster-id", getenv("RAP_CLUSTER_ID", ""), "Cluster ID.") fs.StringVar(&cfg.NodeID, "node-id", getenv("RAP_NODE_ID", ""), "Already enrolled node ID.") fs.StringVar(&cfg.StateDir, "state-dir", getenv("RAP_NODE_STATE_DIR", hostagent.DefaultStateDir), "Host path containing node-agent identity.json.") + fs.StringVar(&cfg.ClusterAuthorityPublicKey, "cluster-authority-public-key", getenv("RAP_CLUSTER_AUTHORITY_PUBLIC_KEY", ""), "Pinned Ed25519 cluster authority public key for signed fabric registry records.") + fs.StringVar(&cfg.FabricRegistryRecordsJSON, "fabric-registry-records-json", getenv("RAP_FABRIC_REGISTRY_RECORDS_JSON", ""), "JSON array of signed QUIC-only fabric registry records used to reach update/control services.") + fs.StringVar(&cfg.MeshRegion, "mesh-region", getenv("RAP_MESH_REGION", ""), "Region/site hint for fabric registry endpoint selection.") fs.StringVar(&cfg.Product, "product", getenv("RAP_MONITOR_PRODUCT", hostagent.DefaultMonitorProduct), "Status product name.") fs.StringVar(&cfg.CurrentVersion, "current-version", getenv("RAP_HOST_AGENT_VERSION", agent.Version), "Current rap-host-agent version.") fs.StringVar(&cfg.DockerBinary, "docker-binary", getenv("RAP_DOCKER_BINARY", "docker"), "Docker CLI binary.") @@ -716,6 +641,9 @@ func parseHostAgentUpdate(args []string) (hostagent.HostAgentUpdateRequest, int, fs.StringVar(&req.ClusterID, "cluster-id", getenv("RAP_CLUSTER_ID", ""), "Cluster ID.") fs.StringVar(&req.NodeID, "node-id", getenv("RAP_NODE_ID", ""), "Already enrolled node ID.") fs.StringVar(&req.StateDir, "state-dir", getenv("RAP_NODE_STATE_DIR", ""), "Host path containing node-agent identity.json.") + fs.StringVar(&req.ClusterAuthorityPublicKey, "cluster-authority-public-key", getenv("RAP_CLUSTER_AUTHORITY_PUBLIC_KEY", ""), "Pinned Ed25519 cluster authority public key for signed fabric registry records.") + fs.StringVar(&req.FabricRegistryRecordsJSON, "fabric-registry-records-json", getenv("RAP_FABRIC_REGISTRY_RECORDS_JSON", ""), "JSON array of signed QUIC-only fabric registry records used to reach update/control services.") + fs.StringVar(&req.MeshRegion, "mesh-region", getenv("RAP_MESH_REGION", ""), "Region/site hint for fabric registry endpoint selection.") fs.StringVar(&req.CurrentVersion, "current-version", getenv("RAP_HOST_AGENT_VERSION", agent.Version), "Currently installed rap-host-agent version.") fs.StringVar(&req.Channel, "channel", getenv("RAP_UPDATE_CHANNEL", ""), "Optional update channel override.") fs.StringVar(&req.OS, "os", getenv("RAP_HOST_AGENT_UPDATE_OS", runtime.GOOS), "Host-agent artifact OS selector.") @@ -739,6 +667,9 @@ func registerUpdateFlags(fs *flag.FlagSet, req *hostagent.UpdateRequest, healthT fs.StringVar(&req.ClusterID, "cluster-id", getenv("RAP_CLUSTER_ID", ""), "Cluster ID.") fs.StringVar(&req.NodeID, "node-id", getenv("RAP_NODE_ID", ""), "Already enrolled node ID.") fs.StringVar(&req.StateDir, "state-dir", getenv("RAP_NODE_STATE_DIR", ""), "Host path containing node-agent identity.json; used when node-id is not known yet.") + fs.StringVar(&req.ClusterAuthorityPublicKey, "cluster-authority-public-key", getenv("RAP_CLUSTER_AUTHORITY_PUBLIC_KEY", ""), "Pinned Ed25519 cluster authority public key for signed fabric registry records.") + fs.StringVar(&req.FabricRegistryRecordsJSON, "fabric-registry-records-json", getenv("RAP_FABRIC_REGISTRY_RECORDS_JSON", ""), "JSON array of signed QUIC-only fabric registry records used to reach update/control services.") + fs.StringVar(&req.MeshRegion, "mesh-region", getenv("RAP_MESH_REGION", ""), "Region/site hint for fabric registry endpoint selection.") fs.StringVar(&req.Product, "product", getenv("RAP_UPDATE_PRODUCT", hostagent.DefaultUpdateProduct), "Update product name.") fs.StringVar(&req.CurrentVersion, "current-version", getenv("RAP_NODE_AGENT_VERSION", agent.Version), "Currently running product version.") fs.StringVar(&req.OS, "os", getenv("RAP_UPDATE_OS", runtime.GOOS), "Artifact OS selector.") @@ -797,16 +728,15 @@ func parseInstall(args []string) (installCommandConfig, error) { fs.IntVar(&autoUpdate.MonitorDiskCritical, "monitor-disk-critical-percent", getenvInt("RAP_MONITOR_DISK_CRITICAL_PERCENT", hostagent.DefaultMonitorDiskCriticalPercent), "Disk used percent that reports failure after cleanup.") fs.BoolVar(&autoUpdate.MonitorCleanupDocker, "monitor-cleanup-docker", getenvBool("RAP_MONITOR_CLEANUP_DOCKER", true), "Run safe docker prune cleanup when disk is above cleanup threshold.") fs.BoolVar(&cfg.WorkloadSupervisionEnabled, "workload-supervision-enabled", getenvBool("RAP_WORKLOAD_SUPERVISION_ENABLED", false), "Enable node-agent workload status reporting.") - fs.BoolVar(&cfg.MeshSyntheticRuntimeEnabled, "mesh-synthetic-runtime-enabled", getenvBool("RAP_MESH_SYNTHETIC_RUNTIME_ENABLED", false), "Enable synthetic mesh runtime.") + fs.BoolVar(&cfg.MeshSyntheticRuntimeEnabled, "mesh-synthetic-runtime-enabled", getenvBool("RAP_MESH_SYNTHETIC_RUNTIME_ENABLED", false), "Enable historical synthetic mesh runtime.") fs.BoolVar(&cfg.MeshProductionForwardingEnabled, "mesh-production-forwarding-enabled", getenvBool("RAP_MESH_PRODUCTION_FORWARDING_ENABLED", false), "Enable production forwarding gate; runtime still fail-closed if unavailable.") - fs.BoolVar(&cfg.MeshFabricSessionEnabled, "mesh-fabric-session-enabled", getenvBool("RAP_MESH_FABRIC_SESSION_ENABLED", false), "Enable authenticated fabric session endpoint.") fs.BoolVar(&cfg.VPNFabricSessionTransportEnabled, "vpn-fabric-session-transport-enabled", getenvBool("RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED", false), "Route VPN packet transport over persistent fabric sessions.") fs.BoolVar(&cfg.MeshQUICFabricEnabled, "mesh-quic-fabric-enabled", getenvBool("RAP_MESH_QUIC_FABRIC_ENABLED", false), "Enable QUIC/UDP fabric listener.") fs.StringVar(&cfg.MeshQUICFabricListenAddr, "mesh-quic-fabric-listen-addr", getenv("RAP_MESH_QUIC_FABRIC_LISTEN_ADDR", ""), "QUIC/UDP fabric listen address.") fs.IntVar(&cfg.VPNFabricSessionStreamShards, "vpn-fabric-session-stream-shards", getenvInt("RAP_VPN_FABRIC_SESSION_STREAM_SHARDS", 4), "VPN fabric-session stream shards per traffic class.") fs.IntVar(&cfg.VPNFabricQUICMaxStreamsPerConn, "vpn-fabric-quic-max-streams-per-conn", getenvInt("RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN", 64), "Maximum logical fabric-session streams per cached VPN QUIC carrier connection.") fs.IntVar(&cfg.VPNFabricQUICIdleTTLSeconds, "vpn-fabric-quic-idle-ttl-seconds", getenvInt("RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS", 300), "Idle TTL seconds for cached VPN QUIC carrier connections.") - fs.StringVar(&cfg.MeshListenAddr, "mesh-listen-addr", getenv("RAP_MESH_LISTEN_ADDR", ""), "Synthetic mesh HTTP listen address inside container.") + fs.StringVar(&cfg.MeshListenAddr, "mesh-listen-addr", getenv("RAP_MESH_LISTEN_ADDR", ""), "Historical synthetic mesh HTTP listen address inside container.") fs.StringVar(&cfg.MeshListenPortMode, "mesh-listen-port-mode", getenv("RAP_MESH_LISTEN_PORT_MODE", ""), "Mesh listen port behavior: manual, auto, or disabled.") fs.IntVar(&cfg.MeshListenAutoPortStart, "mesh-listen-auto-port-start", getenvInt("RAP_MESH_LISTEN_AUTO_PORT_START", 0), "First port used when mesh listen port mode is auto.") fs.IntVar(&cfg.MeshListenAutoPortEnd, "mesh-listen-auto-port-end", getenvInt("RAP_MESH_LISTEN_AUTO_PORT_END", 0), "Last port used when mesh listen port mode is auto.") @@ -941,13 +871,12 @@ func usage() { rap-host-agent install -backend-url URL -cluster-id ID -join-token TOKEN -node-name NAME [docker options] rap-host-agent install-windows -profile-url URL -install-token TOKEN [-node-name NAME] [windows options] rap-host-agent install-linux -profile-url URL -install-token TOKEN [-node-name NAME] [linux/systemd options] - rap-host-agent install-updater -backend-url URL -cluster-id ID -state-dir DIR -container-name NAME - rap-host-agent update-host-agent -backend-url URL -cluster-id ID -state-dir DIR - rap-host-agent update-host-agent-loop -backend-url URL -cluster-id ID -state-dir DIR - rap-host-agent monitor-loop -backend-url URL -cluster-id ID -state-dir DIR --watch-container NAME - rap-host-agent monitor-once -backend-url URL -cluster-id ID -state-dir DIR --watch-container NAME - rap-host-agent fabric-session-smoke -mesh-url URL -token rap_fsn_TOKEN [-authority-payload VALUE -authority-signature VALUE] - rap-host-agent update -backend-url URL -cluster-id ID -node-id ID [-container-name NAME] - rap-host-agent update-loop -backend-url URL -cluster-id ID -node-id ID [-container-name NAME] + rap-host-agent install-updater (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -state-dir DIR -container-name NAME + rap-host-agent update-host-agent (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -state-dir DIR + rap-host-agent update-host-agent-loop (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -state-dir DIR + rap-host-agent monitor-loop (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -state-dir DIR --watch-container NAME + rap-host-agent monitor-once (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -state-dir DIR --watch-container NAME + rap-host-agent update (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -node-id ID [-container-name NAME] + rap-host-agent update-loop (-backend-url URL | -fabric-registry-records-json JSON) -cluster-id ID -node-id ID [-container-name NAME] rap-host-agent status [-container-name NAME]`) } 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 c97940a..fa7ec5a 100644 --- a/agents/rap-node-agent/cmd/rap-node-agent/main.go +++ b/agents/rap-node-agent/cmd/rap-node-agent/main.go @@ -2,12 +2,14 @@ package main import ( "context" + "crypto/ed25519" "crypto/rand" "crypto/rsa" "crypto/sha256" "crypto/tls" "crypto/x509" "crypto/x509/pkix" + "encoding/base64" "encoding/hex" "encoding/json" "encoding/pem" @@ -47,6 +49,7 @@ const ( meshRendezvousLeaseRenewalWindow = time.Minute meshRendezvousLeaseRefreshBackoff = 30 * time.Second meshSyntheticConfigRefreshInterval = 20 * time.Second + fabricRegistryLiveProbeInterval = 20 * time.Second meshRouteHealthFeedbackRefreshBackoff = 5 * time.Second maxMeshRendezvousLeaseReportEntries = 20 maxVPNFabricEndpointHealthReportEntries = 32 @@ -134,7 +137,7 @@ func main() { if meshState != nil && meshState.RemoteWorkspaceFrameSink != nil { telemetry.Payload["remote_workspace_adapter_sink_report"] = meshState.RemoteWorkspaceFrameSink.Report(time.Now().UTC()) } - if err := api.ReportTelemetry(ctx, identity.ClusterID, identity.NodeID, telemetry); err != nil { + if err := reportTelemetry(ctx, api, identity, meshState, telemetry); err != nil { log.Printf("telemetry failed: %v", err) } else { log.Printf("telemetry sent: node_id=%s cluster_id=%s scopes=%v", identity.NodeID, identity.ClusterID, flags.AppliedScopes) @@ -148,12 +151,12 @@ func main() { if err := ensureVPNGatewayRuntime(ctx, api, cfg, identity, vpnGateway, meshState); err != nil { log.Printf("vpn gateway runtime failed: %v", err) } - if err := reportVPNAssignmentStatus(ctx, api, identity, vpnGateway); err != nil { + if err := reportVPNAssignmentStatus(ctx, api, identity, vpnGateway, meshState); err != nil { log.Printf("vpn assignment status failed: %v", err) } logProductionObservationSinkMetrics(meshState) if flags.Enabled && flags.SyntheticLinksEnabled { - if err := api.ReportMeshLink(ctx, identity.ClusterID, agent.MeshSelfObservationPayload(identity)); err != nil { + if err := reportMeshLink(ctx, api, identity, meshState, agent.MeshSelfObservationPayload(identity)); err != nil { log.Printf("mesh self-observation failed: %v", err) } else { log.Printf("mesh self-observation sent: node_id=%s cluster_id=%s scopes=%v", identity.NodeID, identity.ClusterID, flags.AppliedScopes) @@ -170,6 +173,7 @@ func main() { if err := probeWarmPeerHealth(ctx, api, identity, meshState); err != nil { log.Printf("mesh warm peer health failed: %v", err) } + verifyFabricRegistryCandidatesIfDue(ctx, cfg, identity, meshState, time.Now().UTC()) } select { case <-ctx.Done(): @@ -357,83 +361,86 @@ func verifyEnrollmentBootstrap(bootstrap client.NodeBootstrap, identity state.Id } type syntheticMeshState struct { - Runtime *mesh.SyntheticRuntime - Routes []mesh.SyntheticRoute - RouteHealthRoutes []mesh.SyntheticRoute - Source string - PeerCache *mesh.PeerCache - RendezvousLeases []mesh.PeerRendezvousLease - RoutePathDecisions *client.RoutePathDecisionReport - ServiceChannelFeedback *client.FabricServiceChannelFeedbackReport - ServiceChannelAdaptivePolicy *client.FabricServiceChannelAdaptivePolicy - ServiceChannelRemediationCommands []client.FabricServiceChannelRemediationCommand - RouteGenerationTracker *meshRouteGenerationTracker - ConfigVersion string - PeerDirectoryVersion string - PolicyVersion string - PeerConnections *mesh.PeerConnectionTracker - PeerConnectionManager *mesh.PeerConnectionManager - LastPeerRecoveryPlan *mesh.PeerRecoveryPlan - LastPeerConnectionIntent *mesh.PeerConnectionIntentPlan - LastConfigRefreshAt time.Time - LastLeaseRefresh *meshRendezvousLeaseRefreshState - LeaseRefreshAttempts int - LeaseRefreshSuccesses int - LeaseRefreshFailures int - LastRouteHealthRefresh *meshRouteHealthFeedbackRefreshState - RouteHealthRefreshAttempts int - RouteHealthRefreshSuccesses int - RouteHealthRefreshFailures int - RouteHealthRefreshSuppressed int - ProductionObservationSink *mesh.ProductionEnvelopeObservationSink - ProductionForwardTransport mesh.ProductionForwardTransport - ProductionForwardingEnabled bool - SyntheticForwardTransport *mesh.QUICSyntheticTransport - VPNFabricInbox *vpnruntime.FabricPacketInbox - VPNFabricIngress *vpnruntime.FabricClientPacketIngress - VPNPacketSessionPeers *vpnruntime.FabricSessionPacketPeerRegistry - VPNFabricSessionPeers *mesh.FabricSessionPeerManager - VPNFabricQUICTransport *mesh.QUICFabricTransport - VPNFabricSessionDialStats *vpnFabricSessionDialStats - VPNFabricEndpointObservations *vpnFabricEndpointObservationStore - PeerEndpoints map[string]string - PeerEndpointCandidates map[string][]mesh.PeerEndpointCandidate - PeerEndpointObservations map[string]mesh.EndpointCandidateHealthObservation - VPNGateway *vpnruntime.Gateway - ServiceChannelAccessStats *fabricServiceChannelAccessStats - RemoteWorkspaceFrameSink *mesh.RemoteWorkspaceFrameProbeSink - LastProductionSinkMetrics *mesh.ProductionEnvelopeObservationSinkMetrics - ListenerReport meshListenerReport - ListenerConfigKey string - ListenerRuntimeConfig config.Config - ListenerHandler *dynamicHTTPHandler - StopListener func() - QUICFabricServer *mesh.QUICFabricServer - QUICFabricConfiguredKey string - QUICFabricConfiguredListenAddr string - QUICFabricListenAddr string - QUICFabricCertSHA256 string - QUICFabricError string - ConfigLoadError string + Runtime *mesh.SyntheticRuntime + Routes []mesh.SyntheticRoute + RouteHealthRoutes []mesh.SyntheticRoute + Source string + PeerCache *mesh.PeerCache + RendezvousLeases []mesh.PeerRendezvousLease + RoutePathDecisions *client.RoutePathDecisionReport + ServiceChannelFeedback *client.FabricServiceChannelFeedbackReport + ServiceChannelAdaptivePolicy *client.FabricServiceChannelAdaptivePolicy + ServiceChannelRemediationCommands []client.FabricServiceChannelRemediationCommand + RouteGenerationTracker *meshRouteGenerationTracker + ConfigVersion string + PeerDirectoryVersion string + PolicyVersion string + PeerConnections *mesh.PeerConnectionTracker + PeerConnectionManager *mesh.PeerConnectionManager + LastPeerRecoveryPlan *mesh.PeerRecoveryPlan + LastPeerConnectionIntent *mesh.PeerConnectionIntentPlan + LastConfigRefreshAt time.Time + LastLeaseRefresh *meshRendezvousLeaseRefreshState + LeaseRefreshAttempts int + LeaseRefreshSuccesses int + LeaseRefreshFailures int + LastRouteHealthRefresh *meshRouteHealthFeedbackRefreshState + RouteHealthRefreshAttempts int + RouteHealthRefreshSuccesses int + RouteHealthRefreshFailures int + RouteHealthRefreshSuppressed int + ProductionObservationSink *mesh.ProductionEnvelopeObservationSink + ProductionForwardTransport mesh.ProductionForwardTransport + ProductionForwardingEnabled bool + SyntheticForwardTransport *mesh.QUICSyntheticTransport + VPNFabricInbox *vpnruntime.FabricPacketInbox + VPNFabricIngress *vpnruntime.FabricClientPacketIngress + VPNPacketSessionPeers *vpnruntime.FabricSessionPacketPeerRegistry + VPNFabricQUICTransport *mesh.QUICFabricTransport + VPNFabricSessionDialStats *vpnFabricSessionDialStats + VPNFabricEndpointObservations *vpnFabricEndpointObservationStore + PeerEndpoints map[string]string + PeerEndpointCandidates map[string][]mesh.PeerEndpointCandidate + PeerEndpointObservations map[string]mesh.EndpointCandidateHealthObservation + VPNGateway *vpnruntime.Gateway + ServiceChannelAccessStats *fabricServiceChannelAccessStats + RemoteWorkspaceFrameSink *mesh.RemoteWorkspaceFrameProbeSink + LastProductionSinkMetrics *mesh.ProductionEnvelopeObservationSinkMetrics + FabricRegistry *mesh.FabricRegistry + FabricRegistryBootstrapReport mesh.FabricRegistryBootstrapReport + LastFabricRegistryLiveProbeAt time.Time + LastFabricRegistryLiveProbeResults []mesh.FabricRegistryLiveProbeResult + ListenerReport meshListenerReport + ListenerConfigKey string + ListenerRuntimeConfig config.Config + ListenerHandler *dynamicHTTPHandler + StopListener func() + QUICFabricServer *mesh.QUICFabricServer + QUICFabricConfiguredKey string + QUICFabricConfiguredListenAddr string + QUICFabricListenAddr string + QUICFabricCertSHA256 string + QUICFabricError string + ConfigLoadError string } type fabricServiceChannelAccessStats struct { - Total atomic.Int64 - Signed atomic.Int64 - Introspection atomic.Int64 - LegacyUnsigned atomic.Int64 - BackendFallback atomic.Int64 - BackendFallbackBlocked atomic.Int64 - FabricRouteSendFailure atomic.Int64 - DataPlaneContract atomic.Int64 - LastAcceptedUnixSec atomic.Int64 - LastDataPlaneMode atomic.Value - LastWorkingData atomic.Value - LastSteadyState atomic.Value - LastBackendRelay atomic.Value - LastLogicalFlowMode atomic.Value - LastViolationStatus atomic.Value - LastViolationReason atomic.Value + Total atomic.Int64 + Signed atomic.Int64 + Introspection atomic.Int64 + TokenAuthorized atomic.Int64 + DegradedCompatibilityRequested atomic.Int64 + DegradedCompatibilityBlocked atomic.Int64 + FabricRouteSendFailure atomic.Int64 + DataPlaneContract atomic.Int64 + LastAcceptedUnixSec atomic.Int64 + LastDataPlaneMode atomic.Value + LastWorkingData atomic.Value + LastSteadyState atomic.Value + LastBackendRelay atomic.Value + LastLogicalFlowMode atomic.Value + LastViolationStatus atomic.Value + LastViolationReason atomic.Value } type vpnFabricSessionDialStats struct { @@ -751,7 +758,7 @@ func (s *vpnFabricSessionDialStats) ObserveCapacityLimited(target mesh.FabricTra s.LastCapacityEndpoint.Store(endpoint) transport := strings.TrimSpace(target.Transport) if transport == "" { - transport = "legacy_peer_endpoint" + transport = "non_quic_peer_endpoint" } s.LastCapacityTransport.Store(transport) observedAt := time.Now().UTC().Unix() @@ -1042,6 +1049,16 @@ func newFabricServiceChannelAccessStats() *fabricServiceChannelAccessStats { return &fabricServiceChannelAccessStats{} } +func normalizeFabricServiceChannelViolationStatus(status string) string { + status = strings.TrimSpace(status) + switch status { + case "backend_fallback_blocked_by_policy", "fabric_route_send_failed_backend_fallback_blocked": + return "degraded_compatibility_blocked" + default: + return status + } +} + func (s *fabricServiceChannelAccessStats) Observe(entry mesh.FabricServiceChannelAccessLogEntry) { if s == nil { return @@ -1052,17 +1069,17 @@ func (s *fabricServiceChannelAccessStats) Observe(entry mesh.FabricServiceChanne s.Signed.Add(1) case "introspection": s.Introspection.Add(1) - case "legacy_unsigned": - s.LegacyUnsigned.Add(1) + case "token_authorized": + s.TokenAuthorized.Add(1) } if entry.ForceBackendFallback && strings.TrimSpace(entry.BackendRelayPolicy) != "disabled" { - s.BackendFallback.Add(1) + s.DegradedCompatibilityRequested.Add(1) } switch strings.TrimSpace(entry.ViolationStatus) { case "backend_fallback_blocked_by_policy": - s.BackendFallbackBlocked.Add(1) + s.DegradedCompatibilityBlocked.Add(1) case "fabric_route_send_failed_backend_fallback_blocked": - s.BackendFallbackBlocked.Add(1) + s.DegradedCompatibilityBlocked.Add(1) s.FabricRouteSendFailure.Add(1) } if strings.TrimSpace(entry.ViolationStatus) != "" { @@ -1092,19 +1109,19 @@ func (s *fabricServiceChannelAccessStats) Report(observedAt time.Time) map[strin observedAt = time.Now().UTC() } report := map[string]any{ - "schema_version": "c18z52.fabric_service_channel_access_report.v1", - "observed_at": observedAt.UTC().Format(time.RFC3339Nano), - "total": s.Total.Load(), - "signed": s.Signed.Load(), - "introspection": s.Introspection.Load(), - "legacy_unsigned": s.LegacyUnsigned.Load(), - "backend_fallback": s.BackendFallback.Load(), - "backend_fallback_blocked": s.BackendFallbackBlocked.Load(), - "fabric_route_send_failure": s.FabricRouteSendFailure.Load(), - "data_plane_contract": s.DataPlaneContract.Load(), - "accepted_by_signed": s.Signed.Load(), - "accepted_by_introspection": s.Introspection.Load(), - "accepted_by_legacy_unsigned": s.LegacyUnsigned.Load(), + "schema_version": "c18z52.fabric_service_channel_access_report.v1", + "observed_at": observedAt.UTC().Format(time.RFC3339Nano), + "total": s.Total.Load(), + "signed": s.Signed.Load(), + "introspection": s.Introspection.Load(), + "token_authorized": s.TokenAuthorized.Load(), + "degraded_compatibility_requested": s.DegradedCompatibilityRequested.Load(), + "degraded_compatibility_blocked": s.DegradedCompatibilityBlocked.Load(), + "fabric_route_send_failure": s.FabricRouteSendFailure.Load(), + "data_plane_contract": s.DataPlaneContract.Load(), + "accepted_by_signed": s.Signed.Load(), + "accepted_by_introspection": s.Introspection.Load(), + "accepted_by_token_authorized": s.TokenAuthorized.Load(), } if value, ok := s.LastDataPlaneMode.Load().(string); ok && value != "" { report["last_data_plane_mode"] = value @@ -1122,7 +1139,10 @@ func (s *fabricServiceChannelAccessStats) Report(observedAt time.Time) map[strin report["last_logical_flow_mode"] = value } if value, ok := s.LastViolationStatus.Load().(string); ok && value != "" { - report["last_data_plane_violation_status"] = value + report["last_data_plane_violation_status"] = normalizeFabricServiceChannelViolationStatus(value) + if normalized := normalizeFabricServiceChannelViolationStatus(value); normalized != value { + report["last_data_plane_violation_status_raw"] = value + } } if value, ok := s.LastViolationReason.Load().(string); ok && value != "" { report["last_data_plane_violation_reason"] = value @@ -1265,9 +1285,70 @@ type loadedSyntheticMeshConfig struct { ProductionForwarding bool } +func loadFabricRegistryBootstrap(cfg config.Config, identity state.Identity) (*mesh.FabricRegistry, mesh.FabricRegistryBootstrapReport) { + registry := mesh.NewFabricRegistry() + if strings.TrimSpace(cfg.FabricRegistryRecordsJSON) == "" { + return registry, mesh.FabricRegistryBootstrapReport{} + } + publicKey, err := decodeEd25519PublicKey(firstNonEmpty(identity.ClusterAuthorityPublicKey, cfg.ClusterAuthorityPublicKey)) + if err != nil { + log.Printf("fabric registry bootstrap skipped: cluster authority key unavailable or invalid: %v", err) + return registry, mesh.FabricRegistryBootstrapReport{Rejected: 1, Rejects: []string{err.Error()}} + } + policy := mesh.FabricRegistryVerificationPolicy{ + LocalClusterID: identity.ClusterID, + TrustedIssuers: []mesh.FabricRegistryTrustedIssuer{ + { + IssuerID: "cluster-authority", + Role: mesh.FabricRegistryAuthorityControl, + PublicKey: publicKey, + Scopes: []string{mesh.FabricRegistryScopeFarm, mesh.FabricRegistryScopeCluster, mesh.FabricRegistryScopeOrganization}, + Services: []string{mesh.FabricRegistryServiceControlAPI, mesh.FabricRegistryServiceUpdateStore, mesh.FabricRegistryServiceUpdateCache, mesh.FabricRegistryServiceWebAdmin, mesh.FabricRegistryServiceVPNExitPool}, + }, + }, + RequiredSignatures: 1, + MaxClockSkew: 2 * time.Minute, + Now: time.Now().UTC(), + } + loaded, report, err := mesh.LoadFabricRegistryBootstrapRecords(cfg.FabricRegistryRecordsJSON, policy, false) + if err != nil { + log.Printf("fabric registry bootstrap decode failed: %v", err) + return registry, mesh.FabricRegistryBootstrapReport{Rejected: 1, Rejects: []string{err.Error()}} + } + if len(report.Rejects) > 0 { + log.Printf("fabric registry bootstrap loaded with rejected records: total=%d active=%d candidates=%d rejected=%d first_error=%s", report.Total, report.Active, report.Candidate, report.Rejected, report.Rejects[0]) + } else if report.Total > 0 { + log.Printf("fabric registry bootstrap loaded: total=%d active=%d candidates=%d rejected=%d", report.Total, report.Active, report.Candidate, report.Rejected) + } + return loaded, report +} + +func decodeEd25519PublicKey(value string) (ed25519.PublicKey, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, fmt.Errorf("cluster authority public key is empty") + } + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + decoded, err = base64.RawStdEncoding.DecodeString(value) + } + if err != nil { + decoded, err = base64.RawURLEncoding.DecodeString(value) + } + if err != nil || len(decoded) != ed25519.PublicKeySize { + return nil, fmt.Errorf("cluster authority public key must be base64 Ed25519 public key") + } + return ed25519.PublicKey(decoded), nil +} + func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg config.Config, identity state.Identity, api *client.Client, vpnGateway *vpnruntime.Gateway) (*syntheticMeshState, func(), error) { noop := func() {} - if !cfg.MeshSyntheticRuntimeEnabled { + meshRuntimeNeeded := cfg.MeshSyntheticRuntimeEnabled || + cfg.MeshQUICFabricEnabled || + cfg.MeshProductionForwardingEnabled || + cfg.VPNFabricSessionTransportEnabled || + strings.TrimSpace(cfg.WebIngressTrustedKeysJSON) != "" + if !meshRuntimeNeeded { return nil, noop, nil } local := mesh.PeerIdentity{ClusterID: identity.ClusterID, NodeID: identity.NodeID} @@ -1288,6 +1369,7 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c peerEndpoints := loadedConfig.PeerEndpoints routes := loadedConfig.Routes productionForwardingEnabled := cfg.MeshProductionForwardingEnabled || loadedConfig.ProductionForwarding + fabricRegistry, fabricRegistryReport := loadFabricRegistryBootstrap(cfg, identity) routeHealthRoutes := routeHealthRoutesFromPathDecisions(routes, loadedConfig.RoutePathDecisions) peerCache := mesh.NewPeerCache(mesh.PeerCacheConfig{ Local: local, @@ -1333,7 +1415,7 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c routeGenerationTracker := newMeshRouteGenerationTracker(loadedConfig.RoutePathDecisions, time.Now().UTC()) gateEnabled, runtimeEnabled := productionForwardingLogState(cfg, loadedConfig.ProductionForwarding) log.Printf( - "synthetic mesh config loaded: source=%s node_id=%s cluster_id=%s peers=%d routes=%d peer_cache_peers=%d warm_peers=%d recovery_seeds=%d rendezvous_leases=%d peer_connection_states=%d peer_recovery_mode=%s peer_recovery_target_ready_peers=%d peer_connection_intents=%d rendezvous_required=%d rendezvous_resolved=%d production_forwarding_gate_enabled=%t production_forwarding_runtime_enabled=%t", + "synthetic mesh config loaded: source=%s node_id=%s cluster_id=%s peers=%d routes=%d peer_cache_peers=%d warm_peers=%d recovery_seeds=%d rendezvous_leases=%d fabric_registry_active=%d fabric_registry_candidates=%d fabric_registry_rejected=%d peer_connection_states=%d peer_recovery_mode=%s peer_recovery_target_ready_peers=%d peer_connection_intents=%d rendezvous_required=%d rendezvous_resolved=%d production_forwarding_gate_enabled=%t production_forwarding_runtime_enabled=%t", loadedConfig.Source, identity.NodeID, identity.ClusterID, @@ -1343,6 +1425,9 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c peerCacheSnapshot.WarmPeerCount, peerCacheSnapshot.RecoverySeedCount, peerCacheSnapshot.RendezvousLeaseCount, + fabricRegistryReport.Active, + fabricRegistryReport.Candidate, + fabricRegistryReport.Rejected, peerConnectionSnapshot.Total, peerRecoveryPlan.Mode, peerRecoveryPlan.TargetReadyPeers, @@ -1353,7 +1438,7 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c runtimeEnabled, ) runtime := mesh.NewSyntheticRuntime(mesh.SyntheticRuntimeConfig{ - Enabled: true, + Enabled: cfg.MeshSyntheticRuntimeEnabled, Local: local, Routes: routes, RouteHealthRoutes: routeHealthRoutes, @@ -1421,15 +1506,6 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c } log.Printf("fabric_service_channel_access_event=%s", string(payload)) }, - FabricSessionEnabled: cfg.MeshFabricSessionEnabled, - FabricSessionLogger: func(entry mesh.FabricSessionEventLogEntry) { - payload, err := json.Marshal(entry) - if err != nil { - log.Printf("fabric session event marshal failed: %v", err) - return - } - log.Printf("fabric_session_event=%s", string(payload)) - }, RemoteWorkspaceFrameSink: remoteWorkspaceFrameSink, ProductionRoutes: routes, VPNPacketIngress: vpnFabricIngress, @@ -1440,7 +1516,6 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c dynamicListenerHandler := newDynamicHTTPHandler(serverHandler) listenerCfg := meshListenerRuntimeConfig(cfg, loadedConfig.MeshListener) listenerReport, stopListener := startSyntheticMeshHTTPServer(ctx, listenerCfg, identity, dynamicListenerHandler, len(peerEndpoints), len(routes), gateEnabled, runtimeEnabled) - vpnFabricSessionPeers := mesh.NewFabricSessionPeerManager() meshState := &syntheticMeshState{ Runtime: runtime, Routes: routes, @@ -1467,7 +1542,6 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c VPNFabricInbox: vpnFabricInbox, VPNFabricIngress: vpnFabricIngress, VPNPacketSessionPeers: vpnruntime.NewFabricSessionPacketPeerRegistry(), - VPNFabricSessionPeers: vpnFabricSessionPeers, VPNFabricQUICTransport: vpnFabricQUICTransport, VPNFabricSessionDialStats: newVPNFabricSessionDialStats(), VPNFabricEndpointObservations: newVPNFabricEndpointObservationStore(identity.NodeID), @@ -1477,6 +1551,8 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c VPNGateway: vpnGateway, ServiceChannelAccessStats: serviceChannelAccessStats, RemoteWorkspaceFrameSink: remoteWorkspaceFrameSink, + FabricRegistry: fabricRegistry, + FabricRegistryBootstrapReport: fabricRegistryReport, ListenerReport: listenerReport, ListenerConfigKey: meshListenerConfigKey(listenerCfg), ListenerRuntimeConfig: listenerCfg, @@ -1486,7 +1562,7 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c } vpnFabricQUICTransport.SetInboundHandlersWithWebIngress( productionForwardHandlerFromMeshState(identity, meshState), - webIngressForwardHandlerFromConfig(cfg, identity, api), + webIngressForwardHandlerFromConfig(cfg, identity, api, meshState), syntheticForwardHandlerFromMeshState(meshState), func(entry mesh.FabricSessionEventLogEntry) { payload, err := json.Marshal(entry) @@ -1498,7 +1574,7 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c }, ) vpnFabricQUICTransport.SetInboundFabricControlHandler(fabricControlForwardHandler(api)) - quicFabricServer, quicFabricAddr, quicFabricCertSHA256, quicFabricErr := startQUICFabricEndpoint(ctx, cfg, identity, vpnFabricQUICTransport, vpnFabricFrameHandlerFromMeshState(meshState), productionForwardHandlerFromMeshState(identity, meshState), webIngressForwardHandlerFromConfig(cfg, identity, api), fabricControlForwardHandler(api), syntheticForwardHandlerFromMeshState(meshState)) + quicFabricServer, quicFabricAddr, quicFabricCertSHA256, quicFabricErr := startQUICFabricEndpoint(ctx, cfg, identity, vpnFabricQUICTransport, vpnFabricFrameHandlerFromMeshState(meshState), productionForwardHandlerFromMeshState(identity, meshState), webIngressForwardHandlerFromConfig(cfg, identity, api, meshState), fabricControlForwardHandler(api), syntheticForwardHandlerFromMeshState(meshState)) meshState.QUICFabricServer = quicFabricServer meshState.QUICFabricConfiguredKey = quicFabricConfigKey(cfg) meshState.QUICFabricConfiguredListenAddr = strings.TrimSpace(cfg.MeshQUICFabricListenAddr) @@ -1667,7 +1743,7 @@ func routeManagerDecisionsFromControlPlane(report *client.RoutePathDecisionRepor continue } } - rebuildStatus := "pending_degraded_fallback" + rebuildStatus := "pending_degraded_route_state" if action == "prefer_alternate_route" { if replacementRouteID == "" || primaryRouteID == replacementRouteID { continue @@ -1707,6 +1783,10 @@ func startSyntheticMeshHTTPServer(ctx context.Context, cfg config.Config, identi OneWayConnectivity: true, ObservedAt: now.Format(time.RFC3339Nano), } + if !cfg.MeshSyntheticRuntimeEnabled { + baseReport.FailureReason = "synthetic_runtime_disabled" + return baseReport, func() {} + } if mode == "disabled" || strings.TrimSpace(cfg.MeshListenAddr) == "" { if strings.TrimSpace(cfg.MeshListenAddr) == "" { baseReport.FailureReason = "listen_addr_empty" @@ -1801,7 +1881,6 @@ func meshListenerRuntimeConfig(base config.Config, desired *client.MeshListenerC out.MeshAdvertiseTransport = strings.TrimSpace(desired.AdvertiseTransport) } if fabricTransportLabelIsQUIC(out.MeshAdvertiseTransport) { - out.MeshFabricSessionEnabled = true out.MeshQUICFabricEnabled = true if strings.TrimSpace(out.MeshQUICFabricListenAddr) == "" { out.MeshQUICFabricListenAddr = out.MeshListenAddr @@ -2075,30 +2154,7 @@ func loadSyntheticMeshConfig(ctx context.Context, cfg config.Config, identity st } } if err == nil && remote.Enabled { - loaded := loadedSyntheticMeshConfig{ - PeerEndpoints: remote.PeerEndpoints, - PeerEndpointCandidates: peerEndpointCandidatesFromControlPlane(remote.PeerEndpointCandidates), - PeerEndpointObservations: endpointCandidateObservationsFromControlPlane(remote.PeerEndpointObservations), - PeerDirectory: peerDirectoryFromControlPlane(remote.PeerDirectory), - RecoverySeeds: recoverySeedsFromControlPlane(remote.RecoverySeeds), - RendezvousLeases: rendezvousLeasesFromControlPlane(remote.RendezvousLeases), - RoutePathDecisions: remote.RoutePathDecisions, - ServiceChannelFeedback: remote.ServiceChannelFeedback, - ServiceChannelAdaptivePolicy: remote.ServiceChannelAdaptivePolicy, - ServiceChannelRemediationCommands: append([]client.FabricServiceChannelRemediationCommand{}, remote.ServiceChannelRemediationCommands...), - MeshListener: remote.MeshListener, - Routes: syntheticRoutesFromControlPlane(remote.Routes), - Source: "control_plane", - ConfigVersion: remote.ConfigVersion, - PeerDirectoryVersion: remote.PeerDirectoryVersion, - PolicyVersion: remote.PolicyVersion, - ProductionForwarding: remote.ProductionForwarding, - } - normalizeLoadedSyntheticMeshConfigQUICOnly(&loaded) - if err := validateLoadedSyntheticMeshConfigQUICOnly(loaded); err != nil { - return loadedSyntheticMeshConfig{}, err - } - return loaded, nil + return loadedSyntheticMeshConfigFromRemote(remote) } if err != nil { log.Printf("control-plane synthetic mesh config unavailable, falling back to debug JSON: %v", err) @@ -2124,6 +2180,72 @@ func loadSyntheticMeshConfig(ctx context.Context, cfg config.Config, identity st return loaded, nil } +func loadSyntheticMeshConfigRuntime(ctx context.Context, cfg config.Config, identity state.Identity, api *client.Client, meshState *syntheticMeshState) (loadedSyntheticMeshConfig, error) { + if cfg.MeshSyntheticConfigPath != "" { + return loadSyntheticMeshConfig(ctx, cfg, identity, api) + } + remote, viaFabric, err := syntheticMeshConfigViaFabricControl(ctx, identity, meshState) + if viaFabric && err == nil { + if verifyErr := verifyControlPlaneSyntheticMeshConfig(remote, identity, cfg); verifyErr != nil { + return loadedSyntheticMeshConfig{}, verifyErr + } + if remote.Enabled { + return loadedSyntheticMeshConfigFromRemote(remote) + } + } + if viaFabric && err != nil { + log.Printf("fabric-control synthetic mesh config unavailable, falling back to migration control API: %v", err) + } + return loadSyntheticMeshConfig(ctx, cfg, identity, api) +} + +func syntheticMeshConfigViaFabricControl(ctx context.Context, identity state.Identity, meshState *syntheticMeshState) (client.SyntheticMeshConfig, bool, error) { + raw, viaFabric, err := fabricControlRawViaRegistry(ctx, identity, meshState, client.RawControlRequest{ + Method: http.MethodGet, + Path: fmt.Sprintf("/clusters/%s/nodes/%s/mesh/synthetic-config", url.PathEscape(identity.ClusterID), url.PathEscape(identity.NodeID)), + }) + if err != nil || !viaFabric { + return client.SyntheticMeshConfig{}, viaFabric, err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return client.SyntheticMeshConfig{}, true, fmt.Errorf("fabric control synthetic mesh config returned status %d", raw.StatusCode) + } + var response struct { + Config client.SyntheticMeshConfig `json:"synthetic_mesh_config"` + } + if err := json.Unmarshal(raw.Body, &response); err != nil { + return client.SyntheticMeshConfig{}, true, err + } + return response.Config, true, nil +} + +func loadedSyntheticMeshConfigFromRemote(remote client.SyntheticMeshConfig) (loadedSyntheticMeshConfig, error) { + loaded := loadedSyntheticMeshConfig{ + PeerEndpoints: remote.PeerEndpoints, + PeerEndpointCandidates: peerEndpointCandidatesFromControlPlane(remote.PeerEndpointCandidates), + PeerEndpointObservations: endpointCandidateObservationsFromControlPlane(remote.PeerEndpointObservations), + PeerDirectory: peerDirectoryFromControlPlane(remote.PeerDirectory), + RecoverySeeds: recoverySeedsFromControlPlane(remote.RecoverySeeds), + RendezvousLeases: rendezvousLeasesFromControlPlane(remote.RendezvousLeases), + RoutePathDecisions: remote.RoutePathDecisions, + ServiceChannelFeedback: remote.ServiceChannelFeedback, + ServiceChannelAdaptivePolicy: remote.ServiceChannelAdaptivePolicy, + ServiceChannelRemediationCommands: append([]client.FabricServiceChannelRemediationCommand{}, remote.ServiceChannelRemediationCommands...), + MeshListener: remote.MeshListener, + Routes: syntheticRoutesFromControlPlane(remote.Routes), + Source: "control_plane", + ConfigVersion: remote.ConfigVersion, + PeerDirectoryVersion: remote.PeerDirectoryVersion, + PolicyVersion: remote.PolicyVersion, + ProductionForwarding: remote.ProductionForwarding, + } + normalizeLoadedSyntheticMeshConfigQUICOnly(&loaded) + if err := validateLoadedSyntheticMeshConfigQUICOnly(loaded); err != nil { + return loadedSyntheticMeshConfig{}, err + } + return loaded, nil +} + func normalizeLoadedSyntheticMeshConfigQUICOnly(loaded *loadedSyntheticMeshConfig) { if loaded == nil { return @@ -2394,7 +2516,7 @@ func refreshRendezvousLeasesIfNeeded(ctx context.Context, cfg config.Config, ide } local := mesh.PeerIdentity{ClusterID: identity.ClusterID, NodeID: identity.NodeID} - loadedConfig, err := loadSyntheticMeshConfig(ctx, cfg, identity, api) + loadedConfig, err := loadSyntheticMeshConfigRuntime(ctx, cfg, identity, api, meshState) completedAt := time.Now().UTC() refresh.CompletedAt = completedAt if err != nil { @@ -2434,7 +2556,7 @@ func refreshSyntheticMeshConfigIfDue(ctx context.Context, cfg config.Config, ide return nil } local := mesh.PeerIdentity{ClusterID: identity.ClusterID, NodeID: identity.NodeID} - loadedConfig, err := loadSyntheticMeshConfig(ctx, cfg, identity, api) + loadedConfig, err := loadSyntheticMeshConfigRuntime(ctx, cfg, identity, api, meshState) completedAt := time.Now().UTC() if err != nil { meshState.LastConfigRefreshAt = observedAt @@ -2496,7 +2618,7 @@ func refreshSyntheticMeshConfigForRouteHealthFeedback(ctx context.Context, cfg c } local := mesh.PeerIdentity{ClusterID: identity.ClusterID, NodeID: identity.NodeID} - loadedConfig, err := loadSyntheticMeshConfig(ctx, cfg, identity, api) + loadedConfig, err := loadSyntheticMeshConfigRuntime(ctx, cfg, identity, api, meshState) completedAt := time.Now().UTC() refresh.CompletedAt = completedAt if err != nil { @@ -2573,18 +2695,13 @@ func applyRefreshedSyntheticMeshConfig(ctx context.Context, cfg config.Config, i } productionForwardingEnabled := cfg.MeshProductionForwardingEnabled || loadedConfig.ProductionForwarding meshState.ProductionForwardingEnabled = productionForwardingEnabled - if (!sameStringMap(meshState.PeerEndpoints, loadedConfig.PeerEndpoints) || !samePeerEndpointCandidatesMap(meshState.PeerEndpointCandidates, loadedConfig.PeerEndpointCandidates)) && meshState.VPNFabricSessionPeers != nil { - _ = meshState.VPNFabricSessionPeers.Close() + if !sameStringMap(meshState.PeerEndpoints, loadedConfig.PeerEndpoints) || !samePeerEndpointCandidatesMap(meshState.PeerEndpointCandidates, loadedConfig.PeerEndpointCandidates) { if meshState.VPNFabricQUICTransport != nil { _ = meshState.VPNFabricQUICTransport.Close() } - meshState.VPNFabricSessionPeers = mesh.NewFabricSessionPeerManager() meshState.VPNFabricQUICTransport = newVPNFabricQUICTransport(cfg) meshState.VPNFabricQUICTransport.SetLocalPeerID(identity.NodeID) } - if meshState.VPNFabricSessionPeers == nil { - meshState.VPNFabricSessionPeers = mesh.NewFabricSessionPeerManager() - } if meshState.VPNFabricQUICTransport == nil { meshState.VPNFabricQUICTransport = newVPNFabricQUICTransport(cfg) meshState.VPNFabricQUICTransport.SetLocalPeerID(identity.NodeID) @@ -2671,15 +2788,6 @@ func applyRefreshedSyntheticMeshConfig(ctx context.Context, cfg config.Config, i } log.Printf("fabric_service_channel_access_event=%s", string(payload)) }, - FabricSessionEnabled: cfg.MeshFabricSessionEnabled, - FabricSessionLogger: func(entry mesh.FabricSessionEventLogEntry) { - payload, err := json.Marshal(entry) - if err != nil { - log.Printf("fabric session event marshal failed: %v", err) - return - } - log.Printf("fabric_session_event=%s", string(payload)) - }, RemoteWorkspaceFrameSink: meshState.RemoteWorkspaceFrameSink, ProductionRoutes: loadedConfig.Routes, VPNPacketIngress: vpnFabricIngress, @@ -2745,7 +2853,7 @@ func applyQUICFabricConfigIfChanged(ctx context.Context, cfg config.Config, iden if meshState.VPNFabricQUICTransport != nil { meshState.VPNFabricQUICTransport.SetInboundHandlersWithWebIngress( productionForwardHandlerFromMeshState(identity, meshState), - webIngressForwardHandlerFromConfig(cfg, identity, client.New(cfg.BackendURL)), + webIngressForwardHandlerFromConfig(cfg, identity, client.New(cfg.BackendURL), meshState), syntheticForwardHandlerFromMeshState(meshState), func(entry mesh.FabricSessionEventLogEntry) { payload, err := json.Marshal(entry) @@ -2776,7 +2884,7 @@ func applyQUICFabricConfigIfChanged(ctx context.Context, cfg config.Config, iden if meshState.QUICFabricServer != nil { return } - server, addr, certSHA256, err := startQUICFabricEndpoint(ctx, cfg, identity, meshState.VPNFabricQUICTransport, vpnFabricFrameHandlerFromMeshState(meshState), productionForwardHandlerFromMeshState(identity, meshState), webIngressForwardHandlerFromConfig(cfg, identity, client.New(cfg.BackendURL)), fabricControlForwardHandler(client.New(cfg.BackendURL)), syntheticForwardHandlerFromMeshState(meshState)) + server, addr, certSHA256, err := startQUICFabricEndpoint(ctx, cfg, identity, meshState.VPNFabricQUICTransport, vpnFabricFrameHandlerFromMeshState(meshState), productionForwardHandlerFromMeshState(identity, meshState), webIngressForwardHandlerFromConfig(cfg, identity, client.New(cfg.BackendURL), meshState), fabricControlForwardHandler(client.New(cfg.BackendURL)), syntheticForwardHandlerFromMeshState(meshState)) meshState.QUICFabricServer = server meshState.QUICFabricConfiguredKey = desiredKey meshState.QUICFabricConfiguredListenAddr = desiredAddr @@ -2807,7 +2915,7 @@ func syntheticForwardHandlerFromMeshState(meshState *syntheticMeshState) func(co } } -func webIngressForwardHandlerFromConfig(cfg config.Config, identity state.Identity, api *client.Client) func(context.Context, []byte) ([]byte, error) { +func webIngressForwardHandlerFromConfig(cfg config.Config, identity state.Identity, api *client.Client, meshState *syntheticMeshState) func(context.Context, []byte) ([]byte, error) { trustedKeys, err := webingress.ParseTrustedKeysJSON(cfg.WebIngressTrustedKeysJSON) if err != nil || len(trustedKeys) == 0 { return nil @@ -2818,12 +2926,88 @@ func webIngressForwardHandlerFromConfig(cfg config.Config, identity state.Identi }, Keys: trustedKeys, Handler: webingress.AdminRuntimeDispatcher{ - ProjectionClient: controlAPIProjectionClient{API: api, ClusterID: identity.ClusterID, NodeID: identity.NodeID}, + ProjectionClient: controlAPIProjectionClient{API: api, Identity: identity, MeshState: meshState}, }, } return receiver.Receive } +func fabricControlForwardHandlerFromMeshState(api *client.Client, identity state.Identity, meshState *syntheticMeshState) func(context.Context, []byte) ([]byte, error) { + return func(ctx context.Context, payload []byte) ([]byte, error) { + var req client.RawControlRequest + if err := json.Unmarshal(payload, &req); err != nil { + return nil, fmt.Errorf("invalid fabric control request") + } + if !fabricControlPathAllowed(req.Method, req.Path) { + return nil, fmt.Errorf("fabric control path is not allowed") + } + if raw, viaFabric, err := fabricControlRawViaRegistry(ctx, identity, meshState, req); viaFabric { + if err != nil { + return nil, err + } + return json.Marshal(raw) + } + return fabricControlForwardHTTPFallback(ctx, api, req) + } +} + +func fabricControlRawViaRegistry(ctx context.Context, identity state.Identity, meshState *syntheticMeshState, req client.RawControlRequest) (client.RawControlResponse, bool, error) { + if meshState == nil || meshState.FabricRegistry == nil || meshState.VPNFabricQUICTransport == nil { + return client.RawControlResponse{}, false, nil + } + if !fabricControlPathAllowed(req.Method, req.Path) { + return client.RawControlResponse{}, false, fmt.Errorf("fabric control path is not allowed") + } + payload, err := json.Marshal(req) + if err != nil { + return client.RawControlResponse{}, false, err + } + resolved := meshState.FabricRegistry.ResolveService(mesh.FabricRegistryResolveRequest{ + ClusterID: identity.ClusterID, + Service: mesh.FabricRegistryServiceControlAPI, + Scope: mesh.FabricRegistryScopeCluster, + PreferredRegion: meshState.ListenerRuntimeConfig.MeshRegion, + Now: time.Now().UTC(), + }) + if !resolved.Found || len(resolved.Endpoints) == 0 { + return client.RawControlResponse{}, false, nil + } + var lastErr error + for _, endpoint := range resolved.Endpoints { + result, err := mesh.SendFabricControlForward(ctx, meshState.VPNFabricQUICTransport, endpoint, payload, 5*time.Second) + if err != nil { + lastErr = err + log.Printf("fabric control registry forward failed: endpoint=%s path=%s err=%v", endpoint.EndpointID, req.Path, err) + continue + } + var envelope struct { + Payload json.RawMessage `json:"payload,omitempty"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(result.Payload, &envelope); err != nil { + lastErr = err + log.Printf("fabric control registry forward decode failed: endpoint=%s path=%s err=%v", endpoint.EndpointID, req.Path, err) + continue + } + if strings.TrimSpace(envelope.Error) != "" { + lastErr = errors.New(envelope.Error) + log.Printf("fabric control registry forward rejected: endpoint=%s path=%s err=%s", endpoint.EndpointID, req.Path, envelope.Error) + continue + } + var raw client.RawControlResponse + if err := json.Unmarshal(envelope.Payload, &raw); err != nil { + lastErr = err + continue + } + log.Printf("fabric control forwarded via registry: endpoint=%s path=%s latency_ms=%d", endpoint.EndpointID, req.Path, result.LatencyMs) + return raw, true, nil + } + if lastErr == nil { + lastErr = fmt.Errorf("fabric control registry endpoints unavailable") + } + return client.RawControlResponse{}, false, lastErr +} + func fabricControlForwardHandler(api *client.Client) func(context.Context, []byte) ([]byte, error) { return func(ctx context.Context, payload []byte) ([]byte, error) { if api == nil { @@ -2836,14 +3020,22 @@ func fabricControlForwardHandler(api *client.Client) func(context.Context, []byt if !fabricControlPathAllowed(req.Method, req.Path) { return nil, fmt.Errorf("fabric control path is not allowed") } - resp, err := api.RawControl(ctx, req) - if err != nil { - return nil, err - } - return json.Marshal(resp) + return fabricControlForwardHTTPFallback(ctx, api, req) } } +func fabricControlForwardHTTPFallback(ctx context.Context, api *client.Client, req client.RawControlRequest) ([]byte, error) { + if api == nil { + return nil, fmt.Errorf("fabric control api is not configured") + } + resp, err := api.RawControl(ctx, req) + if err != nil { + log.Printf("fabric control forward failed: method=%s path=%s err=%v", req.Method, req.Path, err) + return nil, fmt.Errorf("fabric control upstream unavailable") + } + return json.Marshal(resp) +} + func fabricControlPathAllowed(method, path string) bool { method = strings.ToUpper(strings.TrimSpace(method)) path = strings.TrimSpace(path) @@ -2856,12 +3048,39 @@ func fabricControlPathAllowed(method, path string) bool { if method == http.MethodPost && (path == "/auth/login" || path == "/auth/refresh") { return true } + if method == http.MethodPost && path == "/node-agents/register" { + return true + } if method == http.MethodGet && strings.HasPrefix(path, "/organizations/") { return true } if method == http.MethodGet && strings.Contains(path, "/vpn/client-profile") && strings.HasPrefix(path, "/clusters/") { return true } + if method == http.MethodPost && strings.HasPrefix(path, "/clusters/") && strings.Contains(path, "/nodes/") && strings.HasSuffix(path, "/heartbeats") { + return true + } + if method == http.MethodPost && strings.HasPrefix(path, "/clusters/") && strings.Contains(path, "/nodes/") && strings.HasSuffix(path, "/telemetry") { + return true + } + if method == http.MethodGet && strings.HasPrefix(path, "/clusters/") && strings.Contains(path, "/nodes/") && strings.HasSuffix(path, "/mesh/synthetic-config") { + return true + } + if strings.HasPrefix(path, "/clusters/") && strings.Contains(path, "/nodes/") && strings.Contains(path, "/workloads/") { + return method == http.MethodGet || method == http.MethodPost + } + if method == http.MethodPost && strings.HasPrefix(path, "/clusters/") && strings.Contains(path, "/nodes/") && strings.HasSuffix(path, "/admin-runtime/projection") { + return true + } + if strings.HasPrefix(path, "/clusters/") && strings.Contains(path, "/nodes/") && strings.Contains(path, "/updates/") { + return method == http.MethodGet || method == http.MethodPost + } + if strings.HasPrefix(path, "/clusters/") && strings.Contains(path, "/nodes/") && strings.Contains(path, "/vpn/assignments") { + return method == http.MethodGet || method == http.MethodPost + } + if method == http.MethodPost && strings.HasPrefix(path, "/clusters/") && strings.HasSuffix(path, "/mesh/links") { + return true + } if strings.Contains(path, "/vpn/client-diagnostics/") && strings.HasPrefix(path, "/clusters/") { return method == http.MethodGet || method == http.MethodPost } @@ -2900,15 +3119,15 @@ func webIngressRuntimeServiceClassAllowed(serviceClass string) bool { type controlAPIProjectionClient struct { API *client.Client - ClusterID string - NodeID string + Identity state.Identity + MeshState *syntheticMeshState } func (c controlAPIProjectionClient) Project(ctx context.Context, request webingress.ControlAPIProjectionRequest) (webingress.ControlAPIProjectionResponse, error) { - if c.API == nil || strings.TrimSpace(c.ClusterID) == "" || strings.TrimSpace(c.NodeID) == "" { - return webingress.ControlAPIProjectionResponse{}, fmt.Errorf("control api projection client not configured") + if strings.TrimSpace(c.Identity.ClusterID) == "" || strings.TrimSpace(c.Identity.NodeID) == "" { + return webingress.ControlAPIProjectionResponse{}, fmt.Errorf("control api projection identity not configured") } - response, err := c.API.AdminRuntimeProjection(ctx, c.ClusterID, c.NodeID, client.AdminRuntimeProjectionRequest{ + controlRequest := client.AdminRuntimeProjectionRequest{ SchemaVersion: request.SchemaVersion, Method: request.Method, Path: request.Path, @@ -2917,7 +3136,40 @@ func (c controlAPIProjectionClient) Project(ctx context.Context, request webingr Scope: request.Scope, ServiceClass: request.ServiceClass, ObservedAt: request.ObservedAt, + } + body, err := json.Marshal(controlRequest) + if err != nil { + return webingress.ControlAPIProjectionResponse{}, err + } + raw, viaFabric, err := fabricControlRawViaRegistry(ctx, c.Identity, c.MeshState, client.RawControlRequest{ + Method: http.MethodPost, + Path: fmt.Sprintf("/clusters/%s/nodes/%s/admin-runtime/projection", url.PathEscape(c.Identity.ClusterID), url.PathEscape(c.Identity.NodeID)), + Body: body, }) + var response client.AdminRuntimeProjectionResponse + if viaFabric { + if err != nil { + return webingress.ControlAPIProjectionResponse{}, err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return webingress.ControlAPIProjectionResponse{}, fmt.Errorf("fabric control admin runtime projection returned status %d", raw.StatusCode) + } + if err := json.Unmarshal(raw.Body, &response); err != nil { + return webingress.ControlAPIProjectionResponse{}, err + } + return webingress.ControlAPIProjectionResponse{ + SchemaVersion: response.SchemaVersion, + Status: response.Status, + Reason: response.Reason, + StatusCode: response.StatusCode, + Headers: response.Headers, + Body: response.Body, + }, nil + } + if c.API == nil { + return webingress.ControlAPIProjectionResponse{}, fmt.Errorf("control api projection client not configured") + } + response, err = c.API.AdminRuntimeProjection(ctx, c.Identity.ClusterID, c.Identity.NodeID, controlRequest) if err != nil { return webingress.ControlAPIProjectionResponse{}, err } @@ -3253,6 +3505,9 @@ func reportSyntheticRouteHealth(ctx context.Context, cfg config.Config, api *cli if meshState == nil || meshState.Runtime == nil || api == nil { return nil } + if !cfg.MeshSyntheticRuntimeEnabled { + return nil + } routes := meshState.RouteHealthRoutes if len(routes) == 0 { routes = meshState.Routes @@ -3270,7 +3525,7 @@ func reportSyntheticRouteHealth(ctx context.Context, cfg config.Config, api *cli if err != nil { metadata := routeHealthObservationMetadata(meshState, route, decision, decisionApplied, nil) metadata["failure_reason"] = err.Error() - if reportErr := api.ReportMeshLink(ctx, identity.ClusterID, client.MeshLinkObservationRequest{ + if reportErr := reportMeshLink(ctx, api, identity, meshState, client.MeshLinkObservationRequest{ SourceNodeID: identity.NodeID, TargetNodeID: route.DestinationNodeID, LinkStatus: "unreachable", @@ -3293,7 +3548,7 @@ func reportSyntheticRouteHealth(ctx context.Context, cfg config.Config, api *cli metadata["policy_version"] = result.Observation.PolicyVersion metadata["peer_directory_version"] = result.Observation.PeerDirectoryVersion metadata["synthetic_message_type"] = result.Ack.MessageType - if err := api.ReportMeshLink(ctx, identity.ClusterID, client.MeshLinkObservationRequest{ + if err := reportMeshLink(ctx, api, identity, meshState, client.MeshLinkObservationRequest{ SourceNodeID: identity.NodeID, TargetNodeID: route.DestinationNodeID, LinkStatus: "reachable", @@ -3470,7 +3725,7 @@ func probeWarmPeerHealth(ctx context.Context, api *client.Client, identity state score := syntheticQualityScore(result.LatencyMs) qualityScore = &score } - if err := api.ReportMeshLink(ctx, identity.ClusterID, client.MeshLinkObservationRequest{ + if err := reportMeshLink(ctx, api, identity, meshState, client.MeshLinkObservationRequest{ SourceNodeID: identity.NodeID, TargetNodeID: result.NodeID, LinkStatus: meshLinkStatusFromPeerProbe(result.LinkStatus), @@ -3522,7 +3777,7 @@ func probeWarmPeerHealth(ctx context.Context, api *client.Client, identity state if meshState.PeerConnections != nil { connectionState = meshState.PeerConnections.RecordFailure(candidate.NodeID, err.Error(), time.Now().UTC()) } - if reportErr := api.ReportMeshLink(ctx, identity.ClusterID, client.MeshLinkObservationRequest{ + if reportErr := reportMeshLink(ctx, api, identity, meshState, client.MeshLinkObservationRequest{ SourceNodeID: identity.NodeID, TargetNodeID: candidate.NodeID, LinkStatus: "unreachable", @@ -3561,7 +3816,7 @@ func probeWarmPeerHealth(ctx context.Context, api *client.Client, identity state if meshState.PeerConnections != nil { connectionState = meshState.PeerConnections.RecordSuccess(candidate.NodeID, latency, time.Now().UTC()) } - if err := api.ReportMeshLink(ctx, identity.ClusterID, client.MeshLinkObservationRequest{ + if err := reportMeshLink(ctx, api, identity, meshState, client.MeshLinkObservationRequest{ SourceNodeID: identity.NodeID, TargetNodeID: candidate.NodeID, LinkStatus: "reachable", @@ -3596,6 +3851,34 @@ func probeWarmPeerHealth(ctx context.Context, api *client.Client, identity state return nil } +func verifyFabricRegistryCandidatesIfDue(ctx context.Context, cfg config.Config, identity state.Identity, meshState *syntheticMeshState, observedAt time.Time) { + if meshState == nil || meshState.FabricRegistry == nil || meshState.VPNFabricQUICTransport == nil { + return + } + if !meshState.LastFabricRegistryLiveProbeAt.IsZero() && meshState.LastFabricRegistryLiveProbeAt.Add(fabricRegistryLiveProbeInterval).After(observedAt) { + return + } + meshState.LastFabricRegistryLiveProbeAt = observedAt + results := meshState.FabricRegistry.VerifyCandidates(ctx, meshState.VPNFabricQUICTransport, mesh.FabricRegistryLiveProbeRequest{ + ClusterID: identity.ClusterID, + PreferredRegion: cfg.MeshRegion, + Timeout: 2 * time.Second, + Now: observedAt, + MaxCandidates: 16, + }) + if len(results) == 0 { + return + } + meshState.LastFabricRegistryLiveProbeResults = append([]mesh.FabricRegistryLiveProbeResult(nil), results...) + promoted := 0 + for _, result := range results { + if result.Promoted { + promoted++ + } + } + log.Printf("fabric registry live probe completed: candidates=%d promoted=%d node_id=%s cluster_id=%s", len(results), promoted, identity.NodeID, identity.ClusterID) +} + func meshLinkStatusFromPeerProbe(status string) string { switch status { case mesh.PeerConnectionProbeReachable: @@ -3670,9 +3953,17 @@ func sendHeartbeat(ctx context.Context, api *client.Client, cfg config.Config, i if identity.NodeID == "" || identity.ClusterID == "" { return client.EffectiveTestingFlags{}, fmt.Errorf("node identity is not approved") } - response, err := api.Heartbeat(ctx, identity.ClusterID, identity.NodeID, heartbeatPayload(cfg, identity, meshState, time.Now().UTC())) + request := heartbeatPayload(cfg, identity, meshState, time.Now().UTC()) + response, viaFabric, err := heartbeatViaFabricControl(ctx, identity, meshState, request) + if !viaFabric { + response, err = api.Heartbeat(ctx, identity.ClusterID, identity.NodeID, request) + } if err == nil { - log.Printf("heartbeat sent: node_id=%s cluster_id=%s", identity.NodeID, identity.ClusterID) + if viaFabric { + log.Printf("heartbeat sent via fabric-control: node_id=%s cluster_id=%s", identity.NodeID, identity.ClusterID) + } else { + log.Printf("heartbeat sent: node_id=%s cluster_id=%s", identity.NodeID, identity.ClusterID) + } if err := persistUpdateHintTrigger(cfg.StateDir, response.UpdateHint); err != nil { log.Printf("update hint trigger failed: %v", err) } @@ -3680,6 +3971,79 @@ func sendHeartbeat(ctx context.Context, api *client.Client, cfg config.Config, i return response.TestingFlags, err } +func heartbeatViaFabricControl(ctx context.Context, identity state.Identity, meshState *syntheticMeshState, request client.HeartbeatRequest) (client.HeartbeatResponse, bool, error) { + body, err := json.Marshal(request) + if err != nil { + return client.HeartbeatResponse{}, false, err + } + raw, viaFabric, err := fabricControlRawViaRegistry(ctx, identity, meshState, client.RawControlRequest{ + Method: http.MethodPost, + Path: fmt.Sprintf("/clusters/%s/nodes/%s/heartbeats", url.PathEscape(identity.ClusterID), url.PathEscape(identity.NodeID)), + Body: body, + }) + if err != nil || !viaFabric { + return client.HeartbeatResponse{}, viaFabric, err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return client.HeartbeatResponse{}, true, fmt.Errorf("fabric control heartbeat returned status %d", raw.StatusCode) + } + var response client.HeartbeatResponse + if err := json.Unmarshal(raw.Body, &response); err != nil { + return client.HeartbeatResponse{}, true, err + } + return response, true, nil +} + +func reportMeshLink(ctx context.Context, api *client.Client, identity state.Identity, meshState *syntheticMeshState, request client.MeshLinkObservationRequest) error { + body, err := json.Marshal(request) + if err != nil { + return err + } + raw, viaFabric, err := fabricControlRawViaRegistry(ctx, identity, meshState, client.RawControlRequest{ + Method: http.MethodPost, + Path: fmt.Sprintf("/clusters/%s/mesh/links", url.PathEscape(identity.ClusterID)), + Body: body, + }) + if viaFabric { + if err != nil { + return err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return fmt.Errorf("fabric control mesh link report returned status %d", raw.StatusCode) + } + return nil + } + if api == nil { + return fmt.Errorf("mesh link report control API is not configured") + } + return api.ReportMeshLink(ctx, identity.ClusterID, request) +} + +func reportTelemetry(ctx context.Context, api *client.Client, identity state.Identity, meshState *syntheticMeshState, request client.TelemetryRequest) error { + body, err := json.Marshal(request) + if err != nil { + return err + } + raw, viaFabric, err := fabricControlRawViaRegistry(ctx, identity, meshState, client.RawControlRequest{ + Method: http.MethodPost, + Path: fmt.Sprintf("/clusters/%s/nodes/%s/telemetry", url.PathEscape(identity.ClusterID), url.PathEscape(identity.NodeID)), + Body: body, + }) + if viaFabric { + if err != nil { + return err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return fmt.Errorf("fabric control telemetry returned status %d", raw.StatusCode) + } + return nil + } + if api == nil { + return fmt.Errorf("telemetry control API is not configured") + } + return api.ReportTelemetry(ctx, identity.ClusterID, identity.NodeID, request) +} + func persistUpdateHintTrigger(stateDir string, hint *client.NodeUpdateHint) error { if hint == nil || !hint.CheckNow || strings.TrimSpace(hint.Generation) == "" { return nil @@ -3758,9 +4122,11 @@ func heartbeatPayload(cfg config.Config, identity state.Identity, meshState *syn } if meshState != nil { payload.Metadata["fabric_runtime_report"] = fabricRuntimeReport(meshState, observedAt) + payload.Metadata["fabric_registry_runtime_report"] = fabricRegistryRuntimeReport(identity, meshState, observedAt) payload.Capabilities["fabric_runtime_telemetry"] = true + payload.Capabilities["fabric_registry_service_resolver"] = true } - if cfg.MeshFabricSessionEnabled { + if cfg.MeshQUICFabricEnabled { report := map[string]any{ "schema_version": "rap.fabric_session_endpoint_report.v1", "enabled": true, @@ -3801,9 +4167,6 @@ func heartbeatPayload(cfg config.Config, identity state.Identity, meshState *syn }, "observed_at": observedAt.UTC().Format(time.RFC3339Nano), } - if meshState != nil && meshState.VPNFabricSessionPeers != nil { - report["peer_sessions"] = meshState.VPNFabricSessionPeers.Snapshot() - } if meshState != nil && meshState.VPNFabricQUICTransport != nil { quicSnapshot := meshState.VPNFabricQUICTransport.Snapshot() report["quic_sessions"] = quicSnapshot @@ -3878,17 +4241,17 @@ func heartbeatPayload(cfg config.Config, identity state.Identity, meshState *syn func fabricServiceChannelRuntimeReport(meshState *syntheticMeshState, identity state.Identity, observedAt time.Time) map[string]any { report := map[string]any{ - "schema_version": "c18l.fabric_service_channel_runtime_report.v1", - "cluster_id": identity.ClusterID, - "node_id": identity.NodeID, - "service_class": "vpn_packets", - "channel_class": mesh.ProductionChannelVPNPacket, - "route_manager": "primary_sticky_with_alternate_route_failover", - "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), + "schema_version": "c18l.fabric_service_channel_runtime_report.v1", + "cluster_id": identity.ClusterID, + "node_id": identity.NodeID, + "service_class": "vpn_packets", + "channel_class": mesh.ProductionChannelVPNPacket, + "route_manager": "primary_sticky_with_alternate_route_failover", + "degraded_compatibility_relay": false, + "degraded_compatibility_relay_mode": "disabled_farm_owned_dataplane", + "route_authority": "fabric_farm", + "application_protocol_agnostic": true, + "observed_at": observedAt.UTC().Format(time.RFC3339Nano), } if meshState == nil { report["enabled"] = false @@ -3967,6 +4330,59 @@ func webIngressRuntimeReceiverReport(cfg config.Config, meshState *syntheticMesh return report } +func fabricRegistryRuntimeReport(identity state.Identity, meshState *syntheticMeshState, observedAt time.Time) map[string]any { + report := map[string]any{ + "schema_version": "rap.fabric.registry_runtime_report.v1", + "cluster_id": identity.ClusterID, + "node_id": identity.NodeID, + "transport": "quic", + "mode": "signed_gossip_registry", + "observed_at": observedAt.UTC().Format(time.RFC3339Nano), + } + if meshState == nil || meshState.FabricRegistry == nil { + report["enabled"] = false + report["status"] = "missing" + return report + } + snapshot := meshState.FabricRegistry.Snapshot(observedAt) + report["enabled"] = true + report["status"] = "candidate_only" + report["snapshot"] = snapshot + report["bootstrap"] = meshState.FabricRegistryBootstrapReport + if !meshState.LastFabricRegistryLiveProbeAt.IsZero() { + report["last_live_probe_at"] = meshState.LastFabricRegistryLiveProbeAt.UTC().Format(time.RFC3339Nano) + report["last_live_probe_results"] = meshState.LastFabricRegistryLiveProbeResults + } + services := []string{ + mesh.FabricRegistryServiceControlAPI, + mesh.FabricRegistryServiceUpdateStore, + mesh.FabricRegistryServiceUpdateCache, + mesh.FabricRegistryServiceWebAdmin, + mesh.FabricRegistryServiceVPNExitPool, + } + resolved := map[string]mesh.FabricRegistryResolvedService{} + active := 0 + for _, service := range services { + result := meshState.FabricRegistry.ResolveService(mesh.FabricRegistryResolveRequest{ + ClusterID: identity.ClusterID, + Service: service, + Scope: mesh.FabricRegistryScopeCluster, + PreferredRegion: meshState.ListenerRuntimeConfig.MeshRegion, + Now: observedAt, + }) + resolved[service] = result + if result.Found { + active++ + } + } + report["resolved_services"] = resolved + report["resolved_service_count"] = active + if active > 0 { + report["status"] = "ready" + } + return report +} + func countVPNPacketRoutes(routes []mesh.SyntheticRoute, clusterID string, localNodeID string) int { count := 0 now := time.Now().UTC() @@ -5739,8 +6155,59 @@ func reachabilityFromConnectivityMode(connectivityMode string) string { } } +func desiredWorkloads(ctx context.Context, api *client.Client, identity state.Identity, meshState *syntheticMeshState) ([]client.DesiredWorkload, error) { + raw, viaFabric, err := fabricControlRawViaRegistry(ctx, identity, meshState, client.RawControlRequest{ + Method: http.MethodGet, + Path: fmt.Sprintf("/clusters/%s/nodes/%s/workloads/desired", url.PathEscape(identity.ClusterID), url.PathEscape(identity.NodeID)), + }) + if viaFabric { + if err != nil { + return nil, err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return nil, fmt.Errorf("fabric control desired workloads returned status %d", raw.StatusCode) + } + var response struct { + DesiredWorkloads []client.DesiredWorkload `json:"desired_workloads"` + } + if err := json.Unmarshal(raw.Body, &response); err != nil { + return nil, err + } + return response.DesiredWorkloads, nil + } + if api == nil { + return nil, fmt.Errorf("desired workloads control API is not configured") + } + return api.DesiredWorkloads(ctx, identity.ClusterID, identity.NodeID) +} + +func reportSingleWorkloadStatus(ctx context.Context, api *client.Client, identity state.Identity, meshState *syntheticMeshState, serviceType string, request client.WorkloadStatusRequest) error { + body, err := json.Marshal(request) + if err != nil { + return err + } + raw, viaFabric, err := fabricControlRawViaRegistry(ctx, identity, meshState, client.RawControlRequest{ + Method: http.MethodPost, + Path: fmt.Sprintf("/clusters/%s/nodes/%s/workloads/%s/status", url.PathEscape(identity.ClusterID), url.PathEscape(identity.NodeID), url.PathEscape(serviceType)), + Body: body, + }) + if viaFabric { + if err != nil { + return err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return fmt.Errorf("fabric control workload status returned status %d", raw.StatusCode) + } + return nil + } + if api == nil { + return fmt.Errorf("workload status control API is not configured") + } + return api.ReportWorkloadStatus(ctx, identity.ClusterID, identity.NodeID, serviceType, request) +} + func reportWorkloadStatus(ctx context.Context, api *client.Client, supervisor supervisor.Supervisor, identity state.Identity, meshState *syntheticMeshState) error { - desired, err := api.DesiredWorkloads(ctx, identity.ClusterID, identity.NodeID) + desired, err := desiredWorkloads(ctx, api, identity, meshState) if err != nil { return err } @@ -5753,7 +6220,7 @@ func reportWorkloadStatus(ctx context.Context, api *client.Client, supervisor su if i >= len(desired) { break } - if err := api.ReportWorkloadStatus(ctx, identity.ClusterID, identity.NodeID, desired[i].ServiceType, status); err != nil { + if err := reportSingleWorkloadStatus(ctx, api, identity, meshState, desired[i].ServiceType, status); err != nil { return err } } @@ -5782,8 +6249,90 @@ func enrichWorkloadStatuses(statuses []client.WorkloadStatusRequest, desired []c } } -func reportVPNAssignmentStatus(ctx context.Context, api *client.Client, identity state.Identity, gateway *vpnruntime.Gateway) error { - assignments, err := api.NodeVPNAssignments(ctx, identity.ClusterID, identity.NodeID) +func nodeVPNAssignments(ctx context.Context, api *client.Client, identity state.Identity, meshState *syntheticMeshState) ([]client.NodeVPNAssignment, error) { + raw, viaFabric, err := fabricControlRawViaRegistry(ctx, identity, meshState, client.RawControlRequest{ + Method: http.MethodGet, + Path: fmt.Sprintf("/clusters/%s/nodes/%s/vpn/assignments", url.PathEscape(identity.ClusterID), url.PathEscape(identity.NodeID)), + }) + if viaFabric { + if err != nil { + return nil, err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return nil, fmt.Errorf("fabric control vpn assignments returned status %d", raw.StatusCode) + } + var response struct { + Assignments []client.NodeVPNAssignment `json:"vpn_assignments"` + } + if err := json.Unmarshal(raw.Body, &response); err != nil { + return nil, err + } + return response.Assignments, nil + } + if api == nil { + return nil, fmt.Errorf("vpn assignments control API is not configured") + } + return api.NodeVPNAssignments(ctx, identity.ClusterID, identity.NodeID) +} + +func reportNodeVPNAssignmentStatus(ctx context.Context, api *client.Client, identity state.Identity, meshState *syntheticMeshState, vpnConnectionID string, request client.NodeVPNAssignmentStatusRequest) error { + body, err := json.Marshal(request) + if err != nil { + return err + } + raw, viaFabric, err := fabricControlRawViaRegistry(ctx, identity, meshState, client.RawControlRequest{ + Method: http.MethodPost, + Path: fmt.Sprintf("/clusters/%s/nodes/%s/vpn/assignments/%s/status", url.PathEscape(identity.ClusterID), url.PathEscape(identity.NodeID), url.PathEscape(vpnConnectionID)), + Body: body, + }) + if viaFabric { + if err != nil { + return err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return fmt.Errorf("fabric control vpn assignment status returned status %d", raw.StatusCode) + } + return nil + } + if api == nil { + return fmt.Errorf("vpn assignment status control API is not configured") + } + return api.ReportNodeVPNAssignmentStatus(ctx, identity.ClusterID, identity.NodeID, vpnConnectionID, request) +} + +func acquireNodeVPNAssignmentLease(ctx context.Context, api *client.Client, identity state.Identity, meshState *syntheticMeshState, vpnConnectionID string, request client.NodeVPNAssignmentLeaseAcquireRequest) (*client.NodeVPNAssignmentLease, error) { + body, err := json.Marshal(request) + if err != nil { + return nil, err + } + raw, viaFabric, err := fabricControlRawViaRegistry(ctx, identity, meshState, client.RawControlRequest{ + Method: http.MethodPost, + Path: fmt.Sprintf("/clusters/%s/nodes/%s/vpn/assignments/%s/lease/acquire", url.PathEscape(identity.ClusterID), url.PathEscape(identity.NodeID), url.PathEscape(vpnConnectionID)), + Body: body, + }) + if viaFabric { + if err != nil { + return nil, err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return nil, fmt.Errorf("fabric control vpn assignment lease acquire returned status %d", raw.StatusCode) + } + var response struct { + Lease client.NodeVPNAssignmentLease `json:"lease"` + } + if err := json.Unmarshal(raw.Body, &response); err != nil { + return nil, err + } + return &response.Lease, nil + } + if api == nil { + return nil, fmt.Errorf("vpn assignment lease acquire control API is not configured") + } + return api.AcquireNodeVPNAssignmentLease(ctx, identity.ClusterID, identity.NodeID, vpnConnectionID, request) +} + +func reportVPNAssignmentStatus(ctx context.Context, api *client.Client, identity state.Identity, gateway *vpnruntime.Gateway, meshState *syntheticMeshState) error { + assignments, err := nodeVPNAssignments(ctx, api, identity, meshState) if err != nil { return err } @@ -5847,7 +6396,7 @@ func reportVPNAssignmentStatus(ctx context.Context, api *client.Client, identity payload["lease_generation"] = assignment.ActiveLease.LeaseGeneration payload["lease_expires_at"] = assignment.ActiveLease.ExpiresAt } - if err := api.ReportNodeVPNAssignmentStatus(ctx, identity.ClusterID, identity.NodeID, assignment.VPNConnectionID, client.NodeVPNAssignmentStatusRequest{ + if err := reportNodeVPNAssignmentStatus(ctx, api, identity, meshState, assignment.VPNConnectionID, client.NodeVPNAssignmentStatusRequest{ ObservedStatus: status, StatusPayload: payload, ObservedAt: time.Now().UTC(), @@ -5976,7 +6525,7 @@ func parseDNSServerList(value string) []string { } func ensureVPNGatewayRuntime(ctx context.Context, api *client.Client, cfg config.Config, identity state.Identity, gateway *vpnruntime.Gateway, meshState *syntheticMeshState) error { - assignments, err := api.NodeVPNAssignments(ctx, identity.ClusterID, identity.NodeID) + assignments, err := nodeVPNAssignments(ctx, api, identity, meshState) if err != nil { return err } @@ -5987,7 +6536,7 @@ func ensureVPNGatewayRuntime(ctx context.Context, api *client.Client, cfg config log.Printf("vpn assignment lease auto-acquire skipped: vpn_connection_id=%s reason=local_node_is_not_selected_exit", assignment.VPNConnectionID) continue } - lease, err := api.AcquireNodeVPNAssignmentLease(ctx, identity.ClusterID, identity.NodeID, assignment.VPNConnectionID, client.NodeVPNAssignmentLeaseAcquireRequest{ + lease, err := acquireNodeVPNAssignmentLease(ctx, api, identity, meshState, assignment.VPNConnectionID, client.NodeVPNAssignmentLeaseAcquireRequest{ TTLSeconds: 300, Metadata: map[string]any{ "reason": "node_agent_auto_acquire", @@ -6047,7 +6596,7 @@ func ensureVPNGatewayRuntime(ctx context.Context, api *client.Client, cfg config if err := gateway.EnsureStarted(ctx); err != nil { return err } - if err := renewOwnedVPNLease(ctx, api, identity, assignment); err != nil { + if err := renewOwnedVPNLease(ctx, api, identity, meshState, assignment); err != nil { return err } log.Printf("vpn gateway runtime ensured: vpn_connection_id=%s interface=%s", assignment.VPNConnectionID, gateway.InterfaceName) @@ -6148,9 +6697,6 @@ func fabricSessionGatewayTransportForAssignment(ctx context.Context, cfg config. meshState.VPNFabricEndpointObservations = newVPNFabricEndpointObservationStore(identity.NodeID) } meshState.VPNFabricSessionDialStats.Attempts.Add(1) - if meshState.VPNFabricSessionPeers == nil { - meshState.VPNFabricSessionPeers = mesh.NewFabricSessionPeerManager() - } token := fabricSessionGatewayToken(identity, assignment, nextHop) for index, target := range targets { startedAt := time.Now() @@ -6497,11 +7043,36 @@ func nextRouteHop(path []string, localNodeID string, destinationNodeID string) s return destinationNodeID } -func renewOwnedVPNLease(ctx context.Context, api *client.Client, identity state.Identity, assignment client.NodeVPNAssignment) error { +func renewNodeVPNAssignmentLease(ctx context.Context, api *client.Client, identity state.Identity, meshState *syntheticMeshState, vpnConnectionID, leaseID string, request client.NodeVPNAssignmentLeaseRenewRequest) error { + body, err := json.Marshal(request) + if err != nil { + return err + } + raw, viaFabric, err := fabricControlRawViaRegistry(ctx, identity, meshState, client.RawControlRequest{ + Method: http.MethodPost, + Path: fmt.Sprintf("/clusters/%s/nodes/%s/vpn/assignments/%s/lease/%s/renew", url.PathEscape(identity.ClusterID), url.PathEscape(identity.NodeID), url.PathEscape(vpnConnectionID), url.PathEscape(leaseID)), + Body: body, + }) + if viaFabric { + if err != nil { + return err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return fmt.Errorf("fabric control vpn assignment lease renew returned status %d", raw.StatusCode) + } + return nil + } + if api == nil { + return fmt.Errorf("vpn assignment lease renew control API is not configured") + } + return api.RenewNodeVPNAssignmentLease(ctx, identity.ClusterID, identity.NodeID, vpnConnectionID, leaseID, request) +} + +func renewOwnedVPNLease(ctx context.Context, api *client.Client, identity state.Identity, meshState *syntheticMeshState, assignment client.NodeVPNAssignment) error { if assignment.ActiveLease == nil || assignment.ActiveLease.OwnerNodeID != identity.NodeID { return nil } - if err := api.RenewNodeVPNAssignmentLease(ctx, identity.ClusterID, identity.NodeID, assignment.VPNConnectionID, assignment.ActiveLease.LeaseID, client.NodeVPNAssignmentLeaseRenewRequest{ + if err := renewNodeVPNAssignmentLease(ctx, api, identity, meshState, assignment.VPNConnectionID, assignment.ActiveLease.LeaseID, client.NodeVPNAssignmentLeaseRenewRequest{ TTLSeconds: 300, }); err != nil { return err 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 1c76831..35210ca 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 @@ -21,6 +21,7 @@ import ( "net/http/httptest" "os" "path/filepath" + "reflect" "strings" "testing" "time" @@ -204,7 +205,7 @@ func TestRouteManagerDecisionsFromControlPlaneConsumesRebuildRouteCommand(t *tes } decision := decisions[0] if decision.RouteID != "route-primary" || - decision.RebuildStatus != "pending_degraded_fallback" || + decision.RebuildStatus != "pending_degraded_route_state" || decision.DecisionSource != "service_channel_remediation_command" || decision.RebuildRequestID != "cmd-rebuild" { t.Fatalf("unexpected rebuild remediation decision: %+v", decision) @@ -279,7 +280,6 @@ func TestGatewayTransportForAssignmentUsesFabricSessionWhenEnabled(t *testing.T) &syntheticMeshState{ ProductionForwardTransport: noopProductionForwardTransport{}, VPNFabricInbox: inbox, - VPNFabricSessionPeers: mesh.NewFabricSessionPeerManager(), PeerEndpointCandidates: map[string][]mesh.PeerEndpointCandidate{ "entry-1": {{ EndpointID: "entry-1-quic", @@ -322,7 +322,6 @@ func TestGatewayTransportForAssignmentFallsBackWhenFabricSessionUnavailable(t *t &syntheticMeshState{ ProductionForwardTransport: noopProductionForwardTransport{}, VPNFabricInbox: inbox, - VPNFabricSessionPeers: mesh.NewFabricSessionPeerManager(), PeerEndpoints: map[string]string{}, Routes: []mesh.SyntheticRoute{{ RouteID: "route-exit-entry", @@ -424,6 +423,496 @@ func testMainQUICCertSHA256(t *testing.T, config *tls.Config) string { return hex.EncodeToString(sum[:]) } +func TestFabricControlForwardHandlerUsesRegistryQUICControlAPI(t *testing.T) { + tlsConfig := testMainQUICTLSConfig(t) + server, err := mesh.StartQUICFabricServer(context.Background(), mesh.QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + FabricControlHandler: func(_ context.Context, payload []byte) ([]byte, error) { + var req client.RawControlRequest + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + if req.Path != "/auth/login" { + return nil, fmt.Errorf("unexpected path %s", req.Path) + } + return json.Marshal(client.RawControlResponse{StatusCode: 200, Body: json.RawMessage(`{"via":"fabric"}`)}) + }, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + + now := time.Now().UTC() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + issuer := mesh.FabricRegistryTrustedIssuer{IssuerID: "authority-1", Role: mesh.FabricRegistryAuthorityControl, PublicKey: publicKey} + record := mesh.FabricRegistryGossipRecord{ + SchemaVersion: mesh.FabricRegistryGossipRecordSchema, + ClusterID: "cluster-1", + Service: mesh.FabricRegistryServiceControlAPI, + Scope: mesh.FabricRegistryScopeCluster, + Epoch: 1, + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + IssuerNodeID: "authority-1", + IssuerRole: mesh.FabricRegistryAuthorityControl, + Endpoints: []mesh.FabricRegistryEndpoint{{ + EndpointID: "control-a", + Address: "quic://" + server.Addr().String(), + Transport: "direct_quic", + PeerCertSHA256: testMainQUICCertSHA256(t, tlsConfig), + }}, + } + signed, err := mesh.SignFabricRegistryGossipRecord(record, issuer, privateKey) + if err != nil { + t.Fatalf("sign registry record: %v", err) + } + registry := mesh.NewFabricRegistry() + if _, _, err := registry.ApplyGossipRecord(signed, mesh.FabricRegistryVerificationPolicy{ + LocalClusterID: "cluster-1", + TrustedIssuers: []mesh.FabricRegistryTrustedIssuer{issuer}, + RequiredSignatures: 1, + Now: now, + }, true); err != nil { + t.Fatalf("apply registry record: %v", err) + } + transport := mesh.NewQUICFabricTransport(nil) + transport.SetLocalPeerID("node-a") + handler := fabricControlForwardHandlerFromMeshState(nil, state.Identity{ClusterID: "cluster-1", NodeID: "node-a"}, &syntheticMeshState{ + FabricRegistry: registry, + VPNFabricQUICTransport: transport, + ListenerRuntimeConfig: config.Config{MeshRegion: "test"}, + }) + payload, err := handler(context.Background(), []byte(`{"method":"POST","path":"/auth/login","body":{"user":"a"}}`)) + if err != nil { + t.Fatalf("fabric control handler: %v", err) + } + var response client.RawControlResponse + if err := json.Unmarshal(payload, &response); err != nil { + t.Fatalf("decode raw control response: %v", err) + } + if response.StatusCode != 200 || string(response.Body) != `{"via":"fabric"}` { + t.Fatalf("response = %+v", response) + } +} + +func TestHeartbeatViaFabricControlUsesRegistryQUICControlAPI(t *testing.T) { + tlsConfig := testMainQUICTLSConfig(t) + server, err := mesh.StartQUICFabricServer(context.Background(), mesh.QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + FabricControlHandler: func(_ context.Context, payload []byte) ([]byte, error) { + var req client.RawControlRequest + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + if req.Method != http.MethodPost || req.Path != "/clusters/cluster-1/nodes/node-a/heartbeats" { + return nil, fmt.Errorf("unexpected request: %+v", req) + } + return json.Marshal(client.RawControlResponse{ + StatusCode: 202, + Body: json.RawMessage(`{ + "heartbeat":{"id":"hb-1"}, + "testing_flags":{"enabled":true,"synthetic_links_enabled":true,"applied_scopes":["cluster"]}, + "update_hint":{"schema_version":"rap.node_update_hint.v1","check_now":true,"generation":"gen-1"} + }`), + }) + }, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + + now := time.Now().UTC() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + issuer := mesh.FabricRegistryTrustedIssuer{IssuerID: "authority-1", Role: mesh.FabricRegistryAuthorityControl, PublicKey: publicKey} + record := mesh.FabricRegistryGossipRecord{ + SchemaVersion: mesh.FabricRegistryGossipRecordSchema, + ClusterID: "cluster-1", + Service: mesh.FabricRegistryServiceControlAPI, + Scope: mesh.FabricRegistryScopeCluster, + Epoch: 1, + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + IssuerNodeID: "authority-1", + IssuerRole: mesh.FabricRegistryAuthorityControl, + Endpoints: []mesh.FabricRegistryEndpoint{{ + EndpointID: "control-a", + Address: "quic://" + server.Addr().String(), + Transport: "direct_quic", + PeerCertSHA256: testMainQUICCertSHA256(t, tlsConfig), + }}, + } + signed, err := mesh.SignFabricRegistryGossipRecord(record, issuer, privateKey) + if err != nil { + t.Fatalf("sign registry record: %v", err) + } + registry := mesh.NewFabricRegistry() + if _, _, err := registry.ApplyGossipRecord(signed, mesh.FabricRegistryVerificationPolicy{ + LocalClusterID: "cluster-1", + TrustedIssuers: []mesh.FabricRegistryTrustedIssuer{issuer}, + RequiredSignatures: 1, + Now: now, + }, true); err != nil { + t.Fatalf("apply registry record: %v", err) + } + response, viaFabric, err := heartbeatViaFabricControl(context.Background(), state.Identity{ClusterID: "cluster-1", NodeID: "node-a"}, &syntheticMeshState{ + FabricRegistry: registry, + VPNFabricQUICTransport: mesh.NewQUICFabricTransport(nil), + }, client.HeartbeatRequest{HealthStatus: "healthy"}) + if err != nil { + t.Fatalf("heartbeat via fabric: %v", err) + } + if !viaFabric || !response.TestingFlags.Enabled || response.UpdateHint == nil || response.UpdateHint.Generation != "gen-1" { + t.Fatalf("unexpected heartbeat response viaFabric=%t response=%+v", viaFabric, response) + } +} + +func TestSyntheticMeshConfigRefreshUsesRegistryQUICControlAPI(t *testing.T) { + tlsConfig := testMainQUICTLSConfig(t) + server, err := mesh.StartQUICFabricServer(context.Background(), mesh.QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + FabricControlHandler: func(_ context.Context, payload []byte) ([]byte, error) { + var req client.RawControlRequest + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + if req.Method != http.MethodGet || req.Path != "/clusters/cluster-1/nodes/node-a/mesh/synthetic-config" { + return nil, fmt.Errorf("unexpected request: %+v", req) + } + return json.Marshal(client.RawControlResponse{ + StatusCode: 200, + Body: json.RawMessage(`{ + "synthetic_mesh_config":{ + "enabled":true, + "config_version":"fabric-gen-1", + "peer_directory_version":"pd-1", + "policy_version":"pol-1", + "peer_endpoints":{}, + "routes":[] + } + }`), + }) + }, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + registry := signedTestControlRegistry(t, "cluster-1", "quic://"+server.Addr().String(), testMainQUICCertSHA256(t, tlsConfig)) + loaded, err := loadSyntheticMeshConfigRuntime(context.Background(), config.Config{}, state.Identity{ClusterID: "cluster-1", NodeID: "node-a"}, nil, &syntheticMeshState{ + FabricRegistry: registry, + VPNFabricQUICTransport: mesh.NewQUICFabricTransport(nil), + }) + if err != nil { + t.Fatalf("load synthetic mesh config via fabric: %v", err) + } + if loaded.Source != "control_plane" || loaded.ConfigVersion != "fabric-gen-1" { + t.Fatalf("loaded = %+v", loaded) + } +} + +func TestReportMeshLinkUsesRegistryQUICControlAPI(t *testing.T) { + tlsConfig := testMainQUICTLSConfig(t) + var received client.RawControlRequest + server, err := mesh.StartQUICFabricServer(context.Background(), mesh.QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + FabricControlHandler: func(_ context.Context, payload []byte) ([]byte, error) { + if err := json.Unmarshal(payload, &received); err != nil { + return nil, err + } + if received.Method != http.MethodPost || received.Path != "/clusters/cluster-1/mesh/links" { + return nil, fmt.Errorf("unexpected request: %+v", received) + } + return json.Marshal(client.RawControlResponse{StatusCode: 202, Body: json.RawMessage(`{"ok":true}`)}) + }, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + registry := signedTestControlRegistry(t, "cluster-1", "quic://"+server.Addr().String(), testMainQUICCertSHA256(t, tlsConfig)) + err = reportMeshLink(context.Background(), nil, state.Identity{ClusterID: "cluster-1", NodeID: "node-a"}, &syntheticMeshState{ + FabricRegistry: registry, + VPNFabricQUICTransport: mesh.NewQUICFabricTransport(nil), + }, client.MeshLinkObservationRequest{ + SourceNodeID: "node-a", + TargetNodeID: "node-b", + LinkStatus: "reachable", + }) + if err != nil { + t.Fatalf("report mesh link via fabric: %v", err) + } + if len(received.Body) == 0 || !strings.Contains(string(received.Body), `"target_node_id":"node-b"`) { + t.Fatalf("unexpected received body: %s", string(received.Body)) + } +} + +func TestReportTelemetryUsesRegistryQUICControlAPI(t *testing.T) { + tlsConfig := testMainQUICTLSConfig(t) + var received client.RawControlRequest + server, err := mesh.StartQUICFabricServer(context.Background(), mesh.QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + FabricControlHandler: func(_ context.Context, payload []byte) ([]byte, error) { + if err := json.Unmarshal(payload, &received); err != nil { + return nil, err + } + if received.Method != http.MethodPost || received.Path != "/clusters/cluster-1/nodes/node-a/telemetry" { + return nil, fmt.Errorf("unexpected request: %+v", received) + } + return json.Marshal(client.RawControlResponse{StatusCode: 202, Body: json.RawMessage(`{"ok":true}`)}) + }, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + registry := signedTestControlRegistry(t, "cluster-1", "quic://"+server.Addr().String(), testMainQUICCertSHA256(t, tlsConfig)) + err = reportTelemetry(context.Background(), nil, state.Identity{ClusterID: "cluster-1", NodeID: "node-a"}, &syntheticMeshState{ + FabricRegistry: registry, + VPNFabricQUICTransport: mesh.NewQUICFabricTransport(nil), + }, client.TelemetryRequest{Payload: map[string]any{"fabric": "quic"}}) + if err != nil { + t.Fatalf("report telemetry via fabric: %v", err) + } + if len(received.Body) == 0 || !strings.Contains(string(received.Body), `"fabric":"quic"`) { + t.Fatalf("unexpected received body: %s", string(received.Body)) + } +} + +func TestWorkloadControlUsesRegistryQUICControlAPI(t *testing.T) { + tlsConfig := testMainQUICTLSConfig(t) + var paths []string + server, err := mesh.StartQUICFabricServer(context.Background(), mesh.QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + FabricControlHandler: func(_ context.Context, payload []byte) ([]byte, error) { + var req client.RawControlRequest + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + paths = append(paths, req.Method+" "+req.Path) + switch req.Path { + case "/clusters/cluster-1/nodes/node-a/workloads/desired": + return json.Marshal(client.RawControlResponse{ + StatusCode: 200, + Body: json.RawMessage(`{"desired_workloads":[{"service_type":"vpn-egress","desired_state":"enabled","runtime_mode":"node"}]}`), + }) + case "/clusters/cluster-1/nodes/node-a/workloads/vpn-egress/status": + if len(req.Body) == 0 || !strings.Contains(string(req.Body), `"reported_state":"running"`) { + return nil, fmt.Errorf("unexpected status body: %s", string(req.Body)) + } + return json.Marshal(client.RawControlResponse{StatusCode: 204, Body: json.RawMessage(`{}`)}) + default: + return nil, fmt.Errorf("unexpected request: %+v", req) + } + }, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + registry := signedTestControlRegistry(t, "cluster-1", "quic://"+server.Addr().String(), testMainQUICCertSHA256(t, tlsConfig)) + meshState := &syntheticMeshState{ + FabricRegistry: registry, + VPNFabricQUICTransport: mesh.NewQUICFabricTransport(nil), + } + identity := state.Identity{ClusterID: "cluster-1", NodeID: "node-a"} + desired, err := desiredWorkloads(context.Background(), nil, identity, meshState) + if err != nil { + t.Fatalf("desired workloads via fabric: %v", err) + } + if len(desired) != 1 || desired[0].ServiceType != "vpn-egress" { + t.Fatalf("desired = %+v", desired) + } + if err := reportSingleWorkloadStatus(context.Background(), nil, identity, meshState, "vpn-egress", client.WorkloadStatusRequest{ReportedState: "running"}); err != nil { + t.Fatalf("report workload status via fabric: %v", err) + } + want := []string{ + "GET /clusters/cluster-1/nodes/node-a/workloads/desired", + "POST /clusters/cluster-1/nodes/node-a/workloads/vpn-egress/status", + } + if !reflect.DeepEqual(paths, want) { + t.Fatalf("paths = %+v, want %+v", paths, want) + } +} + +func TestAdminRuntimeProjectionUsesRegistryQUICControlAPI(t *testing.T) { + tlsConfig := testMainQUICTLSConfig(t) + var received client.RawControlRequest + server, err := mesh.StartQUICFabricServer(context.Background(), mesh.QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + FabricControlHandler: func(_ context.Context, payload []byte) ([]byte, error) { + if err := json.Unmarshal(payload, &received); err != nil { + return nil, err + } + if received.Method != http.MethodPost || received.Path != "/clusters/cluster-1/nodes/node-a/admin-runtime/projection" { + return nil, fmt.Errorf("unexpected request: %+v", received) + } + return json.Marshal(client.RawControlResponse{ + StatusCode: 200, + Body: json.RawMessage(`{"schema_version":"rap.admin_runtime_projection.v1","status":"ok","status_code":200,"body":{"page":"cluster"}}`), + }) + }, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + registry := signedTestControlRegistry(t, "cluster-1", "quic://"+server.Addr().String(), testMainQUICCertSHA256(t, tlsConfig)) + projection, err := controlAPIProjectionClient{ + Identity: state.Identity{ClusterID: "cluster-1", NodeID: "node-a"}, + MeshState: &syntheticMeshState{ + FabricRegistry: registry, + VPNFabricQUICTransport: mesh.NewQUICFabricTransport(nil), + }, + }.Project(context.Background(), webingress.ControlAPIProjectionRequest{ + SchemaVersion: "rap.web_ingress_projection.v1", + Method: http.MethodGet, + Path: "/cluster-admin", + Scope: "cluster", + ServiceClass: "cluster_admin", + }) + if err != nil { + t.Fatalf("admin projection via fabric: %v", err) + } + if projection.StatusCode != 200 || string(projection.Body) != `{"page":"cluster"}` { + t.Fatalf("projection = %+v", projection) + } + if len(received.Body) == 0 || !strings.Contains(string(received.Body), `"service_class":"cluster_admin"`) { + t.Fatalf("unexpected received body: %s", string(received.Body)) + } +} + +func TestVPNAssignmentControlUsesRegistryQUICControlAPI(t *testing.T) { + tlsConfig := testMainQUICTLSConfig(t) + var paths []string + server, err := mesh.StartQUICFabricServer(context.Background(), mesh.QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + FabricControlHandler: func(_ context.Context, payload []byte) ([]byte, error) { + var req client.RawControlRequest + if err := json.Unmarshal(payload, &req); err != nil { + return nil, err + } + paths = append(paths, req.Method+" "+req.Path) + switch req.Path { + case "/clusters/cluster-1/nodes/node-a/vpn/assignments": + return json.Marshal(client.RawControlResponse{ + StatusCode: 200, + Body: json.RawMessage(`{"vpn_assignments":[{"vpn_connection_id":"vpn-1","desired_state":"enabled","assignment_reason":"eligible_candidate"}]}`), + }) + case "/clusters/cluster-1/nodes/node-a/vpn/assignments/vpn-1/lease/acquire": + return json.Marshal(client.RawControlResponse{ + StatusCode: 201, + Body: json.RawMessage(`{"lease":{"lease_id":"lease-1","owner_node_id":"node-a","lease_generation":1,"status":"active"}}`), + }) + case "/clusters/cluster-1/nodes/node-a/vpn/assignments/vpn-1/lease/lease-1/renew": + return json.Marshal(client.RawControlResponse{StatusCode: 204, Body: json.RawMessage(`{}`)}) + case "/clusters/cluster-1/nodes/node-a/vpn/assignments/vpn-1/status": + if len(req.Body) == 0 || !strings.Contains(string(req.Body), `"observed_status":"assigned"`) { + return nil, fmt.Errorf("unexpected status body: %s", string(req.Body)) + } + return json.Marshal(client.RawControlResponse{StatusCode: 204, Body: json.RawMessage(`{}`)}) + default: + return nil, fmt.Errorf("unexpected request: %+v", req) + } + }, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + registry := signedTestControlRegistry(t, "cluster-1", "quic://"+server.Addr().String(), testMainQUICCertSHA256(t, tlsConfig)) + meshState := &syntheticMeshState{ + FabricRegistry: registry, + VPNFabricQUICTransport: mesh.NewQUICFabricTransport(nil), + } + identity := state.Identity{ClusterID: "cluster-1", NodeID: "node-a"} + assignments, err := nodeVPNAssignments(context.Background(), nil, identity, meshState) + if err != nil { + t.Fatalf("vpn assignments via fabric: %v", err) + } + if len(assignments) != 1 || assignments[0].VPNConnectionID != "vpn-1" { + t.Fatalf("assignments = %+v", assignments) + } + lease, err := acquireNodeVPNAssignmentLease(context.Background(), nil, identity, meshState, "vpn-1", client.NodeVPNAssignmentLeaseAcquireRequest{TTLSeconds: 300}) + if err != nil { + t.Fatalf("acquire lease via fabric: %v", err) + } + if lease == nil || lease.LeaseID != "lease-1" { + t.Fatalf("lease = %+v", lease) + } + if err := renewNodeVPNAssignmentLease(context.Background(), nil, identity, meshState, "vpn-1", "lease-1", client.NodeVPNAssignmentLeaseRenewRequest{TTLSeconds: 300}); err != nil { + t.Fatalf("renew lease via fabric: %v", err) + } + if err := reportNodeVPNAssignmentStatus(context.Background(), nil, identity, meshState, "vpn-1", client.NodeVPNAssignmentStatusRequest{ObservedStatus: "assigned"}); err != nil { + t.Fatalf("report status via fabric: %v", err) + } + want := []string{ + "GET /clusters/cluster-1/nodes/node-a/vpn/assignments", + "POST /clusters/cluster-1/nodes/node-a/vpn/assignments/vpn-1/lease/acquire", + "POST /clusters/cluster-1/nodes/node-a/vpn/assignments/vpn-1/lease/lease-1/renew", + "POST /clusters/cluster-1/nodes/node-a/vpn/assignments/vpn-1/status", + } + if !reflect.DeepEqual(paths, want) { + t.Fatalf("paths = %+v, want %+v", paths, want) + } +} + +func signedTestControlRegistry(t *testing.T, clusterID string, endpoint string, certSHA256 string) *mesh.FabricRegistry { + t.Helper() + now := time.Now().UTC() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + issuer := mesh.FabricRegistryTrustedIssuer{IssuerID: "authority-1", Role: mesh.FabricRegistryAuthorityControl, PublicKey: publicKey} + record := mesh.FabricRegistryGossipRecord{ + SchemaVersion: mesh.FabricRegistryGossipRecordSchema, + ClusterID: clusterID, + Service: mesh.FabricRegistryServiceControlAPI, + Scope: mesh.FabricRegistryScopeCluster, + Epoch: 1, + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + IssuerNodeID: "authority-1", + IssuerRole: mesh.FabricRegistryAuthorityControl, + Endpoints: []mesh.FabricRegistryEndpoint{{ + EndpointID: "control-a", + Address: endpoint, + Transport: "direct_quic", + PeerCertSHA256: certSHA256, + }}, + } + signed, err := mesh.SignFabricRegistryGossipRecord(record, issuer, privateKey) + if err != nil { + t.Fatalf("sign registry record: %v", err) + } + registry := mesh.NewFabricRegistry() + if _, _, err := registry.ApplyGossipRecord(signed, mesh.FabricRegistryVerificationPolicy{ + LocalClusterID: clusterID, + TrustedIssuers: []mesh.FabricRegistryTrustedIssuer{issuer}, + RequiredSignatures: 1, + Now: now, + }, true); err != nil { + t.Fatalf("apply registry record: %v", err) + } + return registry +} + func TestRouteManagerDecisionsFromControlPlaneKeepsExplicitRemediationCommand(t *testing.T) { now := time.Now().UTC() report := &client.RoutePathDecisionReport{Decisions: []client.RoutePathDecision{{ @@ -493,9 +982,10 @@ func TestFabricServiceChannelAccessStatsReportsDataPlaneViolations(t *testing.T) OccurredAt: time.Unix(10, 0).UTC(), }) report := stats.Report(time.Unix(20, 0).UTC()) - if report["backend_fallback_blocked"] != int64(1) || + if report["degraded_compatibility_blocked"] != int64(1) || report["fabric_route_send_failure"] != int64(1) || - report["last_data_plane_violation_status"] != "fabric_route_send_failed_backend_fallback_blocked" || + report["last_data_plane_violation_status"] != "degraded_compatibility_blocked" || + report["last_data_plane_violation_status_raw"] != "fabric_route_send_failed_backend_fallback_blocked" || report["last_data_plane_violation_reason"] != "mesh synthetic route not found" { t.Fatalf("unexpected violation report: %+v", report) } @@ -790,7 +1280,56 @@ func TestVerifyEnrollmentBootstrapRejectsPinnedAuthorityMismatch(t *testing.T) { } } -func TestNormalizeLoadedSyntheticMeshConfigMigratesLegacyControlPlaneSurfaces(t *testing.T) { +func TestLoadFabricRegistryBootstrapAcceptsSignedCandidate(t *testing.T) { + now := time.Now().UTC() + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + record := mesh.FabricRegistryGossipRecord{ + SchemaVersion: mesh.FabricRegistryGossipRecordSchema, + ClusterID: "cluster-1", + Service: mesh.FabricRegistryServiceControlAPI, + Scope: mesh.FabricRegistryScopeCluster, + Epoch: 1, + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + IssuerNodeID: "authority-node", + IssuerRole: mesh.FabricRegistryAuthorityControl, + Endpoints: []mesh.FabricRegistryEndpoint{ + {EndpointID: "control-a", Address: "quic://control.example.test:19443", Transport: "direct_quic"}, + }, + } + signed, err := mesh.SignFabricRegistryGossipRecord(record, mesh.FabricRegistryTrustedIssuer{ + IssuerID: "cluster-authority", + Role: mesh.FabricRegistryAuthorityControl, + }, privateKey) + if err != nil { + t.Fatalf("sign registry record: %v", err) + } + raw, err := json.Marshal([]mesh.FabricRegistryGossipRecord{signed}) + if err != nil { + t.Fatalf("marshal registry records: %v", err) + } + registry, report := loadFabricRegistryBootstrap(config.Config{ + ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), + FabricRegistryRecordsJSON: string(raw), + }, state.Identity{ClusterID: "cluster-1"}) + if registry == nil || report.Total != 1 || report.Candidate != 1 || report.Rejected != 0 { + t.Fatalf("unexpected registry bootstrap report: %+v registry=%v", report, registry) + } + if _, ok := registry.Active("cluster-1", mesh.FabricRegistryServiceControlAPI, mesh.FabricRegistryScopeCluster, "", now); ok { + t.Fatal("bootstrap record should remain candidate until live verification") + } + if !registry.MarkLiveVerified("cluster-1", mesh.FabricRegistryServiceControlAPI, mesh.FabricRegistryScopeCluster, "", now) { + t.Fatal("MarkLiveVerified = false") + } + if _, ok := registry.Active("cluster-1", mesh.FabricRegistryServiceControlAPI, mesh.FabricRegistryScopeCluster, "", now); !ok { + t.Fatal("expected active record after live verification") + } +} + +func TestNormalizeLoadedSyntheticMeshConfigMigratesNonQUICControlPlaneSurfaces(t *testing.T) { loaded := loadedSyntheticMeshConfig{ PeerEndpoints: map[string]string{ "node-a": "https://node-a.example.test:443", @@ -798,7 +1337,7 @@ func TestNormalizeLoadedSyntheticMeshConfigMigratesLegacyControlPlaneSurfaces(t PeerEndpointCandidates: map[string][]mesh.PeerEndpointCandidate{ "node-b": { { - EndpointID: "node-b-legacy", + EndpointID: "node-b-http-migration", NodeID: "node-b", Transport: "direct_http", Address: "https://node-b.example.test:443", @@ -816,7 +1355,7 @@ func TestNormalizeLoadedSyntheticMeshConfigMigratesLegacyControlPlaneSurfaces(t }, RendezvousLeases: []mesh.PeerRendezvousLease{ { - LeaseID: "lease-legacy", + LeaseID: "lease-http-migration", PeerNodeID: "node-b", RelayNodeID: "node-r", RelayEndpoint: "http://node-r.example.test:19001", @@ -824,7 +1363,7 @@ func TestNormalizeLoadedSyntheticMeshConfigMigratesLegacyControlPlaneSurfaces(t }, }, RoutePathDecisions: &client.RoutePathDecisionReport{ - Decisions: []client.RoutePathDecision{{DecisionID: "decision-legacy", SelectedRelayEndpoint: "http://node-r.example.test:19001"}}, + Decisions: []client.RoutePathDecision{{DecisionID: "decision-http-migration", SelectedRelayEndpoint: "http://node-r.example.test:19001"}}, }, } normalizeLoadedSyntheticMeshConfigQUICOnly(&loaded) @@ -849,14 +1388,14 @@ func TestNormalizeLoadedSyntheticMeshConfigMigratesLegacyControlPlaneSurfaces(t } } -func TestValidateLoadedSyntheticMeshConfigRejectsUnnormalizedLegacyControlPlaneSurfaces(t *testing.T) { +func TestValidateLoadedSyntheticMeshConfigRejectsUnnormalizedNonQUICControlPlaneSurfaces(t *testing.T) { err := validateLoadedSyntheticMeshConfigQUICOnly(loadedSyntheticMeshConfig{ RoutePathDecisions: &client.RoutePathDecisionReport{ - Decisions: []client.RoutePathDecision{{DecisionID: "decision-legacy", SelectedRelayEndpoint: "http://node-r.example.test:19001"}}, + Decisions: []client.RoutePathDecision{{DecisionID: "decision-http-migration", SelectedRelayEndpoint: "http://node-r.example.test:19001"}}, }, }) if err == nil || !strings.Contains(err.Error(), "QUIC selected relay endpoint") { - t.Fatalf("expected legacy selected relay endpoint rejection, got %v", err) + t.Fatalf("expected non-QUIC selected relay endpoint rejection, got %v", err) } } @@ -942,7 +1481,6 @@ func TestHeartbeatPayloadIncludesMeshEndpointReport(t *testing.T) { MeshRegion: "eu", MeshSyntheticRuntimeEnabled: true, MeshProductionForwardingEnabled: true, - MeshFabricSessionEnabled: true, VPNFabricSessionTransportEnabled: true, VPNFabricSessionStreamShards: 6, VPNFabricQUICMaxStreamsPerConn: 24, @@ -952,7 +1490,6 @@ func TestHeartbeatPayloadIncludesMeshEndpointReport(t *testing.T) { ClusterID: "cluster-1", NodeID: "node-a", }, &syntheticMeshState{ - VPNFabricSessionPeers: mesh.NewFabricSessionPeerManager(), VPNFabricQUICTransport: func() *mesh.QUICFabricTransport { transport := mesh.NewQUICFabricTransport(nil) transport.MaxStreamsPerConn = 24 @@ -1010,8 +1547,7 @@ func TestHeartbeatPayloadIncludesMeshEndpointReport(t *testing.T) { if report, ok := payload.Metadata["vpn_fabric_session_transport_report"].(map[string]any); !ok || report["packet_payload"] != "rap.vpn_packet_batch.fabric.v1" || report["transport"] != "fabric_session_binary_frames" || - report["stream_shards_per_class"] != 6 || - report["peer_sessions"] == nil { + report["stream_shards_per_class"] != 6 { t.Fatalf("vpn fabric session report missing: %+v", payload.Metadata) } else if report["quic_sessions"] == nil || report["quic_max_streams_per_conn"] != 24 { t.Fatalf("vpn fabric quic session report missing: %+v", report) @@ -1242,14 +1778,14 @@ func TestVPNFabricSessionTargetPrefersRankedQUICCandidate(t *testing.T) { } } -func TestVPNFabricSessionTargetFallsBackToLegacyPeerEndpoint(t *testing.T) { +func TestVPNFabricSessionTargetRejectsNonQUICPeerEndpoint(t *testing.T) { _, ok := vpnFabricSessionTarget(&syntheticMeshState{ PeerEndpoints: map[string]string{ "node-b": "https://node-b.example.test:443/", }, }, "node-b") if ok { - t.Fatal("legacy peer endpoint unexpectedly produced a QUIC target") + t.Fatal("non-QUIC peer endpoint unexpectedly produced a QUIC target") } } @@ -1257,7 +1793,7 @@ func TestVPNFabricSessionTargetsIncludeRankedQUICCandidatesWithoutLegacyFallback now := time.Now().UTC() targets := vpnFabricSessionTargets(&syntheticMeshState{ PeerEndpoints: map[string]string{ - "node-b": "https://node-b-legacy.example.test:443/", + "node-b": "https://node-b-http-migration.example.test:443/", }, PeerEndpointCandidates: map[string][]mesh.PeerEndpointCandidate{ "node-b": { @@ -2731,7 +3267,7 @@ func TestWebIngressForwardHandlerFromConfigVerifiesSignedEnvelope(t *testing.T) keyID := "web-key-1" handler := webIngressForwardHandlerFromConfig(config.Config{ WebIngressTrustedKeysJSON: webingress.TrustedKeysJSONForPublicKey(keyID, publicKey), - }, state.Identity{ClusterID: "cluster-1", NodeID: "node-1"}, nil) + }, state.Identity{ClusterID: "cluster-1", NodeID: "node-1"}, nil, nil) if handler == nil { t.Fatal("handler is nil") } @@ -2780,10 +3316,10 @@ func TestWebIngressForwardHandlerFromConfigVerifiesSignedEnvelope(t *testing.T) } func TestWebIngressForwardHandlerFromConfigDisabledWithoutTrustedKeys(t *testing.T) { - if handler := webIngressForwardHandlerFromConfig(config.Config{}, state.Identity{}, nil); handler != nil { + if handler := webIngressForwardHandlerFromConfig(config.Config{}, state.Identity{}, nil, nil); handler != nil { t.Fatal("handler should be nil without trusted keys") } - if handler := webIngressForwardHandlerFromConfig(config.Config{WebIngressTrustedKeysJSON: `{"bad":"key"}`}, state.Identity{}, nil); handler != nil { + if handler := webIngressForwardHandlerFromConfig(config.Config{WebIngressTrustedKeysJSON: `{"bad":"key"}`}, state.Identity{}, nil, nil); handler != nil { t.Fatal("handler should be nil with invalid trusted keys") } } diff --git a/agents/rap-node-agent/internal/agent/payload.go b/agents/rap-node-agent/internal/agent/payload.go index e6417ec..d4618ee 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.309-latencyaware" +const Version = "0.2.321-directreadytarget" func EnrollmentPayload(clusterID, joinToken string, identity state.Identity) client.EnrollRequest { return client.EnrollRequest{ diff --git a/agents/rap-node-agent/internal/client/client.go b/agents/rap-node-agent/internal/client/client.go index 4977de6..68240a4 100644 --- a/agents/rap-node-agent/internal/client/client.go +++ b/agents/rap-node-agent/internal/client/client.go @@ -828,9 +828,6 @@ func (c *Client) RawControl(ctx context.Context, request RawControlRequest) (Raw if err != nil { return RawControlResponse{}, err } - if httpResp.StatusCode < 200 || httpResp.StatusCode >= 300 { - return RawControlResponse{}, fmt.Errorf("backend returned status %d: %s", httpResp.StatusCode, string(payload)) - } return RawControlResponse{StatusCode: httpResp.StatusCode, Body: json.RawMessage(payload)}, nil } diff --git a/agents/rap-node-agent/internal/config/config.go b/agents/rap-node-agent/internal/config/config.go index 778b90b..2dd65df 100644 --- a/agents/rap-node-agent/internal/config/config.go +++ b/agents/rap-node-agent/internal/config/config.go @@ -1,6 +1,7 @@ package config import ( + "encoding/json" "errors" "flag" "os" @@ -31,7 +32,6 @@ type Config struct { EnrollmentPollTimeout time.Duration MeshSyntheticRuntimeEnabled bool MeshProductionForwardingEnabled bool - MeshFabricSessionEnabled bool VPNFabricSessionTransportEnabled bool MeshQUICFabricEnabled bool MeshQUICFabricListenAddr string @@ -45,6 +45,7 @@ type Config struct { MeshListenAutoPortEnd int MeshAdvertiseEndpoint string MeshAdvertiseEndpointsJSON string + FabricRegistryRecordsJSON string MeshAdvertiseTransport string MeshConnectivityMode string MeshNATType string @@ -86,7 +87,6 @@ func Load(args []string, env map[string]string) (Config, error) { fs.StringVar(&cfg.WebIngressRuntimeServiceClasses, "web-ingress-runtime-service-classes", getEnv(env, "RAP_WEB_INGRESS_RUNTIME_SERVICE_CLASSES", ""), "Optional comma-separated allow-list of web ingress runtime service classes accepted by this node.") fs.BoolVar(&cfg.MeshSyntheticRuntimeEnabled, "mesh-synthetic-runtime-enabled", getEnvBool(env, "RAP_MESH_SYNTHETIC_RUNTIME_ENABLED", false), "Enable C17A synthetic fabric probe runtime. Disabled by default.") fs.BoolVar(&cfg.MeshProductionForwardingEnabled, "mesh-production-forwarding-enabled", getEnvBool(env, "RAP_MESH_PRODUCTION_FORWARDING_ENABLED", false), "Enable production fabric-control direct next-hop forwarding gate. Disabled by default.") - fs.BoolVar(&cfg.MeshFabricSessionEnabled, "mesh-fabric-session-enabled", getEnvBool(env, "RAP_MESH_FABRIC_SESSION_ENABLED", false), "Enable authenticated fabric session endpoint. Disabled by default.") fs.BoolVar(&cfg.VPNFabricSessionTransportEnabled, "vpn-fabric-session-transport-enabled", getEnvBool(env, "RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED", false), "Route VPN packet transport over persistent fabric session when explicitly enabled. Disabled by default.") fs.BoolVar(&cfg.MeshQUICFabricEnabled, "mesh-quic-fabric-enabled", getEnvBool(env, "RAP_MESH_QUIC_FABRIC_ENABLED", false), "Enable QUIC/UDP fabric listener. Disabled by default.") fs.StringVar(&cfg.MeshQUICFabricListenAddr, "mesh-quic-fabric-listen-addr", getEnv(env, "RAP_MESH_QUIC_FABRIC_LISTEN_ADDR", ""), "Listen address for QUIC/UDP fabric endpoint, for example :19443.") @@ -94,12 +94,13 @@ func Load(args []string, env map[string]string) (Config, error) { fs.IntVar(&cfg.VPNFabricQUICMaxStreamsPerConn, "vpn-fabric-quic-max-streams-per-conn", getEnvInt(env, "RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN", 64), "Maximum logical fabric-session streams per cached VPN QUIC carrier connection.") fs.DurationVar(&cfg.VPNFabricQUICIdleTTL, "vpn-fabric-quic-idle-ttl", time.Duration(getEnvInt(env, "RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS", 300))*time.Second, "Idle TTL for cached VPN QUIC carrier connections.") fs.IntVar(&cfg.MeshProductionObservationSinkCapacity, "mesh-production-observation-sink-capacity", getEnvSignedInt(env, "RAP_MESH_PRODUCTION_OBSERVATION_SINK_CAPACITY", 0), "Bounded local metadata-only production envelope observation sink capacity. Disabled when 0.") - fs.StringVar(&cfg.MeshListenAddr, "mesh-listen-addr", getEnv(env, "RAP_MESH_LISTEN_ADDR", ""), "Listen address for disabled-by-default C17E synthetic mesh HTTP endpoint.") + fs.StringVar(&cfg.MeshListenAddr, "mesh-listen-addr", getEnv(env, "RAP_MESH_LISTEN_ADDR", ""), "Listen address for disabled-by-default historical synthetic mesh HTTP endpoint.") fs.StringVar(&cfg.MeshListenPortMode, "mesh-listen-port-mode", getEnv(env, "RAP_MESH_LISTEN_PORT_MODE", "manual"), "Mesh listen port behavior: manual, auto, or disabled.") fs.IntVar(&cfg.MeshListenAutoPortStart, "mesh-listen-auto-port-start", getEnvInt(env, "RAP_MESH_LISTEN_AUTO_PORT_START", 19131), "First port used when mesh listen port mode is auto.") fs.IntVar(&cfg.MeshListenAutoPortEnd, "mesh-listen-auto-port-end", getEnvInt(env, "RAP_MESH_LISTEN_AUTO_PORT_END", 19231), "Last port used when mesh listen port mode is auto.") fs.StringVar(&cfg.MeshAdvertiseEndpoint, "mesh-advertise-endpoint", getEnv(env, "RAP_MESH_ADVERTISE_ENDPOINT", ""), "Advertised mesh endpoint reported to the Control Plane. Empty disables endpoint reporting.") fs.StringVar(&cfg.MeshAdvertiseEndpointsJSON, "mesh-advertise-endpoints-json", getEnv(env, "RAP_MESH_ADVERTISE_ENDPOINTS_JSON", ""), "JSON array of advertised mesh endpoint candidates, including private/corporate endpoints.") + fs.StringVar(&cfg.FabricRegistryRecordsJSON, "fabric-registry-records-json", getEnv(env, "RAP_FABRIC_REGISTRY_RECORDS_JSON", ""), "JSON array of signed QUIC-only fabric registry gossip records used as bootstrap discovery seeds.") fs.StringVar(&cfg.MeshAdvertiseTransport, "mesh-advertise-transport", getEnv(env, "RAP_MESH_ADVERTISE_TRANSPORT", "quic"), "Transport label for the advertised mesh endpoint.") fs.StringVar(&cfg.MeshConnectivityMode, "mesh-connectivity-mode", getEnv(env, "RAP_MESH_CONNECTIVITY_MODE", "direct"), "Connectivity mode reported with the advertised mesh endpoint.") fs.StringVar(&cfg.MeshNATType, "mesh-nat-type", getEnv(env, "RAP_MESH_NAT_TYPE", "unknown"), "NAT type hint reported with the advertised mesh endpoint.") @@ -150,6 +151,7 @@ func Load(args []string, env map[string]string) (Config, error) { } cfg.MeshAdvertiseEndpoint = strings.TrimRight(strings.TrimSpace(cfg.MeshAdvertiseEndpoint), "/") cfg.MeshAdvertiseEndpointsJSON = strings.TrimSpace(cfg.MeshAdvertiseEndpointsJSON) + cfg.FabricRegistryRecordsJSON = strings.TrimSpace(cfg.FabricRegistryRecordsJSON) cfg.MeshAdvertiseTransport = strings.TrimSpace(cfg.MeshAdvertiseTransport) if cfg.MeshAdvertiseTransport == "" { cfg.MeshAdvertiseTransport = "quic" @@ -199,6 +201,9 @@ func Load(args []string, env map[string]string) (Config, error) { if cfg.MeshProductionObservationSinkCapacity > MaxMeshProductionObservationSinkCapacity { return Config{}, errors.New("mesh production observation sink capacity exceeds maximum") } + if cfg.FabricRegistryRecordsJSON != "" && !isJSONArray(cfg.FabricRegistryRecordsJSON) { + return Config{}, errors.New("fabric registry records must be a JSON array") + } switch cfg.MeshListenPortMode { case "", "manual", "auto", "disabled": if cfg.MeshListenPortMode == "" { @@ -269,6 +274,11 @@ func hasLegacyEndpointScheme(endpoint string) bool { strings.HasPrefix(endpoint, "wss://") } +func isJSONArray(value string) bool { + var items []json.RawMessage + return json.Unmarshal([]byte(strings.TrimSpace(value)), &items) == nil +} + func readEnv() map[string]string { out := map[string]string{} for _, pair := range os.Environ() { diff --git a/agents/rap-node-agent/internal/config/config_test.go b/agents/rap-node-agent/internal/config/config_test.go index f352682..5d68db4 100644 --- a/agents/rap-node-agent/internal/config/config_test.go +++ b/agents/rap-node-agent/internal/config/config_test.go @@ -25,7 +25,6 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) { "RAP_ENROLLMENT_POLL_TIMEOUT_SECONDS": "30", "RAP_MESH_SYNTHETIC_RUNTIME_ENABLED": "true", "RAP_MESH_PRODUCTION_FORWARDING_ENABLED": "true", - "RAP_MESH_FABRIC_SESSION_ENABLED": "true", "RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED": "true", "RAP_MESH_QUIC_FABRIC_ENABLED": "true", "RAP_MESH_QUIC_FABRIC_LISTEN_ADDR": ":19443", @@ -39,6 +38,7 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) { "RAP_MESH_LISTEN_AUTO_PORT_END": "19020", "RAP_MESH_ADVERTISE_ENDPOINT": "quic://node-a.example.test:19443/", "RAP_MESH_ADVERTISE_ENDPOINTS_JSON": `[{"endpoint_id":"node-a-lan","address":"10.10.0.20:19001"}]`, + "RAP_FABRIC_REGISTRY_RECORDS_JSON": ` [{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}] `, "RAP_MESH_ADVERTISE_TRANSPORT": "direct_quic", "RAP_MESH_CONNECTIVITY_MODE": "outbound_only", "RAP_MESH_NAT_TYPE": "symmetric", @@ -93,9 +93,6 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) { if !cfg.MeshProductionForwardingEnabled { t.Fatal("MeshProductionForwardingEnabled = false, want true") } - if !cfg.MeshFabricSessionEnabled { - t.Fatal("MeshFabricSessionEnabled = false, want true") - } if !cfg.VPNFabricSessionTransportEnabled { t.Fatal("VPNFabricSessionTransportEnabled = false, want true") } @@ -122,6 +119,7 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) { } if cfg.MeshAdvertiseEndpoint != "quic://node-a.example.test:19443" || cfg.MeshAdvertiseEndpointsJSON == "" || + cfg.FabricRegistryRecordsJSON != `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]` || cfg.MeshAdvertiseTransport != "direct_quic" || cfg.MeshConnectivityMode != "outbound_only" || cfg.MeshNATType != "symmetric" || diff --git a/agents/rap-node-agent/internal/hostagent/config.go b/agents/rap-node-agent/internal/hostagent/config.go index aa50cb1..441738c 100644 --- a/agents/rap-node-agent/internal/hostagent/config.go +++ b/agents/rap-node-agent/internal/hostagent/config.go @@ -1,6 +1,7 @@ package hostagent import ( + "encoding/json" "errors" "fmt" "strings" @@ -29,7 +30,6 @@ type RuntimeConfig struct { WorkloadSupervisionEnabled bool MeshSyntheticRuntimeEnabled bool MeshProductionForwardingEnabled bool - MeshFabricSessionEnabled bool VPNFabricSessionTransportEnabled bool MeshQUICFabricEnabled bool MeshQUICFabricListenAddr string @@ -42,6 +42,7 @@ type RuntimeConfig struct { MeshListenAutoPortEnd int MeshAdvertiseEndpoint string MeshAdvertiseEndpointsJSON string + FabricRegistryRecordsJSON string MeshAdvertiseTransport string MeshConnectivityMode string MeshNATType string @@ -84,6 +85,7 @@ func (cfg RuntimeConfig) Normalize() RuntimeConfig { cfg.MeshListenPortMode = strings.ToLower(strings.TrimSpace(cfg.MeshListenPortMode)) cfg.MeshAdvertiseEndpoint = strings.TrimRight(strings.TrimSpace(cfg.MeshAdvertiseEndpoint), "/") cfg.MeshAdvertiseEndpointsJSON = strings.TrimSpace(cfg.MeshAdvertiseEndpointsJSON) + cfg.FabricRegistryRecordsJSON = strings.TrimSpace(cfg.FabricRegistryRecordsJSON) cfg.MeshAdvertiseTransport = strings.TrimSpace(cfg.MeshAdvertiseTransport) cfg.MeshConnectivityMode = strings.TrimSpace(cfg.MeshConnectivityMode) cfg.MeshNATType = strings.TrimSpace(cfg.MeshNATType) @@ -145,6 +147,9 @@ func (cfg RuntimeConfig) ValidateInstall() error { if cfg.ProductionObservationSinkCap < 0 { return errors.New("production observation sink capacity must not be negative") } + if cfg.FabricRegistryRecordsJSON != "" && !isJSONArray(cfg.FabricRegistryRecordsJSON) { + return errors.New("fabric registry records must be a JSON array") + } for _, item := range cfg.ExtraEnv { if !strings.Contains(item, "=") { return fmt.Errorf("extra env %q must be KEY=VALUE", item) @@ -176,3 +181,8 @@ func hasLegacyEndpointScheme(endpoint string) bool { strings.HasPrefix(endpoint, "ws://") || strings.HasPrefix(endpoint, "wss://") } + +func isJSONArray(value string) bool { + var items []json.RawMessage + return json.Unmarshal([]byte(strings.TrimSpace(value)), &items) == nil +} diff --git a/agents/rap-node-agent/internal/hostagent/docker.go b/agents/rap-node-agent/internal/hostagent/docker.go index ab7f7a5..52f7d12 100644 --- a/agents/rap-node-agent/internal/hostagent/docker.go +++ b/agents/rap-node-agent/internal/hostagent/docker.go @@ -264,7 +264,6 @@ func NodeAgentEnvWithStateDir(cfg RuntimeConfig, stateDir string) []string { "RAP_WORKLOAD_SUPERVISION_ENABLED=" + boolString(cfg.WorkloadSupervisionEnabled), "RAP_MESH_SYNTHETIC_RUNTIME_ENABLED=" + boolString(cfg.MeshSyntheticRuntimeEnabled), "RAP_MESH_PRODUCTION_FORWARDING_ENABLED=" + boolString(cfg.MeshProductionForwardingEnabled), - "RAP_MESH_FABRIC_SESSION_ENABLED=" + boolString(cfg.MeshFabricSessionEnabled), "RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED=" + boolString(cfg.VPNFabricSessionTransportEnabled), "RAP_MESH_QUIC_FABRIC_ENABLED=" + boolString(cfg.MeshQUICFabricEnabled), "RAP_VPN_FABRIC_SESSION_STREAM_SHARDS=" + strconv.Itoa(cfg.VPNFabricSessionStreamShards), @@ -295,6 +294,9 @@ func NodeAgentEnvWithStateDir(cfg RuntimeConfig, stateDir string) []string { if cfg.MeshAdvertiseEndpointsJSON != "" { env = append(env, "RAP_MESH_ADVERTISE_ENDPOINTS_JSON="+cfg.MeshAdvertiseEndpointsJSON) } + if cfg.FabricRegistryRecordsJSON != "" { + env = append(env, "RAP_FABRIC_REGISTRY_RECORDS_JSON="+cfg.FabricRegistryRecordsJSON) + } if cfg.MeshAdvertiseTransport != "" { env = append(env, "RAP_MESH_ADVERTISE_TRANSPORT="+cfg.MeshAdvertiseTransport) } diff --git a/agents/rap-node-agent/internal/hostagent/docker_test.go b/agents/rap-node-agent/internal/hostagent/docker_test.go index c111582..9787aca 100644 --- a/agents/rap-node-agent/internal/hostagent/docker_test.go +++ b/agents/rap-node-agent/internal/hostagent/docker_test.go @@ -74,6 +74,7 @@ func TestDockerRunArgsBuildNodeRuntimePlacement(t *testing.T) { VPNFabricQUICIdleTTLSeconds: 120, MeshListenAddr: ":19131", MeshAdvertiseEndpoint: "quic://10.0.0.11:19443/", + FabricRegistryRecordsJSON: `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`, MeshAdvertiseTransport: "direct_quic", MeshConnectivityMode: "private_lan", }) @@ -96,6 +97,7 @@ func TestDockerRunArgsBuildNodeRuntimePlacement(t *testing.T) { "RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS=120", "RAP_MESH_LISTEN_ADDR=:19131", "RAP_MESH_ADVERTISE_ENDPOINT=quic://10.0.0.11:19443", + `RAP_FABRIC_REGISTRY_RECORDS_JSON=[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api"}]`, "RAP_MESH_ADVERTISE_TRANSPORT=direct_quic", "RAP_MESH_CONNECTIVITY_MODE=private_lan", "rap-node-agent:test", @@ -164,6 +166,11 @@ func TestFetchDockerInstallProfileBuildsRuntimeConfig(t *testing.T) { "node_name": "node-a", "image": "rap-node-agent:test", "artifact_endpoints": []string{"https://cache.example.test/artifacts"}, + "fabric_registry_records": []map[string]any{{ + "schema": "rap.fabric.registry.gossip_record.v1", + "service_class": "control-api", + "service_id": "control-a", + }}, "docker_image_artifact": map[string]any{ "kind": "docker_image_tar", "image": "rap-node-agent:test", @@ -207,6 +214,7 @@ func TestFetchDockerInstallProfileBuildsRuntimeConfig(t *testing.T) { !cfg.MeshQUICFabricEnabled || cfg.MeshQUICFabricListenAddr != ":19443" || cfg.VPNFabricSessionStreamShards != 6 || + cfg.FabricRegistryRecordsJSON != `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api","service_id":"control-a"}]` || cfg.MeshConnectivityMode != "outbound_only" { t.Fatalf("unexpected cfg: %+v", cfg) } diff --git a/agents/rap-node-agent/internal/hostagent/linux.go b/agents/rap-node-agent/internal/hostagent/linux.go index b770559..7ae5e8f 100644 --- a/agents/rap-node-agent/internal/hostagent/linux.go +++ b/agents/rap-node-agent/internal/hostagent/linux.go @@ -72,7 +72,6 @@ func LinuxInstallConfigFromProfile(profile LinuxInstallProfile) LinuxInstallConf WorkloadSupervisionEnabled: profile.WorkloadSupervisionEnabled, MeshSyntheticRuntimeEnabled: profile.MeshSyntheticRuntimeEnabled, MeshProductionForwardingEnabled: profile.MeshProductionForwardingEnabled, - MeshFabricSessionEnabled: profile.MeshFabricSessionEnabled, VPNFabricSessionTransportEnabled: profile.VPNFabricSessionTransportEnabled, MeshQUICFabricEnabled: profile.MeshQUICFabricEnabled, MeshQUICFabricListenAddr: profile.MeshQUICFabricListenAddr, @@ -287,7 +286,6 @@ func installLinuxHostAgentUpdater(ctx context.Context, m LinuxManager, result Li args := []string{ result.HostAgentPath, "update-loop", - "--backend-url", cfg.RuntimeConfig.BackendURL, "--cluster-id", cfg.RuntimeConfig.ClusterID, "--state-dir", result.StateDir, "--current-version", cfg.AutoUpdateCurrentVersion, @@ -303,6 +301,10 @@ func installLinuxHostAgentUpdater(ctx context.Context, m LinuxManager, result Li "--host-agent-current-version", firstNonEmpty(cfg.AutoUpdateCurrentVersion, "0.0.0"), "--host-agent-binary-path", result.HostAgentPath, } + if strings.TrimSpace(cfg.RuntimeConfig.BackendURL) != "" { + args = append(args, "--backend-url", strings.TrimSpace(cfg.RuntimeConfig.BackendURL)) + } + args = appendFabricUpdateArgs(args, cfg.RuntimeConfig) if strings.TrimSpace(cfg.NodeID) != "" { args = append(args, "--node-id", strings.TrimSpace(cfg.NodeID)) } @@ -363,48 +365,48 @@ func (m LinuxManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Updat } status.Payload["systemd_unit"] = req.SystemdUnitName status.Payload["binary_path"] = req.BinaryPath - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, status) + _ = ReportNodeUpdateStatusForRequest(ctx, req, status) } return result, nil } if plan.ProductionForwarding && !req.AllowProductionMesh { err := errors.New("refusing update plan with production forwarding enabled") - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "preflight", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "preflight", "failed", err)) return result, err } if plan.Artifact == nil { err := errors.New("update plan has no artifact") - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "preflight", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "preflight", "failed", err)) return result, err } if plan.Artifact.InstallType != "" && plan.Artifact.InstallType != BinaryUpdateInstallType { err := fmt.Errorf("unsupported update artifact install type %q", plan.Artifact.InstallType) - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "preflight", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "preflight", "failed", err)) return result, err } if req.DryRun { return result, nil } urls := artifactURLsForBackend(*plan.Artifact, req.BackendURL) - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, NodeUpdateStatusRequest{Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, Phase: "download", Status: "started", AttemptID: updateAttemptID(plan), ObservedAt: time.Now().UTC(), Payload: map[string]any{"artifact_url": plan.Artifact.URL, "artifact_urls": urls, "binary_path": req.BinaryPath}}) + _ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, Phase: "download", Status: "started", AttemptID: updateAttemptID(plan), ObservedAt: time.Now().UTC(), Payload: map[string]any{"artifact_url": plan.Artifact.URL, "artifact_urls": urls, "binary_path": req.BinaryPath}}) path, err := downloadFirstArtifact(ctx, urls, plan.Artifact.SHA256, plan.Artifact.SizeBytes) if err != nil { - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "download", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "download", "failed", err)) return result, err } defer os.Remove(path) runner := m.runner() _, _ = runner.Run(ctx, "systemctl", "stop", req.SystemdUnitName) if err := copyFile(path, req.BinaryPath, 0o755); err != nil { - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "apply", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "apply", "failed", err)) return result, err } result.Replaced = true if _, err := runner.Run(ctx, "systemctl", "restart", req.SystemdUnitName); err != nil { - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "restart", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "restart", "failed", err)) return result, err } - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, NodeUpdateStatusRequest{Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, Phase: "health_check", Status: "succeeded", AttemptID: updateAttemptID(plan), ObservedAt: time.Now().UTC(), Payload: map[string]any{"systemd_unit": req.SystemdUnitName, "binary_path": req.BinaryPath}}) + _ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, Phase: "health_check", Status: "succeeded", AttemptID: updateAttemptID(plan), ObservedAt: time.Now().UTC(), Payload: map[string]any{"systemd_unit": req.SystemdUnitName, "binary_path": req.BinaryPath}}) _ = saveUpdateState(req.StateDir, UpdateState{Product: req.Product, CurrentVersion: plan.TargetVersion, TargetVersion: plan.TargetVersion, Image: req.BinaryPath, UpdatedAt: time.Now().UTC()}) return result, nil } diff --git a/agents/rap-node-agent/internal/hostagent/monitor.go b/agents/rap-node-agent/internal/hostagent/monitor.go index 181d8e5..fdbda03 100644 --- a/agents/rap-node-agent/internal/hostagent/monitor.go +++ b/agents/rap-node-agent/internal/hostagent/monitor.go @@ -31,31 +31,34 @@ const ( ) type MonitorConfig struct { - BackendURL string - ClusterID string - NodeID string - StateDir string - Product string - CurrentVersion string - Interval time.Duration - InitialDelay time.Duration - MaxRuns int - DockerBinary string - WatchContainers []string - RestartContainers bool - RestartCooldown time.Duration - StaleRestartingAfter time.Duration - DiskPath string - TmpDir string - DiskWarnPercent int - DiskCleanupPercent int - DiskCriticalPercent int - TmpMinAge time.Duration - CleanupDocker bool - StatusFile string - Runner CommandRunner - Logf func(format string, args ...any) - restartHistory map[string]time.Time + BackendURL string + ClusterID string + NodeID string + StateDir string + ClusterAuthorityPublicKey string + FabricRegistryRecordsJSON string + MeshRegion string + Product string + CurrentVersion string + Interval time.Duration + InitialDelay time.Duration + MaxRuns int + DockerBinary string + WatchContainers []string + RestartContainers bool + RestartCooldown time.Duration + StaleRestartingAfter time.Duration + DiskPath string + TmpDir string + DiskWarnPercent int + DiskCleanupPercent int + DiskCriticalPercent int + TmpMinAge time.Duration + CleanupDocker bool + StatusFile string + Runner CommandRunner + Logf func(format string, args ...any) + restartHistory map[string]time.Time } type DiskUsage struct { @@ -421,7 +424,18 @@ func reportMonitorStatus(ctx context.Context, cfg MonitorConfig, result MonitorR if errText != "" { req.ErrorMessage = &errText } - return ReportNodeUpdateStatus(ctx, cfg.BackendURL, clusterID, nodeID, req) + return ReportNodeUpdateStatusForRequest(ctx, UpdateRequest{ + BackendURL: cfg.BackendURL, + ClusterID: clusterID, + NodeID: nodeID, + StateDir: cfg.StateDir, + ClusterAuthorityPublicKey: cfg.ClusterAuthorityPublicKey, + FabricRegistryRecordsJSON: cfg.FabricRegistryRecordsJSON, + MeshRegion: cfg.MeshRegion, + Product: cfg.Product, + CurrentVersion: cfg.CurrentVersion, + InstallType: DefaultUpdateInstallType, + }, req) } func resolveMonitorIdentity(cfg MonitorConfig) (string, string, error) { diff --git a/agents/rap-node-agent/internal/hostagent/profile.go b/agents/rap-node-agent/internal/hostagent/profile.go index cd4eca8..f925a91 100644 --- a/agents/rap-node-agent/internal/hostagent/profile.go +++ b/agents/rap-node-agent/internal/hostagent/profile.go @@ -16,6 +16,7 @@ type DockerInstallProfile struct { BackendURL string `json:"backend_url"` ControlPlaneEndpoints []string `json:"control_plane_endpoints"` ArtifactEndpoints []string `json:"artifact_endpoints"` + FabricRegistryRecords json.RawMessage `json:"fabric_registry_records"` DockerImageArtifact *DockerArtifact `json:"docker_image_artifact"` JoinToken string `json:"join_token"` NodeName string `json:"node_name"` @@ -30,7 +31,6 @@ type DockerInstallProfile struct { WorkloadSupervisionEnabled bool `json:"workload_supervision_enabled"` MeshSyntheticRuntimeEnabled bool `json:"mesh_synthetic_runtime_enabled"` MeshProductionForwardingEnabled bool `json:"mesh_production_forwarding_enabled"` - MeshFabricSessionEnabled bool `json:"mesh_fabric_session_enabled"` VPNFabricSessionTransportEnabled bool `json:"vpn_fabric_session_transport_enabled"` MeshQUICFabricEnabled bool `json:"mesh_quic_fabric_enabled"` MeshQUICFabricListenAddr string `json:"mesh_quic_fabric_listen_addr"` @@ -70,6 +70,7 @@ type WindowsInstallProfile struct { BackendURL string `json:"backend_url"` ControlPlaneEndpoints []string `json:"control_plane_endpoints"` ArtifactEndpoints []string `json:"artifact_endpoints"` + FabricRegistryRecords json.RawMessage `json:"fabric_registry_records"` NodeAgentArtifact *DockerArtifact `json:"node_agent_artifact"` JoinToken string `json:"join_token"` NodeName string `json:"node_name"` @@ -79,7 +80,6 @@ type WindowsInstallProfile struct { WorkloadSupervisionEnabled bool `json:"workload_supervision_enabled"` MeshSyntheticRuntimeEnabled bool `json:"mesh_synthetic_runtime_enabled"` MeshProductionForwardingEnabled bool `json:"mesh_production_forwarding_enabled"` - MeshFabricSessionEnabled bool `json:"mesh_fabric_session_enabled"` VPNFabricSessionTransportEnabled bool `json:"vpn_fabric_session_transport_enabled"` MeshQUICFabricEnabled bool `json:"mesh_quic_fabric_enabled"` MeshQUICFabricListenAddr string `json:"mesh_quic_fabric_listen_addr"` @@ -109,6 +109,7 @@ type LinuxInstallProfile struct { BackendURL string `json:"backend_url"` ControlPlaneEndpoints []string `json:"control_plane_endpoints"` ArtifactEndpoints []string `json:"artifact_endpoints"` + FabricRegistryRecords json.RawMessage `json:"fabric_registry_records"` NodeAgentArtifact *DockerArtifact `json:"node_agent_artifact"` JoinToken string `json:"join_token"` NodeName string `json:"node_name"` @@ -118,7 +119,6 @@ type LinuxInstallProfile struct { WorkloadSupervisionEnabled bool `json:"workload_supervision_enabled"` MeshSyntheticRuntimeEnabled bool `json:"mesh_synthetic_runtime_enabled"` MeshProductionForwardingEnabled bool `json:"mesh_production_forwarding_enabled"` - MeshFabricSessionEnabled bool `json:"mesh_fabric_session_enabled"` VPNFabricSessionTransportEnabled bool `json:"vpn_fabric_session_transport_enabled"` MeshQUICFabricEnabled bool `json:"mesh_quic_fabric_enabled"` MeshQUICFabricListenAddr string `json:"mesh_quic_fabric_listen_addr"` @@ -302,7 +302,6 @@ func RuntimeConfigFromProfile(profile DockerInstallProfile) RuntimeConfig { WorkloadSupervisionEnabled: profile.WorkloadSupervisionEnabled, MeshSyntheticRuntimeEnabled: profile.MeshSyntheticRuntimeEnabled, MeshProductionForwardingEnabled: profile.MeshProductionForwardingEnabled, - MeshFabricSessionEnabled: profile.MeshFabricSessionEnabled, VPNFabricSessionTransportEnabled: profile.VPNFabricSessionTransportEnabled, MeshQUICFabricEnabled: profile.MeshQUICFabricEnabled, MeshQUICFabricListenAddr: profile.MeshQUICFabricListenAddr, @@ -315,6 +314,7 @@ func RuntimeConfigFromProfile(profile DockerInstallProfile) RuntimeConfig { MeshListenAutoPortEnd: profile.MeshListenAutoPortEnd, MeshAdvertiseEndpoint: profile.MeshAdvertiseEndpoint, MeshAdvertiseEndpointsJSON: string(profile.MeshAdvertiseEndpointsJSON), + FabricRegistryRecordsJSON: string(profile.FabricRegistryRecords), MeshAdvertiseTransport: profile.MeshAdvertiseTransport, MeshConnectivityMode: profile.MeshConnectivityMode, MeshNATType: profile.MeshNATType, diff --git a/agents/rap-node-agent/internal/hostagent/self_update.go b/agents/rap-node-agent/internal/hostagent/self_update.go index 50c90b5..b1bc8d5 100644 --- a/agents/rap-node-agent/internal/hostagent/self_update.go +++ b/agents/rap-node-agent/internal/hostagent/self_update.go @@ -10,19 +10,22 @@ import ( ) type HostAgentUpdateRequest struct { - BackendURL string - ClusterID string - NodeID string - StateDir string - CurrentVersion string - Channel string - OS string - Arch string - InstallType string - BinaryPath string - DryRun bool - RestartService string - RestartAfterApply bool + BackendURL string + ClusterID string + NodeID string + StateDir string + ClusterAuthorityPublicKey string + FabricRegistryRecordsJSON string + MeshRegion string + CurrentVersion string + Channel string + OS string + Arch string + InstallType string + BinaryPath string + DryRun bool + RestartService string + RestartAfterApply bool } type HostAgentUpdateLoopConfig struct { @@ -37,18 +40,21 @@ type HostAgentUpdateLoopConfig struct { func (req HostAgentUpdateRequest) updateRequest() UpdateRequest { return UpdateRequest{ - BackendURL: req.BackendURL, - ClusterID: req.ClusterID, - NodeID: req.NodeID, - StateDir: req.StateDir, - Product: HostAgentUpdateProduct, - CurrentVersion: req.CurrentVersion, - OS: firstNonEmpty(req.OS, "linux"), - Arch: req.Arch, - InstallType: firstNonEmpty(req.InstallType, BinaryUpdateInstallType), - Channel: req.Channel, - ContainerName: "host-agent-service", - DryRun: req.DryRun, + BackendURL: req.BackendURL, + ClusterID: req.ClusterID, + NodeID: req.NodeID, + StateDir: req.StateDir, + ClusterAuthorityPublicKey: req.ClusterAuthorityPublicKey, + FabricRegistryRecordsJSON: req.FabricRegistryRecordsJSON, + MeshRegion: req.MeshRegion, + Product: HostAgentUpdateProduct, + CurrentVersion: req.CurrentVersion, + OS: firstNonEmpty(req.OS, "linux"), + Arch: req.Arch, + InstallType: firstNonEmpty(req.InstallType, BinaryUpdateInstallType), + Channel: req.Channel, + ContainerName: "host-agent-service", + DryRun: req.DryRun, } } @@ -79,25 +85,25 @@ func (m DockerManager) ApplyHostAgentUpdate(ctx context.Context, req HostAgentUp status.Payload = map[string]any{} } status.Payload["binary_path"] = binaryPath - _ = ReportNodeUpdateStatus(ctx, resolved.BackendURL, resolved.ClusterID, resolved.NodeID, status) + _ = ReportNodeUpdateStatusForRequest(ctx, resolved, status) } return result, nil } if plan.Artifact == nil { err := errors.New("host-agent update plan has no artifact") - _ = ReportNodeUpdateStatus(ctx, resolved.BackendURL, resolved.ClusterID, resolved.NodeID, statusFromError(resolved, plan, "preflight", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, resolved, statusFromError(resolved, plan, "preflight", "failed", err)) return result, err } if !isBinaryInstallType(plan.Artifact.InstallType) { err := fmt.Errorf("unsupported host-agent artifact install type %q", plan.Artifact.InstallType) - _ = ReportNodeUpdateStatus(ctx, resolved.BackendURL, resolved.ClusterID, resolved.NodeID, statusFromError(resolved, plan, "preflight", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, resolved, statusFromError(resolved, plan, "preflight", "failed", err)) return result, err } if req.DryRun { return result, nil } urls := artifactURLsForBackend(*plan.Artifact, resolved.BackendURL) - _ = ReportNodeUpdateStatus(ctx, resolved.BackendURL, resolved.ClusterID, resolved.NodeID, NodeUpdateStatusRequest{ + _ = ReportNodeUpdateStatusForRequest(ctx, resolved, NodeUpdateStatusRequest{ Product: HostAgentUpdateProduct, CurrentVersion: resolved.CurrentVersion, TargetVersion: plan.TargetVersion, @@ -109,7 +115,7 @@ func (m DockerManager) ApplyHostAgentUpdate(ctx context.Context, req HostAgentUp }) path, err := downloadFirstArtifact(ctx, urls, plan.Artifact.SHA256, plan.Artifact.SizeBytes) if err != nil { - _ = ReportNodeUpdateStatus(ctx, resolved.BackendURL, resolved.ClusterID, resolved.NodeID, statusFromError(resolved, plan, "download", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, resolved, statusFromError(resolved, plan, "download", "failed", err)) return result, err } defer os.Remove(path) @@ -125,7 +131,7 @@ func (m DockerManager) ApplyHostAgentUpdate(ctx context.Context, req HostAgentUp Image: binaryPath, UpdatedAt: time.Now().UTC(), }) - _ = ReportNodeUpdateStatus(ctx, resolved.BackendURL, resolved.ClusterID, resolved.NodeID, NodeUpdateStatusRequest{ + _ = ReportNodeUpdateStatusForRequest(ctx, resolved, NodeUpdateStatusRequest{ Product: HostAgentUpdateProduct, CurrentVersion: resolved.CurrentVersion, TargetVersion: plan.TargetVersion, @@ -137,7 +143,7 @@ func (m DockerManager) ApplyHostAgentUpdate(ctx context.Context, req HostAgentUp }) return result, nil } - _ = ReportNodeUpdateStatus(ctx, resolved.BackendURL, resolved.ClusterID, resolved.NodeID, statusFromError(resolved, plan, "apply", "failed", fmt.Errorf("%w; stage failed: %v", err, stageErr))) + _ = ReportNodeUpdateStatusForRequest(ctx, resolved, statusFromError(resolved, plan, "apply", "failed", fmt.Errorf("%w; stage failed: %v", err, stageErr))) return result, err } result.Loaded = true @@ -151,7 +157,7 @@ func (m DockerManager) ApplyHostAgentUpdate(ctx context.Context, req HostAgentUp Image: binaryPath, UpdatedAt: time.Now().UTC(), }) - _ = ReportNodeUpdateStatus(ctx, resolved.BackendURL, resolved.ClusterID, resolved.NodeID, NodeUpdateStatusRequest{ + _ = ReportNodeUpdateStatusForRequest(ctx, resolved, NodeUpdateStatusRequest{ Product: HostAgentUpdateProduct, CurrentVersion: resolved.CurrentVersion, TargetVersion: plan.TargetVersion, diff --git a/agents/rap-node-agent/internal/hostagent/service.go b/agents/rap-node-agent/internal/hostagent/service.go index e0f8ca1..dec5e8b 100644 --- a/agents/rap-node-agent/internal/hostagent/service.go +++ b/agents/rap-node-agent/internal/hostagent/service.go @@ -173,8 +173,8 @@ func (m DockerManager) InstallUpdateService(ctx context.Context, cfg UpdateServi func buildUpdateServiceUnit(cfg UpdateServiceConfig) (string, error) { runtimeCfg := cfg.RuntimeConfig.Normalize() var missing []string - if runtimeCfg.BackendURL == "" { - missing = append(missing, "backend-url") + if runtimeCfg.BackendURL == "" && runtimeCfg.FabricRegistryRecordsJSON == "" { + missing = append(missing, "backend-url-or-fabric-registry-records-json") } if runtimeCfg.ClusterID == "" { missing = append(missing, "cluster-id") @@ -191,7 +191,6 @@ func buildUpdateServiceUnit(cfg UpdateServiceConfig) (string, error) { args := []string{ cfg.BinaryInstallPath, "update-loop", - "--backend-url", runtimeCfg.BackendURL, "--cluster-id", runtimeCfg.ClusterID, "--state-dir", runtimeCfg.StateDir, "--container-name", runtimeCfg.ContainerName, @@ -202,9 +201,13 @@ func buildUpdateServiceUnit(cfg UpdateServiceConfig) (string, error) { "--jitter", fmt.Sprintf("%.3f", cfg.Jitter), "--health-timeout-seconds", fmt.Sprintf("%d", cfg.HealthTimeoutSec), } + if runtimeCfg.BackendURL != "" { + args = append(args, "--backend-url", runtimeCfg.BackendURL) + } if strings.TrimSpace(cfg.Channel) != "" { args = append(args, "--channel", strings.TrimSpace(cfg.Channel)) } + args = appendFabricUpdateArgs(args, runtimeCfg) execStart := systemdJoin(args) return fmt.Sprintf(`[Unit] Description=RAP host-agent updater for %s @@ -225,8 +228,8 @@ WantedBy=multi-user.target func buildHostAgentSelfUpdateUnit(cfg UpdateServiceConfig) (string, string, string, error) { runtimeCfg := cfg.RuntimeConfig.Normalize() - if runtimeCfg.BackendURL == "" || runtimeCfg.ClusterID == "" || runtimeCfg.StateDir == "" { - return "", "", "", fmt.Errorf("backend-url, cluster-id, and state-dir are required for host-agent self updater") + if (runtimeCfg.BackendURL == "" && runtimeCfg.FabricRegistryRecordsJSON == "") || runtimeCfg.ClusterID == "" || runtimeCfg.StateDir == "" { + return "", "", "", fmt.Errorf("backend-url-or-fabric-registry-records-json, cluster-id, and state-dir are required for host-agent self updater") } unitName := "rap-host-agent-self-updater.service" unitPath := filepath.Join(firstNonEmpty(cfg.UnitDir, DefaultSystemdUnitDir), unitName) @@ -234,7 +237,6 @@ func buildHostAgentSelfUpdateUnit(cfg UpdateServiceConfig) (string, string, stri args := []string{ cfg.BinaryInstallPath, "update-host-agent-loop", - "--backend-url", runtimeCfg.BackendURL, "--cluster-id", runtimeCfg.ClusterID, "--state-dir", runtimeCfg.StateDir, "--binary-path", firstNonEmpty(cfg.BinaryInstallPath, DefaultHostAgentInstallPath), @@ -243,9 +245,13 @@ func buildHostAgentSelfUpdateUnit(cfg UpdateServiceConfig) (string, string, stri "--initial-delay-seconds", fmt.Sprintf("%d", cfg.InitialDelaySeconds+30), "--jitter", fmt.Sprintf("%.3f", cfg.Jitter), } + if runtimeCfg.BackendURL != "" { + args = append(args, "--backend-url", runtimeCfg.BackendURL) + } if strings.TrimSpace(cfg.Channel) != "" { args = append(args, "--channel", strings.TrimSpace(cfg.Channel)) } + args = appendFabricUpdateArgs(args, runtimeCfg) return fmt.Sprintf(`[Unit] Description=RAP host-agent self updater After=network-online.target docker.service @@ -265,8 +271,8 @@ WantedBy=multi-user.target func buildHostAgentMonitorUnit(cfg UpdateServiceConfig) (string, string, string, error) { runtimeCfg := cfg.RuntimeConfig.Normalize() - if runtimeCfg.BackendURL == "" || runtimeCfg.ClusterID == "" || runtimeCfg.StateDir == "" { - return "", "", "", fmt.Errorf("backend-url, cluster-id, and state-dir are required for host monitor") + if (runtimeCfg.BackendURL == "" && runtimeCfg.FabricRegistryRecordsJSON == "") || runtimeCfg.ClusterID == "" || runtimeCfg.StateDir == "" { + return "", "", "", fmt.Errorf("backend-url-or-fabric-registry-records-json, cluster-id, and state-dir are required for host monitor") } containers := uniqueTrimmed(append([]string{runtimeCfg.ContainerName}, cfg.MonitorContainers...)) if len(containers) == 0 { @@ -277,7 +283,6 @@ func buildHostAgentMonitorUnit(cfg UpdateServiceConfig) (string, string, string, args := []string{ cfg.BinaryInstallPath, "monitor-loop", - "--backend-url", runtimeCfg.BackendURL, "--cluster-id", runtimeCfg.ClusterID, "--state-dir", runtimeCfg.StateDir, "--current-version", firstNonEmpty(cfg.SelfUpdateVersion, cfg.CurrentVersion), @@ -286,6 +291,9 @@ func buildHostAgentMonitorUnit(cfg UpdateServiceConfig) (string, string, string, "--disk-cleanup-percent", fmt.Sprintf("%d", firstNonZero(cfg.MonitorDiskCleanup, DefaultMonitorDiskCleanupPercent)), "--disk-critical-percent", fmt.Sprintf("%d", firstNonZero(cfg.MonitorDiskCritical, DefaultMonitorDiskCriticalPercent)), } + if runtimeCfg.BackendURL != "" { + args = append(args, "--backend-url", runtimeCfg.BackendURL) + } if cfg.MonitorCleanupDocker { args = append(args, "--cleanup-docker") } @@ -295,6 +303,7 @@ func buildHostAgentMonitorUnit(cfg UpdateServiceConfig) (string, string, string, for _, container := range containers { args = append(args, "--watch-container", container) } + args = appendFabricUpdateArgs(args, runtimeCfg) return fmt.Sprintf(`[Unit] Description=RAP host-agent monitor for %s After=network-online.target docker.service @@ -312,6 +321,16 @@ WantedBy=multi-user.target `, runtimeCfg.ContainerName, systemdJoin(args)), unitName, unitPath, nil } +func appendFabricUpdateArgs(args []string, runtimeCfg RuntimeConfig) []string { + if strings.TrimSpace(runtimeCfg.FabricRegistryRecordsJSON) != "" { + args = append(args, "--fabric-registry-records-json", strings.TrimSpace(runtimeCfg.FabricRegistryRecordsJSON)) + } + if strings.TrimSpace(runtimeCfg.MeshRegion) != "" { + args = append(args, "--mesh-region", strings.TrimSpace(runtimeCfg.MeshRegion)) + } + return args +} + func firstNonZero(values ...int) int { for _, value := range values { if value != 0 { diff --git a/agents/rap-node-agent/internal/hostagent/service_test.go b/agents/rap-node-agent/internal/hostagent/service_test.go index 7935360..ca94a35 100644 --- a/agents/rap-node-agent/internal/hostagent/service_test.go +++ b/agents/rap-node-agent/internal/hostagent/service_test.go @@ -119,7 +119,7 @@ func TestWindowsHostAgentUpdateScriptTargetsWindowsService(t *testing.T) { for _, want := range []string{ ":loop", "rap-host-agent.exe.next", - "update-loop --backend-url", + "update-loop --cluster-id", "--backend-url \"http://control/api/v1\"", "--cluster-id \"cluster-1\"", "--node-id \"node-1\"", @@ -139,6 +139,35 @@ func TestWindowsHostAgentUpdateScriptTargetsWindowsService(t *testing.T) { } } +func TestWindowsHostAgentUpdateScriptOmitsEmptyBackendURL(t *testing.T) { + cfg := WindowsInstallConfig{ + RuntimeConfig: RuntimeConfig{ + ClusterID: "cluster-1", + FabricRegistryRecordsJSON: `[{"record_id":"r1"}]`, + MeshRegion: "ru-msk", + }, + AutoUpdateCurrentVersion: "0.1.2", + } + result := WindowsInstallResult{ + NodeName: "win-a", + StateDir: `C:\ProgramData\RAP\nodes\win-a`, + NodeAgentPath: `C:\Program Files\RAP\win-a\rap-node-agent.exe`, + TaskName: "RAP Node Agent win-a", + } + script := windowsHostAgentUpdateScript(`C:\Program Files\RAP\win-a\rap-host-agent.exe`, cfg, result) + if strings.Contains(script, "--backend-url") { + t.Fatalf("script must not include backend-url when it is empty:\n%s", script) + } + for _, want := range []string{ + `--fabric-registry-records-json [{"record_id":"r1"}]`, + "--mesh-region ru-msk", + } { + if !strings.Contains(script, want) { + t.Fatalf("script missing %q:\n%s", want, script) + } + } +} + func TestWindowsInstallReplaceAllowsExistingNodeWithoutJoinToken(t *testing.T) { result, err := (WindowsManager{}).Install(context.Background(), WindowsInstallConfig{ RuntimeConfig: RuntimeConfig{ diff --git a/agents/rap-node-agent/internal/hostagent/update.go b/agents/rap-node-agent/internal/hostagent/update.go index d49248e..3b4a756 100644 --- a/agents/rap-node-agent/internal/hostagent/update.go +++ b/agents/rap-node-agent/internal/hostagent/update.go @@ -3,6 +3,8 @@ package hostagent import ( "bytes" "context" + "crypto/ed25519" + "encoding/base64" "encoding/json" "errors" "fmt" @@ -17,6 +19,8 @@ import ( "time" clusterauth "github.com/example/remote-access-platform/agents/rap-node-agent/internal/authority" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/client" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/state" ) @@ -33,23 +37,26 @@ const ( var ErrNodeIdentityNotReady = errors.New("node identity is not approved yet") type UpdateRequest struct { - BackendURL string - ClusterID string - NodeID string - StateDir string - Product string - CurrentVersion string - OS string - Arch string - InstallType string - Channel string - ContainerName string - BinaryPath string - WindowsTaskName string - SystemdUnitName string - HealthTimeout time.Duration - DryRun bool - AllowProductionMesh bool + BackendURL string + ClusterID string + NodeID string + StateDir string + ClusterAuthorityPublicKey string + FabricRegistryRecordsJSON string + MeshRegion string + Product string + CurrentVersion string + OS string + Arch string + InstallType string + Channel string + ContainerName string + BinaryPath string + WindowsTaskName string + SystemdUnitName string + HealthTimeout time.Duration + DryRun bool + AllowProductionMesh bool } type UpdateResult struct { @@ -204,6 +211,9 @@ func (req UpdateRequest) Normalize() UpdateRequest { req.ClusterID = strings.TrimSpace(req.ClusterID) req.NodeID = strings.TrimSpace(req.NodeID) req.StateDir = strings.TrimSpace(req.StateDir) + req.ClusterAuthorityPublicKey = strings.TrimSpace(req.ClusterAuthorityPublicKey) + req.FabricRegistryRecordsJSON = strings.TrimSpace(req.FabricRegistryRecordsJSON) + req.MeshRegion = strings.TrimSpace(req.MeshRegion) req.Product = firstNonEmpty(req.Product, DefaultUpdateProduct) req.OS = firstNonEmpty(req.OS, runtime.GOOS) req.Arch = firstNonEmpty(req.Arch, runtime.GOARCH) @@ -222,8 +232,8 @@ func (req UpdateRequest) Normalize() UpdateRequest { func (req UpdateRequest) Validate() error { req = req.Normalize() var missing []string - if req.BackendURL == "" { - missing = append(missing, "backend-url") + if req.BackendURL == "" && req.FabricRegistryRecordsJSON == "" { + missing = append(missing, "backend-url-or-fabric-registry-records-json") } if req.ClusterID == "" { missing = append(missing, "cluster-id") @@ -285,30 +295,30 @@ func (m DockerManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upda } if plan.Action != "update" { if !req.DryRun { - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromNoopPlan(req, plan)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromNoopPlan(req, plan)) } return result, nil } if plan.ProductionForwarding && !req.AllowProductionMesh { err := errors.New("refusing update plan with production forwarding enabled") - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "preflight", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "preflight", "failed", err)) return result, err } if plan.Artifact == nil { err := errors.New("update plan has no artifact") - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "preflight", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "preflight", "failed", err)) return result, err } if plan.Artifact.InstallType != "" && plan.Artifact.InstallType != DefaultUpdateInstallType { err := fmt.Errorf("unsupported update artifact install type %q", plan.Artifact.InstallType) - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "preflight", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "preflight", "failed", err)) return result, err } if req.DryRun { result.NewImage = artifactImage(*plan.Artifact, "") return result, nil } - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, NodeUpdateStatusRequest{ + _ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{ Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, @@ -321,7 +331,7 @@ func (m DockerManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upda current, cfg, err := m.runtimeConfigFromContainer(ctx, runner, docker, req.ContainerName) if err != nil { - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "inspect", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "inspect", "failed", err)) return result, err } result.PreviousImageID = current.Image @@ -339,7 +349,7 @@ func (m DockerManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upda cfg.JoinToken = "" result.NewImage = cfg.Image - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, NodeUpdateStatusRequest{ + _ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{ Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, @@ -351,7 +361,7 @@ func (m DockerManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upda }) installed, err := m.Install(ctx, cfg) if err != nil { - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "apply", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "apply", "failed", err)) rollbackErr := m.rollbackContainer(ctx, runner, docker, cfg, current, plan.RollbackAllowed) if rollbackErr == nil && plan.RollbackAllowed { result.RolledBack = true @@ -363,14 +373,14 @@ func (m DockerManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upda result.ContainerID = installed.ContainerID if err := m.waitContainerRunning(ctx, runner, docker, req.ContainerName, req.HealthTimeout); err != nil { - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "health_check", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "health_check", "failed", err)) rollbackErr := m.rollbackContainer(ctx, runner, docker, cfg, current, plan.RollbackAllowed) if rollbackErr == nil && plan.RollbackAllowed { result.RolledBack = true } return result, err } - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, NodeUpdateStatusRequest{ + _ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{ Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, @@ -515,7 +525,27 @@ func FetchNodeUpdatePlan(ctx context.Context, req UpdateRequest) (NodeUpdatePlan if req.Channel != "" { values.Set("channel", req.Channel) } - endpoint := fmt.Sprintf("%s/clusters/%s/nodes/%s/updates/plan?%s", req.BackendURL, url.PathEscape(req.ClusterID), url.PathEscape(req.NodeID), values.Encode()) + path := fmt.Sprintf("/clusters/%s/nodes/%s/updates/plan?%s", url.PathEscape(req.ClusterID), url.PathEscape(req.NodeID), values.Encode()) + if raw, viaFabric, err := updateControlRawViaFabric(ctx, req, client.RawControlRequest{Method: http.MethodGet, Path: path}); viaFabric { + if err != nil { + return NodeUpdatePlan{}, err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return NodeUpdatePlan{}, fmt.Errorf("fetch update plan via fabric: status %d", raw.StatusCode) + } + var out NodeUpdatePlanResponse + if err := json.Unmarshal(raw.Body, &out); err != nil { + return NodeUpdatePlan{}, err + } + if err := verifyNodeUpdatePlanAuthority(req, out.Plan); err != nil { + return NodeUpdatePlan{}, err + } + return out.Plan, nil + } + endpoint := req.BackendURL + path + if req.BackendURL == "" { + return NodeUpdatePlan{}, errors.New("update plan control API is unavailable: no active fabric route and backend-url is empty") + } httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, endpoint, nil) if err != nil { return NodeUpdatePlan{}, err @@ -538,6 +568,110 @@ func FetchNodeUpdatePlan(ctx context.Context, req UpdateRequest) (NodeUpdatePlan return out.Plan, nil } +func updateControlRawViaFabric(ctx context.Context, req UpdateRequest, rawReq client.RawControlRequest) (client.RawControlResponse, bool, error) { + if strings.TrimSpace(req.FabricRegistryRecordsJSON) == "" { + return client.RawControlResponse{}, false, nil + } + publicKey, err := decodeUpdateFabricRegistryPublicKey(req) + if err != nil { + return client.RawControlResponse{}, false, err + } + registry, _, err := mesh.LoadFabricRegistryBootstrapRecords(req.FabricRegistryRecordsJSON, mesh.FabricRegistryVerificationPolicy{ + LocalClusterID: req.ClusterID, + TrustedIssuers: []mesh.FabricRegistryTrustedIssuer{{ + IssuerID: "cluster-authority", + Role: mesh.FabricRegistryAuthorityControl, + PublicKey: publicKey, + Scopes: []string{mesh.FabricRegistryScopeFarm, mesh.FabricRegistryScopeCluster, mesh.FabricRegistryScopeOrganization}, + Services: []string{mesh.FabricRegistryServiceControlAPI}, + }}, + RequiredSignatures: 1, + MaxClockSkew: 2 * time.Minute, + Now: time.Now().UTC(), + }, false) + if err != nil { + return client.RawControlResponse{}, false, err + } + transport := mesh.NewQUICFabricTransport(nil) + if req.NodeID != "" { + transport.SetLocalPeerID(req.NodeID) + } + registry.VerifyCandidates(ctx, transport, mesh.FabricRegistryLiveProbeRequest{ + ClusterID: req.ClusterID, + PreferredRegion: req.MeshRegion, + Timeout: 2 * time.Second, + MaxCandidates: 8, + Now: time.Now().UTC(), + }) + resolved := registry.ResolveService(mesh.FabricRegistryResolveRequest{ + ClusterID: req.ClusterID, + Service: mesh.FabricRegistryServiceControlAPI, + Scope: mesh.FabricRegistryScopeCluster, + PreferredRegion: req.MeshRegion, + Now: time.Now().UTC(), + }) + if !resolved.Found || len(resolved.Endpoints) == 0 { + return client.RawControlResponse{}, false, nil + } + payload, err := json.Marshal(rawReq) + if err != nil { + return client.RawControlResponse{}, false, err + } + var lastErr error + for _, endpoint := range resolved.Endpoints { + result, err := mesh.SendFabricControlForward(ctx, transport, endpoint, payload, 5*time.Second) + if err != nil { + lastErr = err + continue + } + var envelope struct { + Payload json.RawMessage `json:"payload,omitempty"` + Error string `json:"error,omitempty"` + } + if err := json.Unmarshal(result.Payload, &envelope); err != nil { + lastErr = err + continue + } + if strings.TrimSpace(envelope.Error) != "" { + lastErr = errors.New(envelope.Error) + continue + } + var raw client.RawControlResponse + if err := json.Unmarshal(envelope.Payload, &raw); err != nil { + lastErr = err + continue + } + return raw, true, nil + } + if lastErr == nil { + lastErr = errors.New("fabric control registry endpoints unavailable") + } + return client.RawControlResponse{}, false, lastErr +} + +func decodeUpdateFabricRegistryPublicKey(req UpdateRequest) (ed25519.PublicKey, error) { + value := strings.TrimSpace(req.ClusterAuthorityPublicKey) + if value == "" && strings.TrimSpace(req.StateDir) != "" { + if identity, err := state.Load(filepath.Join(req.StateDir, state.FileName)); err == nil { + value = strings.TrimSpace(identity.ClusterAuthorityPublicKey) + } + } + if value == "" { + return nil, errors.New("cluster authority public key is required for fabric registry records") + } + decoded, err := base64.StdEncoding.DecodeString(value) + if err != nil { + decoded, err = base64.RawStdEncoding.DecodeString(value) + } + if err != nil { + decoded, err = base64.RawURLEncoding.DecodeString(value) + } + if err != nil || len(decoded) != ed25519.PublicKeySize { + return nil, errors.New("cluster authority public key must be base64 Ed25519 public key") + } + return ed25519.PublicKey(decoded), nil +} + func verifyNodeUpdatePlanAuthority(req UpdateRequest, plan NodeUpdatePlan) error { identity, ok := pinnedUpdatePlanAuthority(req) if !ok { @@ -642,6 +776,9 @@ func resolveUpdateRequest(req UpdateRequest) (UpdateRequest, error) { func ReportNodeUpdateStatus(ctx context.Context, backendURL, clusterID, nodeID string, request NodeUpdateStatusRequest) error { backendURL = strings.TrimRight(strings.TrimSpace(backendURL), "/") + if backendURL == "" { + return errors.New("update status control API is unavailable: backend-url is empty") + } endpoint := fmt.Sprintf("%s/clusters/%s/nodes/%s/updates/status", backendURL, url.PathEscape(clusterID), url.PathEscape(nodeID)) body, err := json.Marshal(request) if err != nil { @@ -663,6 +800,33 @@ func ReportNodeUpdateStatus(ctx context.Context, backendURL, clusterID, nodeID s return nil } +func ReportNodeUpdateStatusForRequest(ctx context.Context, req UpdateRequest, request NodeUpdateStatusRequest) error { + var err error + req, err = resolveUpdateRequest(req) + if err != nil { + return err + } + body, err := json.Marshal(request) + if err != nil { + return err + } + raw, viaFabric, err := updateControlRawViaFabric(ctx, req, client.RawControlRequest{ + Method: http.MethodPost, + Path: fmt.Sprintf("/clusters/%s/nodes/%s/updates/status", url.PathEscape(req.ClusterID), url.PathEscape(req.NodeID)), + Body: body, + }) + if viaFabric { + if err != nil { + return err + } + if raw.StatusCode < 200 || raw.StatusCode >= 300 { + return fmt.Errorf("report update status via fabric: status %d", raw.StatusCode) + } + return nil + } + return ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, request) +} + func (m DockerManager) runtimeConfigFromContainer(ctx context.Context, runner CommandRunner, docker, containerName string) (dockerInspectContainer, RuntimeConfig, error) { out, err := runner.Run(ctx, docker, "inspect", containerName) if err != nil { @@ -686,9 +850,8 @@ func (m DockerManager) runtimeConfigFromContainer(ctx context.Context, runner Co Network: firstNonEmpty(inspected[0].HostConfig.NetworkMode, DefaultNetwork), RestartPolicy: firstNonEmpty(inspected[0].HostConfig.RestartPolicy.Name, "unless-stopped"), WorkloadSupervisionEnabled: parseBool(env["RAP_WORKLOAD_SUPERVISION_ENABLED"]), - MeshSyntheticRuntimeEnabled: true, + MeshSyntheticRuntimeEnabled: parseBool(env["RAP_MESH_SYNTHETIC_RUNTIME_ENABLED"]), MeshProductionForwardingEnabled: parseBool(env["RAP_MESH_PRODUCTION_FORWARDING_ENABLED"]), - MeshFabricSessionEnabled: parseBool(env["RAP_MESH_FABRIC_SESSION_ENABLED"]), VPNFabricSessionTransportEnabled: parseBool(env["RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED"]), MeshQUICFabricEnabled: parseBool(env["RAP_MESH_QUIC_FABRIC_ENABLED"]), MeshQUICFabricListenAddr: env["RAP_MESH_QUIC_FABRIC_LISTEN_ADDR"], diff --git a/agents/rap-node-agent/internal/hostagent/update_test.go b/agents/rap-node-agent/internal/hostagent/update_test.go index 3c396f7..9919dbb 100644 --- a/agents/rap-node-agent/internal/hostagent/update_test.go +++ b/agents/rap-node-agent/internal/hostagent/update_test.go @@ -4,9 +4,17 @@ import ( "context" "crypto/ed25519" cryptorand "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" "encoding/base64" + "encoding/hex" "encoding/json" "fmt" + "math/big" + "net" "net/http" "net/http/httptest" "os" @@ -16,6 +24,8 @@ import ( "time" clusterauth "github.com/example/remote-access-platform/agents/rap-node-agent/internal/authority" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/client" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/state" ) @@ -120,6 +130,81 @@ func signHostAgentPayload(t *testing.T, payload json.RawMessage, privateKey ed25 } } +func testHostAgentQUICTLSConfig(t *testing.T) *tls.Config { + t.Helper() + key, err := rsa.GenerateKey(cryptorand.Reader, 2048) + if err != nil { + t.Fatalf("generate rsa key: %v", err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "127.0.0.1"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + IPAddresses: []net.IP{net.ParseIP("127.0.0.1")}, + } + der, err := x509.CreateCertificate(cryptorand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + return &tls.Config{ + Certificates: []tls.Certificate{{Certificate: [][]byte{der}, PrivateKey: key}}, + NextProtos: []string{"rap-fabric-data-session-v1"}, + } +} + +func testHostAgentQUICCertSHA256(t *testing.T, cfg *tls.Config) string { + t.Helper() + if len(cfg.Certificates) == 0 || len(cfg.Certificates[0].Certificate) == 0 { + t.Fatal("missing test certificate") + } + sum := sha256.Sum256(cfg.Certificates[0].Certificate[0]) + return hex.EncodeToString(sum[:]) +} + +func signedUpdateControlRegistry(t *testing.T, clusterID, endpoint, certSHA256 string, publicKey ed25519.PublicKey, privateKey ed25519.PrivateKey) string { + t.Helper() + now := time.Now().UTC() + issuer := mesh.FabricRegistryTrustedIssuer{IssuerID: "cluster-authority", Role: mesh.FabricRegistryAuthorityControl, PublicKey: publicKey} + record := mesh.FabricRegistryGossipRecord{ + SchemaVersion: mesh.FabricRegistryGossipRecordSchema, + ClusterID: clusterID, + Service: mesh.FabricRegistryServiceControlAPI, + Scope: mesh.FabricRegistryScopeCluster, + Epoch: 1, + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Hour), + IssuerNodeID: "cluster-authority", + IssuerRole: mesh.FabricRegistryAuthorityControl, + Endpoints: []mesh.FabricRegistryEndpoint{{ + EndpointID: "control-a", + Address: endpoint, + Transport: "direct_quic", + PeerCertSHA256: certSHA256, + }}, + } + signed, err := mesh.SignFabricRegistryGossipRecord(record, issuer, privateKey) + if err != nil { + t.Fatalf("sign registry record: %v", err) + } + raw, err := json.Marshal([]mesh.FabricRegistryGossipRecord{signed}) + if err != nil { + t.Fatalf("marshal registry record: %v", err) + } + return string(raw) +} + +func mustJSONRaw(t *testing.T, value any) json.RawMessage { + t.Helper() + raw, err := json.Marshal(value) + if err != nil { + t.Fatalf("marshal json: %v", err) + } + return raw +} + func TestArtifactURLsForBackendResolvesControlPlaneRelativeDownloads(t *testing.T) { urls := artifactURLsForBackend(ReleaseArtifact{ URL: "/downloads/rap-node-agent-0.2.92.tar", @@ -223,6 +308,111 @@ func TestFetchNodeUpdatePlanAcceptsSignedPlanWithPinnedAuthority(t *testing.T) { } } +func TestFetchNodeUpdatePlanUsesFabricRegistryQUICControlAPI(t *testing.T) { + stateDir, publicKey, privateKey := writePinnedAuthorityIdentity(t) + plan := map[string]any{ + "schema_version": "rap.node_update_plan.v1", + "cluster_id": "cluster-1", + "node_id": "node-1", + "product": "rap-node-agent", + "current_version": "0.1.0", + "action": "none", + "reason": "already_current", + "production_forwarding": false, + } + payload := map[string]any{ + "schema_version": "rap.node_update_plan_authority.v1", + "cluster_id": "cluster-1", + "node_id": "node-1", + "product": "rap-node-agent", + "current_version": "0.1.0", + "action": "none", + "target_version": "", + "artifact_sha256": "", + "control_plane_only": true, + "production_forwarding": false, + } + rawPayload, signature := signedAuthorityPayload(t, publicKey, privateKey, payload) + plan["authority_payload"] = json.RawMessage(rawPayload) + plan["authority_signature"] = signature + tlsConfig := testHostAgentQUICTLSConfig(t) + var received client.RawControlRequest + server, err := mesh.StartQUICFabricServer(context.Background(), mesh.QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + FabricControlHandler: func(_ context.Context, payload []byte) ([]byte, error) { + if err := json.Unmarshal(payload, &received); err != nil { + return nil, err + } + if received.Method != http.MethodGet || !strings.HasPrefix(received.Path, "/clusters/cluster-1/nodes/node-1/updates/plan?") { + return nil, fmt.Errorf("unexpected request: %+v", received) + } + return json.Marshal(client.RawControlResponse{StatusCode: 200, Body: mustJSONRaw(t, map[string]any{"node_update_plan": plan})}) + }, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + got, err := FetchNodeUpdatePlan(context.Background(), UpdateRequest{ + BackendURL: "http://127.0.0.1:1", + ClusterID: "cluster-1", + NodeID: "node-1", + StateDir: stateDir, + FabricRegistryRecordsJSON: signedUpdateControlRegistry(t, "cluster-1", "quic://"+server.Addr().String(), testHostAgentQUICCertSHA256(t, tlsConfig), publicKey, privateKey), + CurrentVersion: "0.1.0", + OS: "linux", + Arch: "amd64", + InstallType: "docker", + }) + if err != nil { + t.Fatalf("fetch plan via fabric: %v", err) + } + if got.Action != "none" || got.Reason != "already_current" { + t.Fatalf("plan = %+v", got) + } +} + +func TestReportNodeUpdateStatusUsesFabricRegistryQUICControlAPI(t *testing.T) { + stateDir, publicKey, privateKey := writePinnedAuthorityIdentity(t) + tlsConfig := testHostAgentQUICTLSConfig(t) + var received client.RawControlRequest + server, err := mesh.StartQUICFabricServer(context.Background(), mesh.QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + FabricControlHandler: func(_ context.Context, payload []byte) ([]byte, error) { + if err := json.Unmarshal(payload, &received); err != nil { + return nil, err + } + if received.Method != http.MethodPost || received.Path != "/clusters/cluster-1/nodes/node-1/updates/status" { + return nil, fmt.Errorf("unexpected request: %+v", received) + } + return json.Marshal(client.RawControlResponse{StatusCode: 204, Body: json.RawMessage(`{}`)}) + }, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + err = ReportNodeUpdateStatusForRequest(context.Background(), UpdateRequest{ + BackendURL: "http://127.0.0.1:1", + ClusterID: "cluster-1", + NodeID: "node-1", + StateDir: stateDir, + FabricRegistryRecordsJSON: signedUpdateControlRegistry(t, "cluster-1", "quic://"+server.Addr().String(), testHostAgentQUICCertSHA256(t, tlsConfig), publicKey, privateKey), + CurrentVersion: "0.1.0", + OS: "linux", + Arch: "amd64", + InstallType: "docker", + }, NodeUpdateStatusRequest{Product: "rap-node-agent", Phase: "download", Status: "started"}) + if err != nil { + t.Fatalf("report status via fabric: %v", err) + } + if len(received.Body) == 0 || !strings.Contains(string(received.Body), `"phase":"download"`) { + t.Fatalf("unexpected status body: %s", string(received.Body)) + } +} + func TestFetchNodeUpdatePlanAcceptsQuorumSignedPlan(t *testing.T) { stateDir, descriptor, privateKeys := writePinnedQuorumIdentity(t) plan := map[string]any{ diff --git a/agents/rap-node-agent/internal/hostagent/windows.go b/agents/rap-node-agent/internal/hostagent/windows.go index dcf15fb..6ff5dfc 100644 --- a/agents/rap-node-agent/internal/hostagent/windows.go +++ b/agents/rap-node-agent/internal/hostagent/windows.go @@ -66,7 +66,6 @@ func WindowsInstallConfigFromProfile(profile WindowsInstallProfile) WindowsInsta WorkloadSupervisionEnabled: profile.WorkloadSupervisionEnabled, MeshSyntheticRuntimeEnabled: profile.MeshSyntheticRuntimeEnabled, MeshProductionForwardingEnabled: profile.MeshProductionForwardingEnabled, - MeshFabricSessionEnabled: profile.MeshFabricSessionEnabled, VPNFabricSessionTransportEnabled: profile.VPNFabricSessionTransportEnabled, MeshQUICFabricEnabled: profile.MeshQUICFabricEnabled, MeshQUICFabricListenAddr: profile.MeshQUICFabricListenAddr, diff --git a/agents/rap-node-agent/internal/hostagent/windows_update.go b/agents/rap-node-agent/internal/hostagent/windows_update.go index 3f11dbb..51845a2 100644 --- a/agents/rap-node-agent/internal/hostagent/windows_update.go +++ b/agents/rap-node-agent/internal/hostagent/windows_update.go @@ -48,29 +48,29 @@ func (m WindowsManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upd } status.Payload["task"] = req.WindowsTaskName status.Payload["binary_path"] = req.BinaryPath - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, status) + _ = ReportNodeUpdateStatusForRequest(ctx, req, status) } return result, nil } if plan.ProductionForwarding && !req.AllowProductionMesh { err := errors.New("refusing update plan with production forwarding enabled") - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "preflight", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "preflight", "failed", err)) return result, err } if plan.Artifact == nil { err := errors.New("update plan has no artifact") - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "preflight", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "preflight", "failed", err)) return result, err } if plan.Artifact.InstallType != "" && plan.Artifact.InstallType != WindowsUpdateInstallType { err := fmt.Errorf("unsupported update artifact install type %q", plan.Artifact.InstallType) - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "preflight", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "preflight", "failed", err)) return result, err } if req.DryRun { return result, nil } - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, NodeUpdateStatusRequest{ + _ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{ Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, @@ -81,7 +81,7 @@ func (m WindowsManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upd Payload: map[string]any{"strategy": plan.Strategy, "reason": plan.Reason, "task": req.WindowsTaskName}, }) urls := artifactURLsForBackend(*plan.Artifact, req.BackendURL) - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, NodeUpdateStatusRequest{ + _ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{ Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, @@ -93,7 +93,7 @@ func (m WindowsManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upd }) path, err := downloadFirstArtifact(ctx, urls, plan.Artifact.SHA256, plan.Artifact.SizeBytes) if err != nil { - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "download", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "download", "failed", err)) return result, err } defer os.Remove(path) @@ -101,16 +101,16 @@ func (m WindowsManager) ApplyUpdate(ctx context.Context, req UpdateRequest) (Upd if err := copyFile(path, req.BinaryPath, 0o755); err != nil { m.stopExistingNodeAgent(ctx, req.WindowsTaskName, req.BinaryPath) if retryErr := copyFile(path, req.BinaryPath, 0o755); retryErr != nil { - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "apply", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "apply", "failed", err)) return result, err } } result.Replaced = true if _, err := runner.Run(ctx, "schtasks", "/Run", "/TN", req.WindowsTaskName); err != nil { - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, statusFromError(req, plan, "restart", "failed", err)) + _ = ReportNodeUpdateStatusForRequest(ctx, req, statusFromError(req, plan, "restart", "failed", err)) return result, err } - _ = ReportNodeUpdateStatus(ctx, req.BackendURL, req.ClusterID, req.NodeID, NodeUpdateStatusRequest{ + _ = ReportNodeUpdateStatusForRequest(ctx, req, NodeUpdateStatusRequest{ Product: req.Product, CurrentVersion: req.CurrentVersion, TargetVersion: plan.TargetVersion, @@ -290,7 +290,6 @@ func windowsHostAgentUpdateScript(hostAgentPath string, cfg WindowsInstallConfig updateLoopArgs := []string{ `"` + hostAgentPath + `"`, "update-loop", - "--backend-url", `"` + cfg.RuntimeConfig.BackendURL + `"`, "--cluster-id", `"` + cfg.RuntimeConfig.ClusterID + `"`, "--state-dir", `"` + result.StateDir + `"`, "--current-version", currentVersion, @@ -306,6 +305,10 @@ func windowsHostAgentUpdateScript(hostAgentPath string, cfg WindowsInstallConfig "--host-agent-current-version", currentVersion, "--host-agent-binary-path", `"` + hostAgentPath + `"`, } + if strings.TrimSpace(cfg.RuntimeConfig.BackendURL) != "" { + updateLoopArgs = append(updateLoopArgs, "--backend-url", `"`+strings.TrimSpace(cfg.RuntimeConfig.BackendURL)+`"`) + } + updateLoopArgs = appendFabricUpdateArgs(updateLoopArgs, cfg.RuntimeConfig) if strings.TrimSpace(cfg.NodeID) != "" { updateLoopArgs = append(updateLoopArgs, "--node-id", `"`+strings.TrimSpace(cfg.NodeID)+`"`) } diff --git a/agents/rap-node-agent/internal/mesh/client.go b/agents/rap-node-agent/internal/mesh/client.go index 57bdbe1..b389c61 100644 --- a/agents/rap-node-agent/internal/mesh/client.go +++ b/agents/rap-node-agent/internal/mesh/client.go @@ -6,13 +6,7 @@ import ( "encoding/json" "fmt" "net/http" - "net/url" - "strings" - "sync" "time" - - "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" - "github.com/gorilla/websocket" ) type Client struct { @@ -20,38 +14,6 @@ type Client struct { HTTPClient *http.Client } -type FabricSessionDialOptions struct { - Token string - Header http.Header - Dialer *websocket.Dialer - Timeout time.Duration - MaxPayload int -} - -type FabricSessionClient struct { - conn *websocket.Conn - timeout time.Duration - maxPayload int - readMu sync.Mutex - writeMu sync.Mutex -} - -type FabricSessionPumpOptions struct { - OutboundBuffer int - InboundBuffer int - ErrorBuffer int -} - -type FabricSessionPump struct { - session *FabricSessionClient - outbound chan fabricproto.Frame - inbound chan fabricproto.Frame - errors chan error - done chan struct{} - cancel context.CancelFunc - closeMu sync.Once -} - func NewClient(baseURL string) Client { return Client{ BaseURL: baseURL, @@ -147,270 +109,3 @@ func (c Client) SendProduction(ctx context.Context, envelope ProductionEnvelope) } return result, nil } - -func (c Client) DialFabricSession(ctx context.Context, opts FabricSessionDialOptions) (*websocket.Conn, *http.Response, error) { - target, err := c.fabricSessionWebSocketURL() - if err != nil { - return nil, nil, err - } - header := cloneHeader(opts.Header) - if strings.TrimSpace(opts.Token) != "" { - header.Set("X-RAP-Fabric-Session-Token", strings.TrimSpace(opts.Token)) - } - dialer := opts.Dialer - if dialer == nil { - base := *websocket.DefaultDialer - if opts.Timeout > 0 { - base.HandshakeTimeout = opts.Timeout - } - dialer = &base - } - return dialer.DialContext(ctx, target, header) -} - -func (c Client) OpenFabricSession(ctx context.Context, opts FabricSessionDialOptions) (*FabricSessionClient, *http.Response, error) { - conn, resp, err := c.DialFabricSession(ctx, opts) - if err != nil { - if resp != nil { - return nil, resp, fmt.Errorf("fabric session websocket rejected with status %d: %w", resp.StatusCode, err) - } - return nil, resp, err - } - maxPayload := opts.MaxPayload - if maxPayload <= 0 { - maxPayload = fabricproto.DefaultMaxPayload - } - return &FabricSessionClient{ - conn: conn, - timeout: opts.Timeout, - maxPayload: maxPayload, - }, resp, nil -} - -func (c Client) SendFabricSessionFrame(ctx context.Context, opts FabricSessionDialOptions, frame fabricproto.Frame) (fabricproto.Frame, error) { - session, _, err := c.OpenFabricSession(ctx, opts) - if err != nil { - return fabricproto.Frame{}, err - } - defer session.Close() - return session.RoundTrip(ctx, frame) -} - -func (c *FabricSessionClient) Close() error { - if c == nil || c.conn == nil { - return nil - } - return c.conn.Close() -} - -func (c *FabricSessionClient) WriteFrame(ctx context.Context, frame fabricproto.Frame) error { - if c == nil || c.conn == nil { - return fmt.Errorf("fabric session client is closed") - } - payload, err := fabricproto.MarshalFrame(frame) - if err != nil { - return err - } - c.writeMu.Lock() - defer c.writeMu.Unlock() - c.applyWriteDeadline(ctx) - return c.conn.WriteMessage(websocket.BinaryMessage, payload) -} - -func (c *FabricSessionClient) ReadFrame(ctx context.Context) (fabricproto.Frame, error) { - if c == nil || c.conn == nil { - return fabricproto.Frame{}, fmt.Errorf("fabric session client is closed") - } - c.readMu.Lock() - defer c.readMu.Unlock() - c.applyReadDeadline(ctx) - messageType, responsePayload, err := c.conn.ReadMessage() - if err != nil { - return fabricproto.Frame{}, err - } - if messageType != websocket.BinaryMessage { - return fabricproto.Frame{}, fmt.Errorf("fabric session websocket returned non-binary message type %d", messageType) - } - return fabricproto.UnmarshalFrame(responsePayload, c.maxPayload) -} - -func (c *FabricSessionClient) RoundTrip(ctx context.Context, frame fabricproto.Frame) (fabricproto.Frame, error) { - if err := c.WriteFrame(ctx, frame); err != nil { - return fabricproto.Frame{}, err - } - return c.ReadFrame(ctx) -} - -func (c *FabricSessionClient) StartPump(ctx context.Context, opts FabricSessionPumpOptions) *FabricSessionPump { - if opts.OutboundBuffer <= 0 { - opts.OutboundBuffer = 64 - } - if opts.InboundBuffer <= 0 { - opts.InboundBuffer = 64 - } - if opts.ErrorBuffer <= 0 { - opts.ErrorBuffer = 8 - } - pumpCtx, cancel := context.WithCancel(ctx) - pump := &FabricSessionPump{ - session: c, - outbound: make(chan fabricproto.Frame, opts.OutboundBuffer), - inbound: make(chan fabricproto.Frame, opts.InboundBuffer), - errors: make(chan error, opts.ErrorBuffer), - done: make(chan struct{}), - cancel: cancel, - } - go pump.writeLoop(pumpCtx) - go pump.readLoop(pumpCtx) - return pump -} - -func (p *FabricSessionPump) Send(ctx context.Context, frame fabricproto.Frame) error { - if p == nil { - return fmt.Errorf("fabric session pump is nil") - } - select { - case <-ctx.Done(): - return ctx.Err() - case <-p.done: - return fmt.Errorf("fabric session pump is closed") - case p.outbound <- frame: - return nil - } -} - -func (p *FabricSessionPump) Frames() <-chan fabricproto.Frame { - if p == nil { - return nil - } - return p.inbound -} - -func (p *FabricSessionPump) Errors() <-chan error { - if p == nil { - return nil - } - return p.errors -} - -func (p *FabricSessionPump) Closed() bool { - if p == nil { - return true - } - select { - case <-p.done: - return true - default: - return false - } -} - -func (p *FabricSessionPump) Close() error { - if p == nil { - return nil - } - var err error - p.closeMu.Do(func() { - close(p.done) - p.cancel() - err = p.session.Close() - }) - return err -} - -func (p *FabricSessionPump) writeLoop(ctx context.Context) { - defer p.Close() - for { - select { - case <-ctx.Done(): - p.reportError(ctx.Err()) - return - case <-p.done: - return - case frame := <-p.outbound: - if err := p.session.WriteFrame(ctx, frame); err != nil { - p.reportError(err) - return - } - } - } -} - -func (p *FabricSessionPump) readLoop(ctx context.Context) { - defer p.Close() - for { - frame, err := p.session.ReadFrame(ctx) - if err != nil { - p.reportError(err) - return - } - select { - case <-ctx.Done(): - p.reportError(ctx.Err()) - return - case <-p.done: - return - case p.inbound <- frame: - } - } -} - -func (p *FabricSessionPump) reportError(err error) { - if err == nil { - return - } - select { - case p.errors <- err: - default: - } -} - -func (c *FabricSessionClient) applyReadDeadline(ctx context.Context) { - if deadline, ok := ctx.Deadline(); ok { - _ = c.conn.SetReadDeadline(deadline) - } else if c.timeout > 0 { - _ = c.conn.SetReadDeadline(time.Now().Add(c.timeout)) - } -} - -func (c *FabricSessionClient) applyWriteDeadline(ctx context.Context) { - if deadline, ok := ctx.Deadline(); ok { - _ = c.conn.SetWriteDeadline(deadline) - } else if c.timeout > 0 { - _ = c.conn.SetWriteDeadline(time.Now().Add(c.timeout)) - } -} - -func (c Client) fabricSessionWebSocketURL() (string, error) { - base := strings.TrimSpace(c.BaseURL) - if base == "" { - return "", fmt.Errorf("mesh base url is required") - } - parsed, err := url.Parse(base) - if err != nil { - return "", err - } - switch parsed.Scheme { - case "http": - parsed.Scheme = "ws" - case "https": - parsed.Scheme = "wss" - case "ws", "wss": - default: - return "", fmt.Errorf("unsupported mesh base url scheme %q", parsed.Scheme) - } - parsed.Path = strings.TrimRight(parsed.Path, "/") + "/mesh/v1/fabric/session/ws" - parsed.RawQuery = "" - parsed.Fragment = "" - return parsed.String(), nil -} - -func cloneHeader(header http.Header) http.Header { - out := http.Header{} - for key, values := range header { - for _, value := range values { - out.Add(key, value) - } - } - return out -} diff --git a/agents/rap-node-agent/internal/mesh/client_test.go b/agents/rap-node-agent/internal/mesh/client_test.go deleted file mode 100644 index 9bc92e9..0000000 --- a/agents/rap-node-agent/internal/mesh/client_test.go +++ /dev/null @@ -1,243 +0,0 @@ -package mesh - -import ( - "context" - "net/http/httptest" - "testing" - "time" - - "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" -) - -func TestClientFabricSessionFrameRoundTrip(t *testing.T) { - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - }.Handler()) - defer server.Close() - - client := NewClient(server.URL) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - response, err := client.SendFabricSessionFrame(ctx, FabricSessionDialOptions{ - Token: "rap_fsn_clienttest", - Timeout: time.Second, - }, fabricproto.Frame{ - Type: fabricproto.FramePing, - Sequence: 12, - Payload: []byte("probe"), - }) - if err != nil { - t.Fatalf("send fabric session frame: %v", err) - } - if response.Type != fabricproto.FramePong || response.Sequence != 12 || string(response.Payload) != "probe" { - t.Fatalf("response = %+v, want pong seq 12", response) - } -} - -func TestClientFabricSessionPersistentRoundTrips(t *testing.T) { - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - }.Handler()) - defer server.Close() - - client := NewClient(server.URL) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - session, _, err := client.OpenFabricSession(ctx, FabricSessionDialOptions{ - Token: "rap_fsn_persistent", - Timeout: time.Second, - }) - if err != nil { - t.Fatalf("open fabric session: %v", err) - } - defer session.Close() - - first, err := session.RoundTrip(ctx, fabricproto.Frame{ - Type: fabricproto.FramePing, - Sequence: 1, - Payload: []byte("first"), - }) - if err != nil { - t.Fatalf("first round trip: %v", err) - } - second, err := session.RoundTrip(ctx, fabricproto.Frame{ - Type: fabricproto.FramePing, - Sequence: 2, - Payload: []byte("second"), - }) - if err != nil { - t.Fatalf("second round trip: %v", err) - } - if first.Type != fabricproto.FramePong || first.Sequence != 1 || string(first.Payload) != "first" { - t.Fatalf("first response = %+v, want pong seq 1", first) - } - if second.Type != fabricproto.FramePong || second.Sequence != 2 || string(second.Payload) != "second" { - t.Fatalf("second response = %+v, want pong seq 2", second) - } -} - -func TestClientFabricSessionPersistentDataAcks(t *testing.T) { - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - }.Handler()) - defer server.Close() - - client := NewClient(server.URL) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - session, _, err := client.OpenFabricSession(ctx, FabricSessionDialOptions{ - Token: "rap_fsn_dataacks", - Timeout: time.Second, - }) - if err != nil { - t.Fatalf("open fabric session: %v", err) - } - defer session.Close() - - if err := session.WriteFrame(ctx, fabricproto.Frame{ - Type: fabricproto.FrameOpenStream, - StreamID: 77, - TrafficClass: fabricproto.TrafficClassInteractive, - }); err != nil { - t.Fatalf("open stream frame: %v", err) - } - - first, err := session.RoundTrip(ctx, fabricproto.Frame{ - Type: fabricproto.FrameData, - StreamID: 77, - Sequence: 10, - TrafficClass: fabricproto.TrafficClassInteractive, - Payload: []byte("first payload"), - }) - if err != nil { - t.Fatalf("first data round trip: %v", err) - } - second, err := session.RoundTrip(ctx, fabricproto.Frame{ - Type: fabricproto.FrameData, - StreamID: 77, - Sequence: 11, - TrafficClass: fabricproto.TrafficClassInteractive, - Payload: []byte("second payload"), - }) - if err != nil { - t.Fatalf("second data round trip: %v", err) - } - if first.Type != fabricproto.FrameAck || first.StreamID != 77 || first.Sequence != 10 { - t.Fatalf("first ack = %+v, want stream 77 seq 10", first) - } - if second.Type != fabricproto.FrameAck || second.StreamID != 77 || second.Sequence != 11 { - t.Fatalf("second ack = %+v, want stream 77 seq 11", second) - } -} - -func TestClientFabricSessionPumpMovesIndependentFrames(t *testing.T) { - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - }.Handler()) - defer server.Close() - - client := NewClient(server.URL) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - session, _, err := client.OpenFabricSession(ctx, FabricSessionDialOptions{ - Token: "rap_fsn_pump", - Timeout: time.Second, - }) - if err != nil { - t.Fatalf("open fabric session: %v", err) - } - pump := session.StartPump(ctx, FabricSessionPumpOptions{ - OutboundBuffer: 4, - InboundBuffer: 4, - ErrorBuffer: 4, - }) - defer pump.Close() - - if err := pump.Send(ctx, fabricproto.Frame{ - Type: fabricproto.FrameOpenStream, - StreamID: 900, - TrafficClass: fabricproto.TrafficClassBulk, - }); err != nil { - t.Fatalf("send open bulk stream: %v", err) - } - if err := pump.Send(ctx, fabricproto.Frame{ - Type: fabricproto.FrameData, - StreamID: 900, - Sequence: 31, - TrafficClass: fabricproto.TrafficClassBulk, - Payload: []byte("bulk payload"), - }); err != nil { - t.Fatalf("send bulk data: %v", err) - } - if err := pump.Send(ctx, fabricproto.Frame{ - Type: fabricproto.FramePing, - Sequence: 32, - Payload: []byte("control ping"), - }); err != nil { - t.Fatalf("send ping: %v", err) - } - - gotAck := false - gotPong := false - for !gotAck || !gotPong { - select { - case frame := <-pump.Frames(): - switch { - case frame.Type == fabricproto.FrameAck && frame.StreamID == 900 && frame.Sequence == 31: - gotAck = true - case frame.Type == fabricproto.FramePong && frame.Sequence == 32 && string(frame.Payload) == "control ping": - gotPong = true - } - case err := <-pump.Errors(): - t.Fatalf("pump error: %v", err) - case <-ctx.Done(): - t.Fatalf("timed out waiting for pump frames: ack=%v pong=%v", gotAck, gotPong) - } - } -} - -func TestClientFabricSessionReportsRejectedStatus(t *testing.T) { - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - }.Handler()) - defer server.Close() - - client := NewClient(server.URL) - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - _, err := client.SendFabricSessionFrame(ctx, FabricSessionDialOptions{}, fabricproto.Frame{Type: fabricproto.FramePing}) - if err == nil { - t.Fatal("send fabric session without token unexpectedly succeeded") - } -} - -func TestClientFabricSessionWebSocketURL(t *testing.T) { - cases := []struct { - base string - want string - }{ - {base: "http://node.example", want: "ws://node.example/mesh/v1/fabric/session/ws"}, - {base: "https://node.example/base/", want: "wss://node.example/base/mesh/v1/fabric/session/ws"}, - {base: "ws://node.example", want: "ws://node.example/mesh/v1/fabric/session/ws"}, - } - for _, tc := range cases { - client := NewClient(tc.base) - got, err := client.fabricSessionWebSocketURL() - if err != nil { - t.Fatalf("fabricSessionWebSocketURL(%q): %v", tc.base, err) - } - if got != tc.want { - t.Fatalf("fabricSessionWebSocketURL(%q) = %q, want %q", tc.base, got, tc.want) - } - } -} diff --git a/agents/rap-node-agent/internal/mesh/fabric_control_transport.go b/agents/rap-node-agent/internal/mesh/fabric_control_transport.go new file mode 100644 index 0000000..95dec25 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_control_transport.go @@ -0,0 +1,94 @@ +package mesh + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync/atomic" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" +) + +var fabricControlForwardSequence atomic.Uint64 + +type FabricControlForwardResult struct { + Payload json.RawMessage `json:"payload,omitempty"` + LatencyMs int64 `json:"latency_ms"` + Endpoint string `json:"endpoint,omitempty"` +} + +func FabricTransportTargetFromRegistryEndpoint(endpoint FabricRegistryEndpoint) FabricTransportTarget { + return FabricTransportTarget{ + EndpointID: strings.TrimSpace(endpoint.EndpointID), + PeerID: strings.TrimSpace(endpoint.EndpointID), + Endpoint: strings.TrimSpace(endpoint.Address), + Transport: strings.TrimSpace(endpoint.Transport), + PeerCertSHA256: strings.TrimSpace(endpoint.PeerCertSHA256), + Timeout: 5 * time.Second, + InboundBuffer: 4, + ErrorBuffer: 4, + } +} + +func SendFabricControlForward(ctx context.Context, transport FabricTransport, endpoint FabricRegistryEndpoint, payload []byte, timeout time.Duration) (FabricControlForwardResult, error) { + if transport == nil { + return FabricControlForwardResult{}, fmt.Errorf("fabric control transport is unavailable") + } + if len(payload) == 0 { + return FabricControlForwardResult{}, fmt.Errorf("fabric control payload is empty") + } + if timeout <= 0 { + timeout = 5 * time.Second + } + target := FabricTransportTargetFromRegistryEndpoint(endpoint) + target.Timeout = timeout + session, err := transport.Connect(ctx, target) + if err != nil { + return FabricControlForwardResult{}, err + } + defer session.Close() + sequence := fabricControlForwardSequence.Add(1) + if err := session.Send(ctx, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: fabricproto.TrafficClassReliable, + StreamID: FabricControlForwardQUICStreamID, + Sequence: sequence, + Payload: append([]byte(nil), payload...), + }); err != nil { + return FabricControlForwardResult{}, err + } + waitCtx := ctx + var cancel context.CancelFunc + if timeout > 0 { + waitCtx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + startedAt := time.Now() + for { + select { + case <-waitCtx.Done(): + return FabricControlForwardResult{}, waitCtx.Err() + case err, ok := <-session.Errors(): + if !ok { + return FabricControlForwardResult{}, fmt.Errorf("fabric control session closed") + } + if err != nil { + return FabricControlForwardResult{}, err + } + case frame, ok := <-session.Frames(): + if !ok { + return FabricControlForwardResult{}, fmt.Errorf("fabric control session closed") + } + if frame.Type != fabricproto.FrameData || frame.StreamID != FabricControlForwardQUICStreamID || frame.Sequence != sequence { + continue + } + return FabricControlForwardResult{ + Payload: append(json.RawMessage(nil), frame.Payload...), + LatencyMs: time.Since(startedAt).Milliseconds(), + Endpoint: endpoint.Address, + }, nil + } + } +} diff --git a/agents/rap-node-agent/internal/mesh/fabric_quic_transport_test.go b/agents/rap-node-agent/internal/mesh/fabric_quic_transport_test.go index d23bade..1ce2332 100644 --- a/agents/rap-node-agent/internal/mesh/fabric_quic_transport_test.go +++ b/agents/rap-node-agent/internal/mesh/fabric_quic_transport_test.go @@ -565,6 +565,43 @@ func TestQUICFabricServerHandlesWebIngressForwardFrames(t *testing.T) { } } +func TestSendFabricControlForwardUsesQUICStream(t *testing.T) { + tlsConfig := testQUICTLSConfig(t) + server, err := StartQUICFabricServer(context.Background(), QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + FabricControlHandler: func(_ context.Context, payload []byte) ([]byte, error) { + if string(payload) != `{"method":"GET","path":"/auth/login"}` { + return nil, ErrForwardRuntimeUnavailable + } + return []byte(`{"status_code":200,"body":{"ok":true}}`), nil + }, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + result, err := SendFabricControlForward(ctx, NewQUICFabricTransport(nil), FabricRegistryEndpoint{ + EndpointID: "control-a", + Address: "quic://" + server.Addr().String(), + Transport: "direct_quic", + PeerCertSHA256: testQUICCertSHA256(t, tlsConfig), + }, []byte(`{"method":"GET","path":"/auth/login"}`), time.Second) + if err != nil { + t.Fatalf("send fabric control forward: %v", err) + } + var response quicFabricControlForwardResponse + if err := json.Unmarshal(result.Payload, &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Error != "" || string(response.Payload) != `{"status_code":200,"body":{"ok":true}}` { + t.Fatalf("response = %+v", response) + } +} + func startQUICFabricEchoServer(t *testing.T) *quic.Listener { t.Helper() return startQUICFabricEchoServerWithTLS(t, testQUICTLSConfig(t)) diff --git a/agents/rap-node-agent/internal/mesh/fabric_route_planner.go b/agents/rap-node-agent/internal/mesh/fabric_route_planner.go index 6c99461..d043fbf 100644 --- a/agents/rap-node-agent/internal/mesh/fabric_route_planner.go +++ b/agents/rap-node-agent/internal/mesh/fabric_route_planner.go @@ -164,6 +164,7 @@ func fabricRouteHopsForCandidate(candidate PeerEndpointCandidate, metadata Fabri case FabricRouteRelay: relayNodeID := firstNonEmpty(strings.TrimSpace(metadata.RelayNodeID), strings.TrimSpace(metadata.ViaNodeID)) relayEndpoint := firstNonEmpty(strings.TrimRight(strings.TrimSpace(metadata.RelayEndpoint), "/"), endpoint) + relayPeerCertSHA256 := candidatePeerCertSHA256(candidate) hops := []FabricRouteHop{} if localNodeID != "" { hops = append(hops, FabricRouteHop{NodeID: localNodeID, Mode: FabricRouteDirect}) @@ -173,7 +174,7 @@ func fabricRouteHopsForCandidate(candidate PeerEndpointCandidate, metadata Fabri return hops } hops = append(hops, - FabricRouteHop{NodeID: relayNodeID, Mode: FabricRouteRelay, EndpointID: candidate.EndpointID + ":relay", Address: relayEndpoint}, + FabricRouteHop{NodeID: relayNodeID, Mode: FabricRouteRelay, EndpointID: candidate.EndpointID + ":relay", Address: relayEndpoint, PeerCertSHA256: relayPeerCertSHA256}, FabricRouteHop{NodeID: targetNodeID, Mode: FabricRouteRelay, EndpointID: candidate.EndpointID, Address: endpoint, PeerCertSHA256: candidatePeerCertSHA256(candidate)}, ) return hops diff --git a/agents/rap-node-agent/internal/mesh/fabric_route_planner_test.go b/agents/rap-node-agent/internal/mesh/fabric_route_planner_test.go index d72b6ac..30b9d0f 100644 --- a/agents/rap-node-agent/internal/mesh/fabric_route_planner_test.go +++ b/agents/rap-node-agent/internal/mesh/fabric_route_planner_test.go @@ -44,7 +44,13 @@ func TestFabricRouteSetForPeerEndpointCandidatesPrefersLocalLAN(t *testing.T) { } func TestFabricRouteSetForPeerEndpointCandidatesBuildsRelayFallback(t *testing.T) { - metadata, _ := json.Marshal(FabricCandidateMetadata{RelayNodeID: "node-r", RelayEndpoint: "quic://node-r:19443"}) + metadata, _ := json.Marshal(struct { + FabricCandidateMetadata + TLSCertSHA256 string `json:"tls_cert_sha256,omitempty"` + }{ + FabricCandidateMetadata: FabricCandidateMetadata{RelayNodeID: "node-r", RelayEndpoint: "quic://node-r:19443"}, + TLSCertSHA256: "relay-cert", + }) routeSet := FabricRouteSetForPeerEndpointCandidates("node-b", []PeerEndpointCandidate{{ EndpointID: "node-b-relay", NodeID: "node-b", @@ -69,6 +75,9 @@ func TestFabricRouteSetForPeerEndpointCandidatesBuildsRelayFallback(t *testing.T if got := routeSet.Primary.Hops[1].NodeID; got != "node-r" { t.Fatalf("relay hop = %q, want node-r", got) } + if got := routeSet.Primary.Hops[1].PeerCertSHA256; got != "relay-cert" { + t.Fatalf("relay hop peer cert = %q, want relay-cert", got) + } if routeSet.Primary.Capacity != 50 { t.Fatalf("capacity = %d, want 50", routeSet.Primary.Capacity) } diff --git a/agents/rap-node-agent/internal/mesh/fabric_session_manager.go b/agents/rap-node-agent/internal/mesh/fabric_session_manager.go deleted file mode 100644 index b1ed653..0000000 --- a/agents/rap-node-agent/internal/mesh/fabric_session_manager.go +++ /dev/null @@ -1,156 +0,0 @@ -package mesh - -import ( - "context" - "fmt" - "strings" - "sync" -) - -type FabricSessionPeerManager struct { - mu sync.Mutex - sessions map[string]*FabricSessionPump - stats FabricSessionPeerManagerStats -} - -type FabricSessionPeerTarget struct { - PeerID string - BaseURL string - Options FabricSessionDialOptions - Pump FabricSessionPumpOptions -} - -type FabricSessionPeerManagerStats struct { - Opens uint64 `json:"opens"` - Reuses uint64 `json:"reuses"` - ClosedEvicted uint64 `json:"closed_evicted"` - ClosePeerCalls uint64 `json:"close_peer_calls"` - CloseAllCalls uint64 `json:"close_all_calls"` -} - -type FabricSessionPeerManagerSnapshot struct { - SchemaVersion string `json:"schema_version"` - ActiveCount int `json:"active_count"` - ClosedCount int `json:"closed_count"` - Stats FabricSessionPeerManagerStats `json:"stats"` -} - -func NewFabricSessionPeerManager() *FabricSessionPeerManager { - return &FabricSessionPeerManager{ - sessions: map[string]*FabricSessionPump{}, - } -} - -func (m *FabricSessionPeerManager) Get(ctx context.Context, target FabricSessionPeerTarget) (*FabricSessionPump, error) { - if m == nil { - return nil, fmt.Errorf("fabric session peer manager is nil") - } - key, err := fabricSessionPeerKey(target) - if err != nil { - return nil, err - } - m.mu.Lock() - if pump := m.sessions[key]; pump != nil { - if pump.Closed() { - delete(m.sessions, key) - m.stats.ClosedEvicted++ - } else { - m.stats.Reuses++ - m.mu.Unlock() - return pump, nil - } - } - m.mu.Unlock() - - session, _, err := NewClient(target.BaseURL).OpenFabricSession(ctx, target.Options) - if err != nil { - return nil, err - } - pump := session.StartPump(context.Background(), target.Pump) - - m.mu.Lock() - if existing := m.sessions[key]; existing != nil { - if existing.Closed() { - delete(m.sessions, key) - m.stats.ClosedEvicted++ - } else { - m.stats.Reuses++ - m.mu.Unlock() - _ = pump.Close() - return existing, nil - } - } - if m.sessions == nil { - m.sessions = map[string]*FabricSessionPump{} - } - m.sessions[key] = pump - m.stats.Opens++ - m.mu.Unlock() - return pump, nil -} - -func (m *FabricSessionPeerManager) ClosePeer(target FabricSessionPeerTarget) error { - if m == nil { - return nil - } - key, err := fabricSessionPeerKey(target) - if err != nil { - return err - } - m.mu.Lock() - m.stats.ClosePeerCalls++ - pump := m.sessions[key] - delete(m.sessions, key) - m.mu.Unlock() - if pump == nil { - return nil - } - return pump.Close() -} - -func (m *FabricSessionPeerManager) Close() error { - if m == nil { - return nil - } - m.mu.Lock() - m.stats.CloseAllCalls++ - sessions := m.sessions - m.sessions = map[string]*FabricSessionPump{} - m.mu.Unlock() - var firstErr error - for _, pump := range sessions { - if err := pump.Close(); err != nil && firstErr == nil { - firstErr = err - } - } - return firstErr -} - -func (m *FabricSessionPeerManager) Snapshot() FabricSessionPeerManagerSnapshot { - if m == nil { - return FabricSessionPeerManagerSnapshot{SchemaVersion: "rap.fabric_session_peer_manager.v1"} - } - m.mu.Lock() - defer m.mu.Unlock() - snapshot := FabricSessionPeerManagerSnapshot{ - SchemaVersion: "rap.fabric_session_peer_manager.v1", - Stats: m.stats, - } - for _, pump := range m.sessions { - if pump == nil || pump.Closed() { - snapshot.ClosedCount++ - continue - } - snapshot.ActiveCount++ - } - return snapshot -} - -func fabricSessionPeerKey(target FabricSessionPeerTarget) (string, error) { - peerID := strings.TrimSpace(target.PeerID) - baseURL := strings.TrimRight(strings.TrimSpace(target.BaseURL), "/") - if peerID == "" || baseURL == "" { - return "", fmt.Errorf("fabric session peer id and base url are required") - } - return peerID + "\x00" + baseURL, nil -} diff --git a/agents/rap-node-agent/internal/mesh/fabric_session_manager_test.go b/agents/rap-node-agent/internal/mesh/fabric_session_manager_test.go deleted file mode 100644 index 903abfa..0000000 --- a/agents/rap-node-agent/internal/mesh/fabric_session_manager_test.go +++ /dev/null @@ -1,194 +0,0 @@ -package mesh - -import ( - "context" - "net/http/httptest" - "testing" - "time" - - "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" -) - -func TestFabricSessionPeerManagerReusesPeerPump(t *testing.T) { - var opened int - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - FabricSessionLogger: func(entry FabricSessionEventLogEntry) { - if entry.Event == "fabric_session_websocket_opened" { - opened++ - } - }, - }.Handler()) - defer server.Close() - - manager := NewFabricSessionPeerManager() - defer manager.Close() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - target := FabricSessionPeerTarget{ - PeerID: "node-a", - BaseURL: server.URL, - Options: FabricSessionDialOptions{ - Token: "rap_fsn_manager", - Timeout: time.Second, - }, - Pump: FabricSessionPumpOptions{ - OutboundBuffer: 4, - InboundBuffer: 4, - }, - } - - first, err := manager.Get(ctx, target) - if err != nil { - t.Fatalf("first get: %v", err) - } - second, err := manager.Get(ctx, target) - if err != nil { - t.Fatalf("second get: %v", err) - } - if first != second { - t.Fatal("manager did not reuse peer pump") - } - if opened != 1 { - t.Fatalf("opened sessions = %d, want 1", opened) - } - snapshot := manager.Snapshot() - if snapshot.SchemaVersion != "rap.fabric_session_peer_manager.v1" || - snapshot.ActiveCount != 1 || - snapshot.ClosedCount != 0 || - snapshot.Stats.Opens != 1 || - snapshot.Stats.Reuses != 1 { - t.Fatalf("snapshot = %+v", snapshot) - } - if err := first.Send(ctx, fabricproto.Frame{ - Type: fabricproto.FramePing, - Sequence: 1, - Payload: []byte("manager"), - }); err != nil { - t.Fatalf("send ping: %v", err) - } - select { - case frame := <-first.Frames(): - if frame.Type != fabricproto.FramePong || frame.Sequence != 1 || string(frame.Payload) != "manager" { - t.Fatalf("frame = %+v", frame) - } - case err := <-first.Errors(): - t.Fatalf("pump error: %v", err) - case <-ctx.Done(): - t.Fatal(ctx.Err()) - } -} - -func TestFabricSessionPeerManagerClosePeerReopens(t *testing.T) { - var opened int - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - FabricSessionLogger: func(entry FabricSessionEventLogEntry) { - if entry.Event == "fabric_session_websocket_opened" { - opened++ - } - }, - }.Handler()) - defer server.Close() - - manager := NewFabricSessionPeerManager() - defer manager.Close() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - target := FabricSessionPeerTarget{ - PeerID: "node-a", - BaseURL: server.URL, - Options: FabricSessionDialOptions{ - Token: "rap_fsn_manager_reopen", - Timeout: time.Second, - }, - } - - first, err := manager.Get(ctx, target) - if err != nil { - t.Fatalf("first get: %v", err) - } - if err := manager.ClosePeer(target); err != nil { - t.Fatalf("close peer: %v", err) - } - second, err := manager.Get(ctx, target) - if err != nil { - t.Fatalf("second get: %v", err) - } - if first == second { - t.Fatal("manager reused pump after close peer") - } - if opened != 2 { - t.Fatalf("opened sessions = %d, want 2", opened) - } - if snapshot := manager.Snapshot(); snapshot.Stats.ClosePeerCalls != 1 || snapshot.Stats.Opens != 2 { - t.Fatalf("snapshot = %+v", snapshot) - } -} - -func TestFabricSessionPeerManagerReopensClosedPump(t *testing.T) { - var opened int - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - FabricSessionLogger: func(entry FabricSessionEventLogEntry) { - if entry.Event == "fabric_session_websocket_opened" { - opened++ - } - }, - }.Handler()) - defer server.Close() - - manager := NewFabricSessionPeerManager() - defer manager.Close() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - target := FabricSessionPeerTarget{ - PeerID: "node-a", - BaseURL: server.URL, - Options: FabricSessionDialOptions{ - Token: "rap_fsn_manager_closed", - Timeout: time.Second, - }, - } - - first, err := manager.Get(ctx, target) - if err != nil { - t.Fatalf("first get: %v", err) - } - if err := first.Close(); err != nil { - t.Fatalf("close first pump: %v", err) - } - if !first.Closed() { - t.Fatal("first pump should report closed") - } - second, err := manager.Get(ctx, target) - if err != nil { - t.Fatalf("second get: %v", err) - } - if first == second { - t.Fatal("manager reused closed pump") - } - if opened != 2 { - t.Fatalf("opened sessions = %d, want 2", opened) - } - snapshot := manager.Snapshot() - if snapshot.ActiveCount != 1 || - snapshot.Stats.Opens != 2 || - snapshot.Stats.ClosedEvicted != 1 { - t.Fatalf("snapshot = %+v", snapshot) - } -} - -func TestFabricSessionPeerManagerRejectsIncompleteTarget(t *testing.T) { - manager := NewFabricSessionPeerManager() - _, err := manager.Get(context.Background(), FabricSessionPeerTarget{PeerID: "node-a"}) - if err == nil { - t.Fatal("incomplete target unexpectedly succeeded") - } -} diff --git a/agents/rap-node-agent/internal/mesh/peer_connection_manager.go b/agents/rap-node-agent/internal/mesh/peer_connection_manager.go index e65d803..d3a58f8 100644 --- a/agents/rap-node-agent/internal/mesh/peer_connection_manager.go +++ b/agents/rap-node-agent/internal/mesh/peer_connection_manager.go @@ -308,7 +308,7 @@ func (m *PeerConnectionManager) probeIntent(ctx context.Context, intent PeerConn Transport: intent.Transport, PeerCertSHA256: intent.BestPeerCertSHA256, }} - if intent.DirectCandidate { + if intent.DirectCandidate || peerConnectionShouldProbeDirectUpgrade(intent, cacheEntry) { targets = peerConnectionProbeTargets(intent, cacheEntry) } var lastFailure string @@ -354,7 +354,9 @@ func (m *PeerConnectionManager) probeIntent(ctx context.Context, intent PeerConn result.SelectedCandidateID = probePeer.BestCandidateID result.SelectedEndpoint = probePeer.Endpoint result.LatencyMs = latency - if intent.RelayCandidate { + if probeTargetUsesDirectQUIC(probeTarget) { + result.ConnectionState = m.tracker.RecordSuccessForPeer(probePeer, latency, completedAt) + } else if intent.RelayCandidate { result.ConnectionState = m.tracker.RecordRelayReady(probePeer, latency, completedAt) } else { result.ConnectionState = m.tracker.RecordSuccessForPeer(probePeer, latency, completedAt) @@ -410,6 +412,10 @@ func (m *PeerConnectionManager) probePeerTarget(ctx context.Context, probePeer P func peerConnectionProbeTargets(intent PeerConnectionIntent, cacheEntry PeerCacheEntry) []peerConnectionProbeTarget { seen := map[string]struct{}{} out := make([]peerConnectionProbeTarget, 0, len(cacheEntry.EndpointCandidates)+1) + fallbackPeerCertSHA256 := firstNonEmpty( + strings.TrimSpace(cacheEntry.BestPeerCertSHA256), + strings.TrimSpace(intent.BestPeerCertSHA256), + ) add := func(candidateID, endpoint, transport, peerCertSHA256 string) { endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/") if endpoint == "" { @@ -423,6 +429,9 @@ func peerConnectionProbeTargets(intent PeerConnectionIntent, cacheEntry PeerCach return } seen[key] = struct{}{} + if strings.TrimSpace(peerCertSHA256) == "" { + peerCertSHA256 = fallbackPeerCertSHA256 + } out = append(out, peerConnectionProbeTarget{ CandidateID: strings.TrimSpace(candidateID), Endpoint: endpoint, @@ -440,6 +449,31 @@ func peerConnectionProbeTargets(intent PeerConnectionIntent, cacheEntry PeerCach return out } +func peerConnectionShouldProbeDirectUpgrade(intent PeerConnectionIntent, cacheEntry PeerCacheEntry) bool { + if intent.DirectCandidate { + return true + } + if strings.TrimSpace(intent.ConnectionState) != PeerConnectionRelayReady && + !intent.RelayCandidate && + strings.TrimSpace(intent.TransportMode) != PeerTransportModeRelayControl { + return false + } + for _, candidate := range cacheEntry.EndpointCandidates { + if candidateUsableForDirectProbe(candidate) { + return true + } + } + return false +} + +func probeTargetUsesDirectQUIC(target peerConnectionProbeTarget) bool { + transport := strings.ToLower(strings.TrimSpace(target.Transport)) + if strings.Contains(transport, "relay") || strings.Contains(transport, "reverse") || strings.Contains(transport, "outbound") { + return false + } + return peerConnectionTargetIsQUIC(target.Transport, target.Endpoint) +} + func peerConnectionTargetIsQUIC(transport string, endpoint string) bool { return isQUICOnlyCandidateTransport(transport) || strings.HasPrefix(strings.ToLower(strings.TrimSpace(endpoint)), "quic://") } diff --git a/agents/rap-node-agent/internal/mesh/peer_connection_manager_test.go b/agents/rap-node-agent/internal/mesh/peer_connection_manager_test.go index 721d7cb..54cd8c3 100644 --- a/agents/rap-node-agent/internal/mesh/peer_connection_manager_test.go +++ b/agents/rap-node-agent/internal/mesh/peer_connection_manager_test.go @@ -221,6 +221,125 @@ func TestPeerConnectionProbeTargetKeepsPeerForLocalRelayReverseQUIC(t *testing.T } } +func TestPeerConnectionProbeTargetsFallsBackToBestPeerCertSHA256(t *testing.T) { + intent := PeerConnectionIntent{ + NodeID: "node-b", + BestPeerCertSHA256: "intent-cert", + } + cacheEntry := PeerCacheEntry{ + NodeID: "node-b", + BestPeerCertSHA256: "cache-cert", + BestCandidateID: "node-b-best", + BestTransport: "direct_quic", + Endpoint: "quic://94.141.118.222:19199", + EndpointCandidates: []PeerEndpointCandidate{ + { + EndpointID: "node-b-public", + NodeID: "node-b", + Transport: "direct_quic", + Address: "quic://94.141.118.222:19199", + Reachability: "public", + ConnectivityMode: "direct", + Priority: 1, + }, + }, + } + + targets := peerConnectionProbeTargets(intent, cacheEntry) + if len(targets) != 1 { + t.Fatalf("target count = %d, want 1", len(targets)) + } + for _, target := range targets { + if target.Endpoint != "quic://94.141.118.222:19199" { + continue + } + if target.PeerCertSHA256 != "cache-cert" { + t.Fatalf("peer cert = %q, want cache-cert", target.PeerCertSHA256) + } + } +} + +func TestPeerConnectionProbeTargetsUpgradeRelayReadyPeerToDirectQUIC(t *testing.T) { + now := time.Date(2026, 5, 18, 12, 0, 0, 0, time.UTC) + current := now + tlsConfig := testQUICTLSConfig(t) + server, err := StartQUICFabricServer(context.Background(), QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + + local := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"} + certSHA256 := testQUICCertSHA256(t, tlsConfig) + leases := []PeerRendezvousLease{{ + LeaseID: "lease-node-b-via-node-r", + PeerNodeID: "node-b", + RelayNodeID: "node-r", + RelayEndpoint: "quic://127.0.0.1:1", + Transport: "relay_quic", + ConnectivityMode: "relay_required", + Priority: 10, + ControlPlaneOnly: true, + IssuedAt: now.Add(-time.Minute), + ExpiresAt: now.Add(time.Minute), + }} + cache := NewPeerCache(PeerCacheConfig{ + Local: local, + PeerEndpointCandidates: map[string][]PeerEndpointCandidate{ + "node-b": { + { + EndpointID: "node-b-direct", + NodeID: "node-b", + Transport: "direct_quic", + Address: "quic://" + server.Addr().String(), + Reachability: "public", + ConnectivityMode: "direct", + Priority: 1, + Metadata: peerConnectionProbeMetadata(t, certSHA256), + }, + }, + }, + RendezvousLeases: leases, + WarmPeerLimit: 1, + Now: now, + }) + tracker := NewPeerConnectionTracker(cache.Snapshot(), now) + manager := NewPeerConnectionManager(PeerConnectionManagerConfig{ + Local: local, + PeerCache: cache, + Tracker: tracker, + RendezvousLeases: leases, + QUICTransport: NewQUICFabricTransport(nil), + ProbeTimeout: time.Second, + Now: func() time.Time { + current = current.Add(10 * time.Millisecond) + return current + }, + }) + + cycle := manager.ProbeOnce(context.Background()) + if cycle.Attempted != 1 || cycle.Succeeded != 1 || len(cycle.Results) != 1 { + t.Fatalf("unexpected cycle: %+v", cycle) + } + result := cycle.Results[0] + if result.SelectedCandidateID != "node-b-direct" || result.SelectedEndpoint != "quic://"+server.Addr().String() { + t.Fatalf("relay-ready peer did not upgrade to direct candidate: %+v", result) + } + if result.ConnectionState.State != PeerConnectionReady { + t.Fatalf("connection state = %q, want ready", result.ConnectionState.State) + } + if len(result.CandidateResults) == 0 || result.CandidateResults[0].Transport != "direct_quic" || result.CandidateResults[0].LinkStatus != PeerConnectionProbeReachable { + t.Fatalf("candidate trail missing direct probe success: %+v", result.CandidateResults) + } + snapshot := tracker.Snapshot() + if snapshot.Ready != 1 || snapshot.RelayReady != 0 { + t.Fatalf("unexpected tracker snapshot after direct upgrade: %+v", snapshot) + } +} + func TestPeerConnectionManagerFallsBackAcrossEndpointCandidates(t *testing.T) { now := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC) current := now diff --git a/agents/rap-node-agent/internal/mesh/peer_recovery_plan.go b/agents/rap-node-agent/internal/mesh/peer_recovery_plan.go index 0defcd6..00c1967 100644 --- a/agents/rap-node-agent/internal/mesh/peer_recovery_plan.go +++ b/agents/rap-node-agent/internal/mesh/peer_recovery_plan.go @@ -102,8 +102,11 @@ func PlanPeerRecovery(cfg PeerRecoveryPlanConfig) PeerRecoveryPlan { continue } switch connection.State { - case PeerConnectionReady, PeerConnectionRelayReady: + case PeerConnectionReady: ready++ + case PeerConnectionRelayReady: + // Relay-ready peers remain valuable for control-plane reachability, + // but they do not satisfy the target for direct-ready transport paths. case PeerConnectionDegraded: degraded++ case PeerConnectionBackoff: diff --git a/agents/rap-node-agent/internal/mesh/peer_recovery_plan_test.go b/agents/rap-node-agent/internal/mesh/peer_recovery_plan_test.go index 818ad50..fbf6774 100644 --- a/agents/rap-node-agent/internal/mesh/peer_recovery_plan_test.go +++ b/agents/rap-node-agent/internal/mesh/peer_recovery_plan_test.go @@ -69,7 +69,7 @@ func TestPeerRecoveryPlanAddsRecoverySeedWhenReadyDeficit(t *testing.T) { } } -func TestPeerRecoveryPlanMaintainsRelayReadyPeersInSteadyMode(t *testing.T) { +func TestPeerRecoveryPlanTreatsRelayReadyPeersAsRecoveryGap(t *testing.T) { now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) plan := PlanPeerRecovery(PeerRecoveryPlanConfig{ PeerCache: PeerCacheSnapshot{ @@ -92,12 +92,15 @@ func TestPeerRecoveryPlanMaintainsRelayReadyPeersInSteadyMode(t *testing.T) { Now: now, }) - if plan.Mode != PeerRecoveryModeSteady || !plan.Healthy { - t.Fatalf("unexpected steady plan: %+v", plan) + if plan.Mode != PeerRecoveryModeRecovery || plan.Healthy { + t.Fatalf("unexpected relay-ready recovery plan: %+v", plan) } if !recoveryPlanHasCandidate(plan, "node-c", "maintain_ready") { t.Fatalf("relay-ready peer was not maintained: %+v", plan.Candidates) } + if plan.ReadyPeerCount != 0 || plan.Deficit != 1 { + t.Fatalf("relay-ready peer should not satisfy direct-ready target: %+v", plan) + } } func TestPeerRecoveryPlanCapsTargetByConnectablePeers(t *testing.T) { diff --git a/agents/rap-node-agent/internal/mesh/registry_gossip.go b/agents/rap-node-agent/internal/mesh/registry_gossip.go new file mode 100644 index 0000000..5134559 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/registry_gossip.go @@ -0,0 +1,713 @@ +package mesh + +import ( + "bytes" + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/hex" + "encoding/json" + "fmt" + "sort" + "strings" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" +) + +const ( + FabricRegistryGossipRecordSchema = "rap.fabric.registry.gossip_record.v1" + + FabricRegistryScopeFarm = "farm" + FabricRegistryScopeCluster = "cluster" + FabricRegistryScopeOrganization = "organization" + + FabricRegistryServiceControlAPI = "control-api" + FabricRegistryServiceUpdateStore = "update-store" + FabricRegistryServiceUpdateCache = "update-cache" + FabricRegistryServiceWebAdmin = "web-admin" + FabricRegistryServiceVPNExitPool = "vpn-egress-pool" + + FabricRegistryAuthorityControl = "control-authority" + FabricRegistryAuthorityUpdate = "update-authority" + FabricRegistryAuthorityStorage = "storage-authority" + FabricRegistryAuthorityRoute = "route-authority" +) + +type FabricRegistryEndpoint struct { + EndpointID string `json:"endpoint_id"` + Address string `json:"address"` + Transport string `json:"transport"` + Reachability string `json:"reachability,omitempty"` + ConnectivityMode string `json:"connectivity_mode,omitempty"` + Region string `json:"region,omitempty"` + Priority int `json:"priority,omitempty"` + Weight int `json:"weight,omitempty"` + PeerCertSHA256 string `json:"peer_cert_sha256,omitempty"` + LastVerifiedAt *time.Time `json:"last_verified_at,omitempty"` + Metadata json.RawMessage `json:"metadata,omitempty"` +} + +type FabricRegistrySignature struct { + KeyID string `json:"key_id"` + IssuerID string `json:"issuer_id"` + Role string `json:"role"` + Alg string `json:"alg"` + Value string `json:"value"` +} + +type FabricRegistryGossipRecord struct { + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + Service string `json:"service"` + Scope string `json:"scope"` + OrganizationID string `json:"organization_id,omitempty"` + Epoch int64 `json:"epoch"` + Generation string `json:"generation,omitempty"` + IssuedAt time.Time `json:"issued_at"` + ExpiresAt time.Time `json:"expires_at"` + IssuerNodeID string `json:"issuer_node_id"` + IssuerRole string `json:"issuer_role"` + Endpoints []FabricRegistryEndpoint `json:"endpoints"` + Metadata json.RawMessage `json:"metadata,omitempty"` + Signatures []FabricRegistrySignature `json:"signatures,omitempty"` +} + +type FabricRegistryTrustedIssuer struct { + IssuerID string + Role string + PublicKey ed25519.PublicKey + Scopes []string + Services []string +} + +type FabricRegistryVerificationPolicy struct { + LocalClusterID string + TrustedIssuers []FabricRegistryTrustedIssuer + RequiredSignatures int + MaxClockSkew time.Duration + Now time.Time +} + +type FabricRegistryVerificationResult struct { + AcceptedSignatureCount int `json:"accepted_signature_count"` + AcceptedIssuers []string `json:"accepted_issuers,omitempty"` + RecordHash string `json:"record_hash"` +} + +type FabricRegistryEntryState string + +const ( + FabricRegistryCandidate FabricRegistryEntryState = "candidate" + FabricRegistryActive FabricRegistryEntryState = "active" + FabricRegistryExpired FabricRegistryEntryState = "expired" + FabricRegistryRejected FabricRegistryEntryState = "rejected" +) + +type FabricRegistryEntry struct { + Record FabricRegistryGossipRecord `json:"record"` + State FabricRegistryEntryState `json:"state"` + AcceptedAt time.Time `json:"accepted_at"` + PromotedAt *time.Time `json:"promoted_at,omitempty"` + VerifyResult FabricRegistryVerificationResult `json:"verify_result"` +} + +type FabricRegistryBootstrapReport struct { + Total int `json:"total"` + Active int `json:"active"` + Candidate int `json:"candidate"` + Rejected int `json:"rejected"` + Rejects []string `json:"rejects,omitempty"` + RecordKeys []string `json:"record_keys,omitempty"` +} + +type FabricRegistryResolveRequest struct { + ClusterID string + Service string + Scope string + OrganizationID string + PreferredRegion string + Now time.Time +} + +type FabricRegistryResolvedService struct { + Found bool `json:"found"` + Service string `json:"service"` + Scope string `json:"scope,omitempty"` + OrganizationID string `json:"organization_id,omitempty"` + RecordEpoch int64 `json:"record_epoch,omitempty"` + RecordHash string `json:"record_hash,omitempty"` + Endpoints []FabricRegistryEndpoint `json:"endpoints,omitempty"` + Reason string `json:"reason,omitempty"` +} + +type FabricRegistryLiveProbeRequest struct { + ClusterID string + PreferredRegion string + Timeout time.Duration + Now time.Time + MaxCandidates int +} + +type FabricRegistryLiveProbeResult struct { + Service string `json:"service"` + Scope string `json:"scope"` + OrganizationID string `json:"organization_id,omitempty"` + EndpointID string `json:"endpoint_id,omitempty"` + Address string `json:"address,omitempty"` + Status string `json:"status"` + LatencyMs int64 `json:"latency_ms,omitempty"` + Promoted bool `json:"promoted"` + Error string `json:"error,omitempty"` +} + +type FabricRegistrySnapshot struct { + Active int `json:"active"` + Candidate int `json:"candidate"` + ActiveKeys []string `json:"active_keys,omitempty"` + CandidateKeys []string `json:"candidate_keys,omitempty"` +} + +type FabricRegistry struct { + entries map[string]FabricRegistryEntry + candidates map[string]FabricRegistryEntry +} + +func NewFabricRegistry() *FabricRegistry { + return &FabricRegistry{entries: map[string]FabricRegistryEntry{}, candidates: map[string]FabricRegistryEntry{}} +} + +func LoadFabricRegistryBootstrapRecords(recordsJSON string, policy FabricRegistryVerificationPolicy, liveVerified bool) (*FabricRegistry, FabricRegistryBootstrapReport, error) { + registry := NewFabricRegistry() + recordsJSON = strings.TrimSpace(recordsJSON) + if recordsJSON == "" { + return registry, FabricRegistryBootstrapReport{}, nil + } + var records []FabricRegistryGossipRecord + if err := json.Unmarshal([]byte(recordsJSON), &records); err != nil { + return nil, FabricRegistryBootstrapReport{}, fmt.Errorf("decode fabric registry bootstrap records: %w", err) + } + report := FabricRegistryBootstrapReport{Total: len(records)} + for _, record := range records { + entry, changed, err := registry.ApplyGossipRecord(record, policy, liveVerified) + if err != nil { + report.Rejected++ + report.Rejects = append(report.Rejects, err.Error()) + continue + } + if !changed { + continue + } + report.RecordKeys = append(report.RecordKeys, fabricRegistryRecordKey(record)) + switch entry.State { + case FabricRegistryActive: + report.Active++ + case FabricRegistryCandidate: + report.Candidate++ + } + } + return registry, report, nil +} + +func (r *FabricRegistry) ApplyGossipRecord(record FabricRegistryGossipRecord, policy FabricRegistryVerificationPolicy, liveVerified bool) (FabricRegistryEntry, bool, error) { + if r == nil { + return FabricRegistryEntry{}, false, fmt.Errorf("fabric registry is nil") + } + result, err := VerifyFabricRegistryGossipRecord(record, policy) + if err != nil { + return FabricRegistryEntry{}, false, err + } + now := registryNow(policy.Now) + key := fabricRegistryRecordKey(record) + current, exists := r.entries[key] + if exists && !fabricRegistryRecordNewer(record, current.Record, now) { + return current, false, nil + } + state := FabricRegistryCandidate + var promotedAt *time.Time + if liveVerified { + state = FabricRegistryActive + t := now + promotedAt = &t + } + entry := FabricRegistryEntry{ + Record: normalizeFabricRegistryRecord(record), + State: state, + AcceptedAt: now, + PromotedAt: promotedAt, + VerifyResult: result, + } + if state == FabricRegistryActive { + r.entries[key] = entry + delete(r.candidates, key) + return entry, true, nil + } + if r.candidates == nil { + r.candidates = map[string]FabricRegistryEntry{} + } + r.candidates[key] = entry + return entry, true, nil +} + +func (r *FabricRegistry) MarkLiveVerified(clusterID, service, scope, organizationID string, now time.Time) bool { + if r == nil { + return false + } + key := fabricRegistryKey(clusterID, service, scope, organizationID) + entry, ok := r.candidates[key] + if !ok || entry.State == FabricRegistryExpired || entry.State == FabricRegistryRejected { + return false + } + t := registryNow(now) + entry.State = FabricRegistryActive + entry.PromotedAt = &t + r.entries[key] = entry + delete(r.candidates, key) + return true +} + +func (r *FabricRegistry) Active(clusterID, service, scope, organizationID string, now time.Time) (FabricRegistryGossipRecord, bool) { + if r == nil { + return FabricRegistryGossipRecord{}, false + } + entry, ok := r.entries[fabricRegistryKey(clusterID, service, scope, organizationID)] + if !ok || entry.State != FabricRegistryActive || !entry.Record.ExpiresAt.After(registryNow(now)) { + return FabricRegistryGossipRecord{}, false + } + return entry.Record, true +} + +func (r *FabricRegistry) ResolveService(req FabricRegistryResolveRequest) FabricRegistryResolvedService { + service := strings.ToLower(strings.TrimSpace(req.Service)) + if service == "" { + return FabricRegistryResolvedService{Found: false, Reason: "service_required"} + } + scopeOrder := fabricRegistryScopeResolutionOrder(req.Scope, req.OrganizationID) + for _, scope := range scopeOrder { + organizationID := strings.TrimSpace(req.OrganizationID) + if scope != FabricRegistryScopeOrganization { + organizationID = "" + } + record, ok := r.Active(req.ClusterID, service, scope, organizationID, req.Now) + if !ok { + continue + } + endpoints := selectFabricRegistryEndpoints(record.Endpoints, req.PreferredRegion) + if len(endpoints) == 0 { + return FabricRegistryResolvedService{Found: false, Service: service, Scope: scope, OrganizationID: organizationID, Reason: "no_usable_endpoints"} + } + result, _ := canonicalFabricRegistryPayload(record) + sum := sha256.Sum256(result) + return FabricRegistryResolvedService{ + Found: true, + Service: service, + Scope: scope, + OrganizationID: organizationID, + RecordEpoch: record.Epoch, + RecordHash: hex.EncodeToString(sum[:]), + Endpoints: endpoints, + } + } + return FabricRegistryResolvedService{Found: false, Service: service, Reason: "no_active_record"} +} + +func (r *FabricRegistry) Snapshot(now time.Time) FabricRegistrySnapshot { + if r == nil { + return FabricRegistrySnapshot{} + } + now = registryNow(now) + out := FabricRegistrySnapshot{} + for key, entry := range r.entries { + if entry.State == FabricRegistryActive && entry.Record.ExpiresAt.After(now) { + out.Active++ + out.ActiveKeys = append(out.ActiveKeys, key) + } + } + for key, entry := range r.candidates { + if entry.State == FabricRegistryCandidate && entry.Record.ExpiresAt.After(now) { + out.Candidate++ + out.CandidateKeys = append(out.CandidateKeys, key) + } + } + sort.Strings(out.ActiveKeys) + sort.Strings(out.CandidateKeys) + return out +} + +func (r *FabricRegistry) VerifyCandidates(ctx context.Context, transport FabricTransport, req FabricRegistryLiveProbeRequest) []FabricRegistryLiveProbeResult { + if r == nil { + return nil + } + now := registryNow(req.Now) + timeout := req.Timeout + if timeout <= 0 { + timeout = 2 * time.Second + } + maxCandidates := req.MaxCandidates + if maxCandidates <= 0 { + maxCandidates = 16 + } + candidates := make([]FabricRegistryEntry, 0, len(r.candidates)) + for _, entry := range r.candidates { + if entry.State != FabricRegistryCandidate || !entry.Record.ExpiresAt.After(now) { + continue + } + if clusterID := strings.TrimSpace(req.ClusterID); clusterID != "" && entry.Record.ClusterID != clusterID { + continue + } + candidates = append(candidates, entry) + } + sort.SliceStable(candidates, func(i, j int) bool { + if candidates[i].Record.Service != candidates[j].Record.Service { + return candidates[i].Record.Service < candidates[j].Record.Service + } + if candidates[i].Record.Scope != candidates[j].Record.Scope { + return candidates[i].Record.Scope < candidates[j].Record.Scope + } + return candidates[i].Record.Epoch > candidates[j].Record.Epoch + }) + if len(candidates) > maxCandidates { + candidates = candidates[:maxCandidates] + } + results := make([]FabricRegistryLiveProbeResult, 0, len(candidates)) + for _, entry := range candidates { + record := entry.Record + result := FabricRegistryLiveProbeResult{ + Service: record.Service, + Scope: record.Scope, + OrganizationID: record.OrganizationID, + Status: "unreachable", + } + endpoints := selectFabricRegistryEndpoints(record.Endpoints, req.PreferredRegion) + if len(endpoints) == 0 { + result.Error = "no_usable_endpoints" + results = append(results, result) + continue + } + for _, endpoint := range endpoints { + probeCtx, cancel := context.WithTimeout(ctx, timeout) + latency, err := probeFabricRegistryEndpoint(probeCtx, transport, endpoint, timeout) + cancel() + result.EndpointID = endpoint.EndpointID + result.Address = endpoint.Address + if err != nil { + result.Error = err.Error() + continue + } + result.Status = "reachable" + result.LatencyMs = latency.Milliseconds() + result.Promoted = r.MarkLiveVerified(record.ClusterID, record.Service, record.Scope, record.OrganizationID, now) + result.Error = "" + break + } + results = append(results, result) + } + return results +} + +func SignFabricRegistryGossipRecord(record FabricRegistryGossipRecord, issuer FabricRegistryTrustedIssuer, privateKey ed25519.PrivateKey) (FabricRegistryGossipRecord, error) { + payload, err := canonicalFabricRegistryPayload(record) + if err != nil { + return record, err + } + sig := ed25519.Sign(privateKey, payload) + record.Signatures = append(record.Signatures, FabricRegistrySignature{ + KeyID: firstNonEmpty(issuer.IssuerID, record.IssuerNodeID), + IssuerID: firstNonEmpty(issuer.IssuerID, record.IssuerNodeID), + Role: firstNonEmpty(issuer.Role, record.IssuerRole), + Alg: "ed25519", + Value: hex.EncodeToString(sig), + }) + return record, nil +} + +func VerifyFabricRegistryGossipRecord(record FabricRegistryGossipRecord, policy FabricRegistryVerificationPolicy) (FabricRegistryVerificationResult, error) { + record = normalizeFabricRegistryRecord(record) + if err := validateFabricRegistryGossipRecord(record, policy); err != nil { + return FabricRegistryVerificationResult{}, err + } + payload, err := canonicalFabricRegistryPayload(record) + if err != nil { + return FabricRegistryVerificationResult{}, err + } + sum := sha256.Sum256(payload) + trusted := map[string]FabricRegistryTrustedIssuer{} + for _, issuer := range policy.TrustedIssuers { + if strings.TrimSpace(issuer.IssuerID) != "" { + trusted[issuer.IssuerID] = issuer + } + if strings.TrimSpace(issuer.IssuerID) != "" && strings.TrimSpace(issuer.Role) != "" { + trusted[issuer.IssuerID+"\x00"+issuer.Role] = issuer + } + } + accepted := map[string]struct{}{} + for _, signature := range record.Signatures { + if strings.ToLower(strings.TrimSpace(signature.Alg)) != "ed25519" { + continue + } + issuer, ok := trusted[strings.TrimSpace(signature.IssuerID)+"\x00"+strings.TrimSpace(signature.Role)] + if !ok { + issuer, ok = trusted[strings.TrimSpace(signature.IssuerID)] + } + if !ok || !fabricRegistryIssuerAllowed(issuer, record) { + continue + } + rawSig, err := hex.DecodeString(strings.TrimSpace(signature.Value)) + if err != nil || len(rawSig) != ed25519.SignatureSize || len(issuer.PublicKey) != ed25519.PublicKeySize { + continue + } + if ed25519.Verify(issuer.PublicKey, payload, rawSig) { + accepted[signature.IssuerID] = struct{}{} + } + } + required := policy.RequiredSignatures + if required <= 0 { + required = 1 + } + if len(accepted) < required { + return FabricRegistryVerificationResult{RecordHash: hex.EncodeToString(sum[:])}, fmt.Errorf("fabric registry gossip record lacks required trusted signatures") + } + issuers := make([]string, 0, len(accepted)) + for issuer := range accepted { + issuers = append(issuers, issuer) + } + sort.Strings(issuers) + return FabricRegistryVerificationResult{ + AcceptedSignatureCount: len(accepted), + AcceptedIssuers: issuers, + RecordHash: hex.EncodeToString(sum[:]), + }, nil +} + +func validateFabricRegistryGossipRecord(record FabricRegistryGossipRecord, policy FabricRegistryVerificationPolicy) error { + if record.SchemaVersion != FabricRegistryGossipRecordSchema { + return fmt.Errorf("fabric registry gossip record schema_version is invalid") + } + if strings.TrimSpace(record.ClusterID) == "" || (strings.TrimSpace(policy.LocalClusterID) != "" && record.ClusterID != policy.LocalClusterID) { + return ErrClusterMismatch + } + if strings.TrimSpace(record.Service) == "" || strings.TrimSpace(record.Scope) == "" || strings.TrimSpace(record.IssuerNodeID) == "" || strings.TrimSpace(record.IssuerRole) == "" { + return fmt.Errorf("fabric registry gossip record is missing service, scope, or issuer") + } + if record.Epoch <= 0 || record.IssuedAt.IsZero() || record.ExpiresAt.IsZero() || !record.ExpiresAt.After(record.IssuedAt) { + return fmt.Errorf("fabric registry gossip record has invalid epoch or validity window") + } + now := registryNow(policy.Now) + skew := policy.MaxClockSkew + if skew <= 0 { + skew = time.Minute + } + if record.IssuedAt.After(now.Add(skew)) || !record.ExpiresAt.After(now) { + return fmt.Errorf("fabric registry gossip record is not currently valid") + } + if len(record.Endpoints) == 0 { + return fmt.Errorf("fabric registry gossip record has no endpoints") + } + for _, endpoint := range record.Endpoints { + if strings.TrimSpace(endpoint.EndpointID) == "" || strings.TrimSpace(endpoint.Address) == "" || strings.TrimSpace(endpoint.Transport) == "" { + return fmt.Errorf("fabric registry gossip record contains invalid endpoint") + } + if !isQUICOnlyCandidateTransport(endpoint.Transport) || hasLegacyEndpointScheme(endpoint.Address) { + return fmt.Errorf("fabric registry gossip endpoint must be QUIC-only") + } + if len(endpoint.Metadata) > 0 && !json.Valid(endpoint.Metadata) { + return fmt.Errorf("fabric registry gossip endpoint metadata is invalid") + } + } + if len(record.Metadata) > 0 && !json.Valid(record.Metadata) { + return fmt.Errorf("fabric registry gossip metadata is invalid") + } + return nil +} + +func canonicalFabricRegistryPayload(record FabricRegistryGossipRecord) ([]byte, error) { + record = normalizeFabricRegistryRecord(record) + record.Signatures = nil + payload, err := json.Marshal(record) + if err != nil { + return nil, err + } + var compact bytes.Buffer + if err := json.Compact(&compact, payload); err != nil { + return nil, err + } + return compact.Bytes(), nil +} + +func normalizeFabricRegistryRecord(record FabricRegistryGossipRecord) FabricRegistryGossipRecord { + record.SchemaVersion = strings.TrimSpace(record.SchemaVersion) + record.ClusterID = strings.TrimSpace(record.ClusterID) + record.Service = strings.ToLower(strings.TrimSpace(record.Service)) + record.Scope = strings.ToLower(strings.TrimSpace(record.Scope)) + record.OrganizationID = strings.TrimSpace(record.OrganizationID) + record.IssuerNodeID = strings.TrimSpace(record.IssuerNodeID) + record.IssuerRole = strings.TrimSpace(record.IssuerRole) + record.Generation = strings.TrimSpace(record.Generation) + for i := range record.Endpoints { + record.Endpoints[i].EndpointID = strings.TrimSpace(record.Endpoints[i].EndpointID) + record.Endpoints[i].Address = strings.TrimSpace(record.Endpoints[i].Address) + record.Endpoints[i].Transport = strings.TrimSpace(record.Endpoints[i].Transport) + record.Endpoints[i].Reachability = strings.TrimSpace(record.Endpoints[i].Reachability) + record.Endpoints[i].ConnectivityMode = strings.TrimSpace(record.Endpoints[i].ConnectivityMode) + record.Endpoints[i].Region = strings.TrimSpace(record.Endpoints[i].Region) + record.Endpoints[i].PeerCertSHA256 = normalizeCertSHA256(record.Endpoints[i].PeerCertSHA256) + } + sort.SliceStable(record.Endpoints, func(i, j int) bool { + if record.Endpoints[i].Priority != record.Endpoints[j].Priority { + return record.Endpoints[i].Priority < record.Endpoints[j].Priority + } + return record.Endpoints[i].EndpointID < record.Endpoints[j].EndpointID + }) + sort.SliceStable(record.Signatures, func(i, j int) bool { + if record.Signatures[i].IssuerID != record.Signatures[j].IssuerID { + return record.Signatures[i].IssuerID < record.Signatures[j].IssuerID + } + return record.Signatures[i].KeyID < record.Signatures[j].KeyID + }) + return record +} + +func fabricRegistryIssuerAllowed(issuer FabricRegistryTrustedIssuer, record FabricRegistryGossipRecord) bool { + if strings.TrimSpace(issuer.Role) != "" && issuer.Role != record.IssuerRole { + return false + } + if len(issuer.Scopes) > 0 && !stringInSlice(record.Scope, issuer.Scopes) { + return false + } + if len(issuer.Services) > 0 && !stringInSlice(record.Service, issuer.Services) { + return false + } + return true +} + +func fabricRegistryRecordKey(record FabricRegistryGossipRecord) string { + return fabricRegistryKey(record.ClusterID, record.Service, record.Scope, record.OrganizationID) +} + +func fabricRegistryScopeResolutionOrder(scope string, organizationID string) []string { + scope = strings.ToLower(strings.TrimSpace(scope)) + switch scope { + case FabricRegistryScopeOrganization: + if strings.TrimSpace(organizationID) != "" { + return []string{FabricRegistryScopeOrganization, FabricRegistryScopeCluster, FabricRegistryScopeFarm} + } + return []string{FabricRegistryScopeCluster, FabricRegistryScopeFarm} + case FabricRegistryScopeFarm: + return []string{FabricRegistryScopeFarm} + case FabricRegistryScopeCluster, "": + return []string{FabricRegistryScopeCluster, FabricRegistryScopeFarm} + default: + return []string{scope, FabricRegistryScopeCluster, FabricRegistryScopeFarm} + } +} + +func selectFabricRegistryEndpoints(endpoints []FabricRegistryEndpoint, preferredRegion string) []FabricRegistryEndpoint { + preferredRegion = strings.TrimSpace(preferredRegion) + out := make([]FabricRegistryEndpoint, 0, len(endpoints)) + for _, endpoint := range endpoints { + if strings.TrimSpace(endpoint.Address) == "" || !isQUICOnlyCandidateTransport(endpoint.Transport) || hasLegacyEndpointScheme(endpoint.Address) { + continue + } + out = append(out, endpoint) + } + sort.SliceStable(out, func(i, j int) bool { + if preferredRegion != "" { + iMatch := strings.EqualFold(out[i].Region, preferredRegion) + jMatch := strings.EqualFold(out[j].Region, preferredRegion) + if iMatch != jMatch { + return iMatch + } + } + if out[i].Priority != out[j].Priority { + return out[i].Priority < out[j].Priority + } + if out[i].Weight != out[j].Weight { + return out[i].Weight > out[j].Weight + } + return out[i].EndpointID < out[j].EndpointID + }) + return out +} + +func probeFabricRegistryEndpoint(ctx context.Context, transport FabricTransport, endpoint FabricRegistryEndpoint, timeout time.Duration) (time.Duration, error) { + if transport == nil { + return 0, fmt.Errorf("fabric registry live probe transport is unavailable") + } + if timeout <= 0 { + timeout = 2 * time.Second + } + target := FabricTransportTarget{ + EndpointID: endpoint.EndpointID, + PeerID: endpoint.EndpointID, + Endpoint: endpoint.Address, + Transport: endpoint.Transport, + PeerCertSHA256: endpoint.PeerCertSHA256, + Timeout: timeout, + InboundBuffer: 2, + ErrorBuffer: 2, + } + startedAt := time.Now() + session, err := transport.Connect(ctx, target) + if err != nil { + return 0, err + } + defer session.Close() + sequence := uint64(startedAt.UnixNano()) + if err := session.Send(ctx, fabricproto.Frame{Type: fabricproto.FramePing, TrafficClass: fabricproto.TrafficClassReliable, Sequence: sequence, Payload: []byte("fabric-registry-live-probe")}); err != nil { + return 0, err + } + for { + select { + case frame, ok := <-session.Frames(): + if !ok { + return 0, fmt.Errorf("fabric registry live probe session closed") + } + if frame.Type == fabricproto.FramePong && frame.Sequence == sequence { + return time.Since(startedAt), nil + } + case err, ok := <-session.Errors(): + if !ok { + return 0, fmt.Errorf("fabric registry live probe error channel closed") + } + if err != nil { + return 0, err + } + case <-ctx.Done(): + return 0, ctx.Err() + } + } +} + +func fabricRegistryKey(clusterID, service, scope, organizationID string) string { + return strings.TrimSpace(clusterID) + "\x00" + strings.ToLower(strings.TrimSpace(service)) + "\x00" + strings.ToLower(strings.TrimSpace(scope)) + "\x00" + strings.TrimSpace(organizationID) +} + +func fabricRegistryRecordNewer(next, current FabricRegistryGossipRecord, now time.Time) bool { + if !current.ExpiresAt.After(now) { + return true + } + if next.Epoch != current.Epoch { + return next.Epoch > current.Epoch + } + if !next.IssuedAt.Equal(current.IssuedAt) { + return next.IssuedAt.After(current.IssuedAt) + } + return strings.TrimSpace(next.Generation) > strings.TrimSpace(current.Generation) +} + +func registryNow(now time.Time) time.Time { + if now.IsZero() { + return time.Now().UTC() + } + return now.UTC() +} + +func stringInSlice(value string, values []string) bool { + value = strings.TrimSpace(value) + for _, candidate := range values { + if strings.TrimSpace(candidate) == value { + return true + } + } + return false +} diff --git a/agents/rap-node-agent/internal/mesh/registry_gossip_test.go b/agents/rap-node-agent/internal/mesh/registry_gossip_test.go new file mode 100644 index 0000000..1dfdab5 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/registry_gossip_test.go @@ -0,0 +1,280 @@ +package mesh + +import ( + "context" + "crypto/ed25519" + "testing" + "time" +) + +func TestFabricRegistryGossipRecordRequiresTrustedSignature(t *testing.T) { + now := time.Date(2026, 5, 18, 10, 0, 0, 0, time.UTC) + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + record := testFabricRegistryGossipRecord(now, 10) + issuer := FabricRegistryTrustedIssuer{ + IssuerID: "authority-1", + Role: FabricRegistryAuthorityControl, + PublicKey: publicKey, + Scopes: []string{FabricRegistryScopeCluster}, + Services: []string{FabricRegistryServiceControlAPI}, + } + signed, err := SignFabricRegistryGossipRecord(record, issuer, privateKey) + if err != nil { + t.Fatalf("sign record: %v", err) + } + if _, err := VerifyFabricRegistryGossipRecord(signed, FabricRegistryVerificationPolicy{ + LocalClusterID: "cluster-1", + TrustedIssuers: []FabricRegistryTrustedIssuer{issuer}, + RequiredSignatures: 1, + Now: now, + }); err != nil { + t.Fatalf("verify signed record: %v", err) + } + tampered := signed + tampered.Endpoints[0].Address = "quic://10.10.10.10:19443" + if _, err := VerifyFabricRegistryGossipRecord(tampered, FabricRegistryVerificationPolicy{ + LocalClusterID: "cluster-1", + TrustedIssuers: []FabricRegistryTrustedIssuer{issuer}, + RequiredSignatures: 1, + Now: now, + }); err == nil { + t.Fatal("tampered record verified") + } +} + +func TestFabricRegistryRejectsLegacyEndpointAndExpiredRecord(t *testing.T) { + now := time.Date(2026, 5, 18, 10, 0, 0, 0, time.UTC) + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + issuer := FabricRegistryTrustedIssuer{IssuerID: "authority-1", Role: FabricRegistryAuthorityControl, PublicKey: publicKey} + record := testFabricRegistryGossipRecord(now, 10) + record.Endpoints[0].Address = "https://control.example.test/api/v1" + signed, err := SignFabricRegistryGossipRecord(record, issuer, privateKey) + if err != nil { + t.Fatalf("sign record: %v", err) + } + if _, err := VerifyFabricRegistryGossipRecord(signed, FabricRegistryVerificationPolicy{ + LocalClusterID: "cluster-1", + TrustedIssuers: []FabricRegistryTrustedIssuer{ + {IssuerID: "authority-1", Role: FabricRegistryAuthorityControl, PublicKey: publicKey}, + }, + Now: now, + }); err == nil { + t.Fatal("legacy HTTP endpoint was accepted") + } + expired := testFabricRegistryGossipRecord(now.Add(-2*time.Hour), 11) + expired.ExpiresAt = now.Add(-time.Minute) + expiredSigned, err := SignFabricRegistryGossipRecord(expired, issuer, privateKey) + if err != nil { + t.Fatalf("sign expired record: %v", err) + } + if _, err := VerifyFabricRegistryGossipRecord(expiredSigned, FabricRegistryVerificationPolicy{ + LocalClusterID: "cluster-1", + TrustedIssuers: []FabricRegistryTrustedIssuer{ + {IssuerID: "authority-1", Role: FabricRegistryAuthorityControl, PublicKey: publicKey}, + }, + Now: now, + }); err == nil { + t.Fatal("expired record was accepted") + } +} + +func TestFabricRegistryKeepsActiveRecordUntilNewerVerified(t *testing.T) { + now := time.Date(2026, 5, 18, 10, 0, 0, 0, time.UTC) + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + issuer := FabricRegistryTrustedIssuer{IssuerID: "authority-1", Role: FabricRegistryAuthorityControl, PublicKey: publicKey} + policy := FabricRegistryVerificationPolicy{ + LocalClusterID: "cluster-1", + TrustedIssuers: []FabricRegistryTrustedIssuer{issuer}, + RequiredSignatures: 1, + Now: now, + } + registry := NewFabricRegistry() + active, err := SignFabricRegistryGossipRecord(testFabricRegistryGossipRecord(now, 10), issuer, privateKey) + if err != nil { + t.Fatalf("sign active: %v", err) + } + entry, changed, err := registry.ApplyGossipRecord(active, policy, true) + if err != nil || !changed || entry.State != FabricRegistryActive { + t.Fatalf("apply active entry changed=%t entry=%+v err=%v", changed, entry, err) + } + old := testFabricRegistryGossipRecord(now.Add(time.Minute), 9) + old.Endpoints[0].Address = "quic://192.0.2.9:19443" + oldSigned, err := SignFabricRegistryGossipRecord(old, issuer, privateKey) + if err != nil { + t.Fatalf("sign old: %v", err) + } + entry, changed, err = registry.ApplyGossipRecord(oldSigned, policy, true) + if err != nil { + t.Fatalf("apply old: %v", err) + } + if changed || entry.Record.Epoch != 10 || entry.Record.Endpoints[0].Address != "quic://192.0.2.10:19443" { + t.Fatalf("older record replaced active entry: changed=%t entry=%+v", changed, entry) + } + newer := testFabricRegistryGossipRecord(now.Add(2*time.Minute), 11) + newer.Endpoints[0].Address = "quic://192.0.2.11:19443" + newerSigned, err := SignFabricRegistryGossipRecord(newer, issuer, privateKey) + if err != nil { + t.Fatalf("sign newer: %v", err) + } + policy.Now = now.Add(2 * time.Minute) + entry, changed, err = registry.ApplyGossipRecord(newerSigned, policy, false) + if err != nil || !changed || entry.State != FabricRegistryCandidate { + t.Fatalf("apply newer candidate changed=%t entry=%+v err=%v", changed, entry, err) + } + activeRecord, ok := registry.Active("cluster-1", FabricRegistryServiceControlAPI, FabricRegistryScopeCluster, "", policy.Now) + if !ok || activeRecord.Endpoints[0].Address != "quic://192.0.2.10:19443" { + t.Fatalf("unverified newer candidate displaced active fallback: ok=%t record=%+v", ok, activeRecord) + } + if !registry.MarkLiveVerified("cluster-1", FabricRegistryServiceControlAPI, FabricRegistryScopeCluster, "", policy.Now.Add(time.Second)) { + t.Fatal("mark live verified failed") + } + activeRecord, ok = registry.Active("cluster-1", FabricRegistryServiceControlAPI, FabricRegistryScopeCluster, "", policy.Now.Add(time.Second)) + if !ok || activeRecord.Endpoints[0].Address != "quic://192.0.2.11:19443" { + t.Fatalf("newer verified record not active: ok=%t record=%+v", ok, activeRecord) + } +} + +func TestFabricRegistryResolveServicePrefersVerifiedScopedRegionalEndpoint(t *testing.T) { + now := time.Date(2026, 5, 18, 10, 0, 0, 0, time.UTC) + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + issuer := FabricRegistryTrustedIssuer{IssuerID: "authority-1", Role: FabricRegistryAuthorityControl, PublicKey: publicKey} + policy := FabricRegistryVerificationPolicy{ + LocalClusterID: "cluster-1", + TrustedIssuers: []FabricRegistryTrustedIssuer{issuer}, + RequiredSignatures: 1, + Now: now, + } + registry := NewFabricRegistry() + clusterRecord := testFabricRegistryGossipRecord(now, 10) + clusterRecord.Endpoints = []FabricRegistryEndpoint{ + {EndpointID: "control-eu", Address: "quic://eu.example.test:19443", Transport: "direct_quic", Region: "eu", Priority: 10, Weight: 1}, + {EndpointID: "control-us", Address: "quic://us.example.test:19443", Transport: "direct_quic", Region: "us", Priority: 10, Weight: 10}, + } + signedCluster, err := SignFabricRegistryGossipRecord(clusterRecord, issuer, privateKey) + if err != nil { + t.Fatalf("sign cluster record: %v", err) + } + if _, _, err := registry.ApplyGossipRecord(signedCluster, policy, true); err != nil { + t.Fatalf("apply cluster record: %v", err) + } + orgRecord := testFabricRegistryGossipRecord(now.Add(time.Minute), 11) + orgRecord.Scope = FabricRegistryScopeOrganization + orgRecord.OrganizationID = "org-1" + orgRecord.Endpoints = []FabricRegistryEndpoint{ + {EndpointID: "control-org", Address: "quic://org.example.test:19443", Transport: "direct_quic", Region: "eu", Priority: 1, Weight: 1}, + } + signedOrg, err := SignFabricRegistryGossipRecord(orgRecord, issuer, privateKey) + if err != nil { + t.Fatalf("sign org record: %v", err) + } + policy.Now = now.Add(time.Minute) + if _, _, err := registry.ApplyGossipRecord(signedOrg, policy, false); err != nil { + t.Fatalf("apply org candidate: %v", err) + } + resolved := registry.ResolveService(FabricRegistryResolveRequest{ + ClusterID: "cluster-1", + Service: FabricRegistryServiceControlAPI, + Scope: FabricRegistryScopeOrganization, + OrganizationID: "org-1", + PreferredRegion: "us", + Now: now.Add(time.Minute), + }) + if !resolved.Found || resolved.Scope != FabricRegistryScopeCluster || resolved.Endpoints[0].EndpointID != "control-us" { + t.Fatalf("expected cluster fallback with preferred region endpoint, got %+v", resolved) + } + if !registry.MarkLiveVerified("cluster-1", FabricRegistryServiceControlAPI, FabricRegistryScopeOrganization, "org-1", now.Add(2*time.Minute)) { + t.Fatal("mark org live verified failed") + } + resolved = registry.ResolveService(FabricRegistryResolveRequest{ + ClusterID: "cluster-1", + Service: FabricRegistryServiceControlAPI, + Scope: FabricRegistryScopeOrganization, + OrganizationID: "org-1", + Now: now.Add(2 * time.Minute), + }) + if !resolved.Found || resolved.Scope != FabricRegistryScopeOrganization || resolved.Endpoints[0].EndpointID != "control-org" { + t.Fatalf("expected verified organization record, got %+v", resolved) + } + snapshot := registry.Snapshot(now.Add(2 * time.Minute)) + if snapshot.Active != 2 || snapshot.Candidate != 0 { + t.Fatalf("unexpected snapshot: %+v", snapshot) + } +} + +func TestFabricRegistryVerifyCandidatesPromotesAfterQUICPong(t *testing.T) { + now := time.Date(2026, 5, 18, 10, 0, 0, 0, time.UTC) + tlsConfig := testQUICTLSConfig(t) + listener := startQUICFabricEchoServerWithTLS(t, tlsConfig) + defer listener.Close() + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatal(err) + } + issuer := FabricRegistryTrustedIssuer{IssuerID: "authority-1", Role: FabricRegistryAuthorityControl, PublicKey: publicKey} + policy := FabricRegistryVerificationPolicy{ + LocalClusterID: "cluster-1", + TrustedIssuers: []FabricRegistryTrustedIssuer{issuer}, + RequiredSignatures: 1, + Now: now, + } + record := testFabricRegistryGossipRecord(now, 12) + record.Endpoints[0].Address = "quic://" + listener.Addr().String() + record.Endpoints[0].PeerCertSHA256 = testQUICCertSHA256(t, tlsConfig) + signed, err := SignFabricRegistryGossipRecord(record, issuer, privateKey) + if err != nil { + t.Fatalf("sign record: %v", err) + } + registry := NewFabricRegistry() + if entry, changed, err := registry.ApplyGossipRecord(signed, policy, false); err != nil || !changed || entry.State != FabricRegistryCandidate { + t.Fatalf("apply candidate changed=%t entry=%+v err=%v", changed, entry, err) + } + results := registry.VerifyCandidates(context.Background(), NewQUICFabricTransport(nil), FabricRegistryLiveProbeRequest{ + ClusterID: "cluster-1", + Timeout: 3 * time.Second, + Now: now.Add(time.Second), + MaxCandidates: 1, + }) + if len(results) != 1 || results[0].Status != "reachable" || !results[0].Promoted { + t.Fatalf("unexpected live probe results: %+v", results) + } + if _, ok := registry.Active("cluster-1", FabricRegistryServiceControlAPI, FabricRegistryScopeCluster, "", now.Add(time.Second)); !ok { + t.Fatal("candidate was not promoted to active") + } +} + +func testFabricRegistryGossipRecord(now time.Time, epoch int64) FabricRegistryGossipRecord { + return FabricRegistryGossipRecord{ + SchemaVersion: FabricRegistryGossipRecordSchema, + ClusterID: "cluster-1", + Service: FabricRegistryServiceControlAPI, + Scope: FabricRegistryScopeCluster, + Epoch: epoch, + Generation: "gen", + IssuedAt: now, + ExpiresAt: now.Add(10 * time.Minute), + IssuerNodeID: "authority-1", + IssuerRole: FabricRegistryAuthorityControl, + Endpoints: []FabricRegistryEndpoint{ + { + EndpointID: "control-a", + Address: "quic://192.0.2.10:19443", + Transport: "direct_quic", + Reachability: "public", + ConnectivityMode: "direct", + Priority: 1, + }, + }, + } +} diff --git a/agents/rap-node-agent/internal/mesh/server.go b/agents/rap-node-agent/internal/mesh/server.go index 5038096..46e1033 100644 --- a/agents/rap-node-agent/internal/mesh/server.go +++ b/agents/rap-node-agent/internal/mesh/server.go @@ -20,7 +20,6 @@ import ( "github.com/example/remote-access-platform/agents/rap-node-agent/internal/authority" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" - "github.com/gorilla/websocket" ) type ProductionEnvelopeObserver func(context.Context, ProductionEnvelopeObservation) error @@ -55,6 +54,22 @@ type RemoteWorkspaceFrameSinkSessionMailboxConsumerResume interface { type RemoteWorkspaceFrameSinkSessionMailboxPreflight interface { PreflightAdapterSessionMailboxConsumerResume(adapterSessionID string, consumerID string, resumeFrom string, limit int, now time.Time) (RemoteWorkspaceAdapterMailboxPreflightSnapshot, error) } +type FabricSessionEventLogEntry struct { + Event string `json:"event"` + ClusterID string `json:"cluster_id,omitempty"` + NodeID string `json:"node_id,omitempty"` + PeerID string `json:"peer_id,omitempty"` + AcceptedBy string `json:"accepted_by,omitempty"` + SessionID string `json:"session_id,omitempty"` + SessionEvent fabricproto.SessionEventType `json:"session_event,omitempty"` + StreamID uint64 `json:"stream_id,omitempty"` + Sequence uint64 `json:"sequence,omitempty"` + TrafficClass fabricproto.TrafficClass `json:"traffic_class,omitempty"` + RemoteAddr string `json:"remote_addr,omitempty"` + Reason string `json:"reason,omitempty"` + ObservedAt time.Time `json:"observed_at"` +} + type VPNPacketIngress interface { SendClientPacketBatch(ctx context.Context, clusterID string, vpnConnectionID string, packets [][]byte) error ReceiveClientPacketBatch(ctx context.Context, clusterID string, vpnConnectionID string, timeout time.Duration) ([][]byte, error) @@ -69,24 +84,21 @@ type VPNPacketIngressRoutePreference interface { } type Server struct { - Local PeerIdentity - SyntheticRuntime *SyntheticRuntime - ProductionForwardingEnabled bool - ProductionEnvelopeObserver ProductionEnvelopeObserver - ProductionEnvelopeDelivery ProductionEnvelopeDelivery - ProductionForwardTransport ProductionForwardTransport - ProductionForwardLogger ProductionForwardLogger - DisableHTTPDataPlane bool - FabricServiceChannelLogger FabricServiceChannelAccessLogger - RemoteWorkspaceFrameSink RemoteWorkspaceFrameSink - ProductionRoutes []SyntheticRoute - VPNPacketIngress VPNPacketIngress - BackendProxyBaseURL string - ClusterAuthorityPublicKey string - ServiceChannelIntrospection bool - FabricSessionEnabled bool - FabricSessionWebSocketEnabled bool - FabricSessionLogger FabricSessionEventLogger + Local PeerIdentity + SyntheticRuntime *SyntheticRuntime + ProductionForwardingEnabled bool + ProductionEnvelopeObserver ProductionEnvelopeObserver + ProductionEnvelopeDelivery ProductionEnvelopeDelivery + ProductionForwardTransport ProductionForwardTransport + ProductionForwardLogger ProductionForwardLogger + DisableHTTPDataPlane bool + FabricServiceChannelLogger FabricServiceChannelAccessLogger + RemoteWorkspaceFrameSink RemoteWorkspaceFrameSink + ProductionRoutes []SyntheticRoute + VPNPacketIngress VPNPacketIngress + BackendProxyBaseURL string + ClusterAuthorityPublicKey string + ServiceChannelIntrospection bool } func (s Server) Handler() http.Handler { @@ -94,9 +106,6 @@ func (s Server) Handler() http.Handler { mux.HandleFunc("/mesh/v1/health", s.handleHealth) mux.HandleFunc("/mesh/v1/forward", s.handleForward) mux.HandleFunc("/mesh/v1/synthetic/probe", s.handleSyntheticProbe) - if s.FabricSessionEnabled && s.FabricSessionWebSocketEnabled { - mux.HandleFunc("/mesh/v1/fabric/session/ws", s.handleFabricSessionWebSocket) - } if s.RemoteWorkspaceFrameSink != nil { mux.HandleFunc("/mesh/v1/remote-workspace/adapter-sessions/", s.handleRemoteWorkspaceAdapterSessionControl) } @@ -196,185 +205,6 @@ func (s Server) handleRemoteWorkspaceAdapterSessionSnapshot(w http.ResponseWrite _ = json.NewEncoder(w).Encode(snapshotter.SnapshotAdapterSessions(includeTerminal, limit, time.Now().UTC())) } -type FabricSessionEventLogEntry struct { - Event string `json:"event"` - ClusterID string `json:"cluster_id,omitempty"` - NodeID string `json:"node_id,omitempty"` - PeerID string `json:"peer_id,omitempty"` - AcceptedBy string `json:"accepted_by,omitempty"` - SessionID string `json:"session_id,omitempty"` - SessionEvent fabricproto.SessionEventType `json:"session_event,omitempty"` - StreamID uint64 `json:"stream_id,omitempty"` - Sequence uint64 `json:"sequence,omitempty"` - TrafficClass fabricproto.TrafficClass `json:"traffic_class,omitempty"` - RemoteAddr string `json:"remote_addr,omitempty"` - Reason string `json:"reason,omitempty"` - ObservedAt time.Time `json:"observed_at"` -} - -type fabricSessionAuthorityPayload struct { - SchemaVersion string `json:"schema_version"` - ClusterID string `json:"cluster_id"` - SessionID string `json:"session_id"` - SourceNodeID string `json:"source_node_id,omitempty"` - SelectedEntryNodeID string `json:"selected_entry_node_id,omitempty"` - TokenHash string `json:"token_hash"` - IssuedAt time.Time `json:"issued_at"` - ExpiresAt time.Time `json:"expires_at"` -} - -type fabricSessionAuthDecision struct { - AcceptedBy string - SessionID string -} - -func (s Server) handleFabricSessionWebSocket(w http.ResponseWriter, r *http.Request) { - if r.Method != http.MethodGet { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - decision, ok := s.validateFabricSessionRequest(w, r) - if !ok { - return - } - upgrader := websocket.Upgrader{ - CheckOrigin: func(_ *http.Request) bool { return true }, - } - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer conn.Close() - - s.logFabricSession(FabricSessionEventLogEntry{ - Event: "fabric_session_websocket_opened", - ClusterID: s.Local.ClusterID, - NodeID: s.Local.NodeID, - AcceptedBy: decision.AcceptedBy, - SessionID: decision.SessionID, - RemoteAddr: r.RemoteAddr, - ObservedAt: time.Now().UTC(), - }) - loop := fabricproto.TransportLoop{ - Session: fabricproto.NewSession(fabricproto.SessionConfig{}), - OnEvent: func(event fabricproto.SessionEvent) ([]fabricproto.Frame, error) { - s.logFabricSession(FabricSessionEventLogEntry{ - Event: "fabric_session_event", - ClusterID: s.Local.ClusterID, - NodeID: s.Local.NodeID, - AcceptedBy: decision.AcceptedBy, - SessionID: decision.SessionID, - SessionEvent: event.Type, - StreamID: event.StreamID, - Sequence: event.Sequence, - TrafficClass: event.TrafficClass, - RemoteAddr: r.RemoteAddr, - ObservedAt: time.Now().UTC(), - }) - return nil, nil - }, - } - err = loop.RunWebSocket(r.Context(), conn, fabricproto.WebSocketTransportConfig{}) - if err != nil && !errors.Is(err, context.Canceled) { - s.logFabricSession(FabricSessionEventLogEntry{ - Event: "fabric_session_websocket_closed", - ClusterID: s.Local.ClusterID, - NodeID: s.Local.NodeID, - AcceptedBy: decision.AcceptedBy, - SessionID: decision.SessionID, - RemoteAddr: r.RemoteAddr, - Reason: err.Error(), - ObservedAt: time.Now().UTC(), - }) - return - } - s.logFabricSession(FabricSessionEventLogEntry{ - Event: "fabric_session_websocket_closed", - ClusterID: s.Local.ClusterID, - NodeID: s.Local.NodeID, - AcceptedBy: decision.AcceptedBy, - SessionID: decision.SessionID, - RemoteAddr: r.RemoteAddr, - ObservedAt: time.Now().UTC(), - }) -} - -func (s Server) validateFabricSessionRequest(w http.ResponseWriter, r *http.Request) (fabricSessionAuthDecision, bool) { - var decision fabricSessionAuthDecision - token := fabricSessionBearerToken(r) - if !strings.HasPrefix(token, "rap_fsn_") { - http.Error(w, "fabric session token is required", http.StatusUnauthorized) - return decision, false - } - payload, err := s.verifyFabricSessionAuthority(r, token) - if err != nil { - http.Error(w, err.Error(), http.StatusForbidden) - return decision, false - } - decision.AcceptedBy = "legacy_unsigned" - if payload != nil { - decision.AcceptedBy = "signed" - decision.SessionID = strings.TrimSpace(payload.SessionID) - } - return decision, true -} - -func (s Server) verifyFabricSessionAuthority(r *http.Request, token string) (*fabricSessionAuthorityPayload, error) { - publicKey := strings.TrimSpace(s.ClusterAuthorityPublicKey) - payloadHeader := strings.TrimSpace(r.Header.Get("X-RAP-Fabric-Session-Authority-Payload")) - signatureHeader := strings.TrimSpace(r.Header.Get("X-RAP-Fabric-Session-Authority-Signature")) - if payloadHeader == "" && signatureHeader == "" { - if publicKey != "" { - return nil, fmt.Errorf("%w: signed fabric session authority is required", ErrUnauthorizedChannel) - } - return nil, nil - } - if publicKey == "" { - return nil, ErrUnauthorizedChannel - } - if payloadHeader == "" || signatureHeader == "" { - return nil, fmt.Errorf("%w: fabric session authority payload and signature are required together", ErrUnauthorizedChannel) - } - payloadRaw, err := decodeHeaderJSON(payloadHeader) - if err != nil { - return nil, fmt.Errorf("%w: invalid fabric session authority payload", ErrUnauthorizedChannel) - } - signatureRaw, err := decodeHeaderJSON(signatureHeader) - if err != nil { - return nil, fmt.Errorf("%w: invalid fabric session authority signature", ErrUnauthorizedChannel) - } - var signature authority.Signature - if err := json.Unmarshal(signatureRaw, &signature); err != nil { - return nil, fmt.Errorf("%w: invalid fabric session authority signature", ErrUnauthorizedChannel) - } - if err := authority.VerifyRaw(publicKey, payloadRaw, signature); err != nil { - return nil, fmt.Errorf("%w: fabric session authority signature rejected", ErrUnauthorizedChannel) - } - var payload fabricSessionAuthorityPayload - if err := json.Unmarshal(payloadRaw, &payload); err != nil { - return nil, fmt.Errorf("%w: invalid fabric session authority payload", ErrUnauthorizedChannel) - } - if payload.SchemaVersion != "rap.fabric_session_authority.v1" || - payload.ClusterID != s.Local.ClusterID || - payload.TokenHash != fabricSessionTokenHash(token) || - strings.TrimSpace(payload.SessionID) == "" { - return nil, fmt.Errorf("%w: fabric session authority payload mismatch", ErrUnauthorizedChannel) - } - if payload.SelectedEntryNodeID != "" && s.Local.NodeID != "" && payload.SelectedEntryNodeID != s.Local.NodeID { - return nil, fmt.Errorf("%w: fabric session entry node mismatch", ErrUnauthorizedChannel) - } - if !payload.ExpiresAt.IsZero() && !payload.ExpiresAt.After(time.Now().UTC()) { - return nil, fmt.Errorf("%w: fabric session lease expired", ErrUnauthorizedChannel) - } - return &payload, nil -} - -func (s Server) logFabricSession(entry FabricSessionEventLogEntry) { - if s.FabricSessionLogger != nil { - s.FabricSessionLogger(entry) - } -} - func (s Server) handleRemoteWorkspaceAdapterSessionMailbox(w http.ResponseWriter, r *http.Request) { reader, ok := s.RemoteWorkspaceFrameSink.(RemoteWorkspaceFrameSinkSessionMailbox) if !ok { @@ -711,15 +541,15 @@ func parseRemoteWorkspaceAdapterSessionControlPath(path string) (string, bool) { } func (s Server) handleVPNPacketIngress(w http.ResponseWriter, r *http.Request) bool { - if clusterID, vpnConnectionID, ok := parseVPNClientPacketWebSocketPath(r.URL.Path); ok { - s.handleVPNPacketWebSocket(w, r, clusterID, "", vpnConnectionID, false, true, "") + if isVPNClientPacketWebSocketPath(r.URL.Path) { + http.Error(w, "legacy VPN WebSocket dataplane is removed; use QUIC fabric route", http.StatusGone) return true } - clusterID, vpnConnectionID, ok := parseVPNClientPacketPath(r.URL.Path) - if !ok { + if _, _, ok := parseVPNClientPacketPath(r.URL.Path); !ok { return false } - return s.handleVPNPacketHTTP(w, r, clusterID, "", vpnConnectionID, "", false, true, "") + http.Error(w, "legacy VPN HTTP dataplane is removed; use QUIC fabric route", http.StatusGone) + return true } func (s Server) handleFabricServiceChannelRemoteWorkspaceIngress(w http.ResponseWriter, r *http.Request) bool { @@ -728,7 +558,7 @@ func (s Server) handleFabricServiceChannelRemoteWorkspaceIngress(w http.Response return false } if webSocket { - http.Error(w, "remote workspace service-channel websocket forwarding is not implemented", http.StatusNotImplemented) + http.Error(w, "remote workspace service-channel websocket ingress is removed; use QUIC fabric route", http.StatusGone) return true } decision, valid := s.validateFabricServiceChannelRequest(w, r, clusterID, channelID, resourceID, FabricServiceClassRemoteWorkspace, channelClass) @@ -809,7 +639,7 @@ func (s Server) handleFabricServiceChannelRemoteWorkspaceIngress(w http.Response "channel_id": channelID, "resource_id": resourceID, "data_plane": "validated", - "payload_flow": "not_implemented", + "payload_flow": "validated_only", }) return true } @@ -898,7 +728,7 @@ func validateRemoteWorkspaceFrameBatchProbe(payload []byte, requiredChannelClass return decoded, fmt.Errorf("unsupported remote workspace frame batch schema") } if !decoded.ProbeOnly { - return decoded, fmt.Errorf("remote workspace payload forwarding is not implemented") + return decoded, fmt.Errorf("remote workspace production payload forwarding is disabled; probe_only required") } if strings.TrimSpace(strings.ToLower(decoded.ServiceClass)) != FabricServiceClassRemoteWorkspace { return decoded, fmt.Errorf("remote workspace frame batch service class mismatch") @@ -952,438 +782,6 @@ func isAllowedRemoteWorkspaceAdapterFrameDirection(channel string, direction str } } -func (s Server) handleFabricServiceChannelVPNPacketIngress(w http.ResponseWriter, r *http.Request) bool { - if clusterID, channelID, vpnConnectionID, ok := parseFabricServiceChannelVPNPacketWebSocketPath(r.URL.Path); ok { - decision, valid := s.validateFabricServiceChannelVPNRequest(w, r, clusterID, channelID, vpnConnectionID) - if !valid { - return true - } - s.logFabricServiceChannelAccess(r, clusterID, channelID, vpnConnectionID, decision) - s.preferVPNPacketIngressRoute(decision.PreferredRouteID) - s.handleVPNPacketWebSocket(w, r, clusterID, channelID, vpnConnectionID, decision.ForceBackendFallback, decision.BackendFallbackAllowed(), decision.BackendRelayPolicy) - return true - } - clusterID, channelID, vpnConnectionID, ok := parseFabricServiceChannelVPNPacketPath(r.URL.Path) - if !ok { - return false - } - decision, valid := s.validateFabricServiceChannelVPNRequest(w, r, clusterID, channelID, vpnConnectionID) - if !valid { - return true - } - w.Header().Set("X-RAP-Service-Channel-Accepted-By", decision.AcceptedBy) - s.logFabricServiceChannelAccess(r, clusterID, channelID, vpnConnectionID, decision) - s.preferVPNPacketIngressRoute(decision.PreferredRouteID) - backendPath := "/api/v1/clusters/" + clusterID + "/vpn-connections/" + vpnConnectionID + "/tunnel/client/packets" - return s.handleVPNPacketHTTP(w, r, clusterID, channelID, vpnConnectionID, backendPath, decision.ForceBackendFallback, decision.BackendFallbackAllowed(), decision.BackendRelayPolicy) -} - -func (s Server) preferVPNPacketIngressRoute(routeID string) { - routeID = strings.TrimSpace(routeID) - if routeID == "" || s.VPNPacketIngress == nil { - return - } - if preferred, ok := s.VPNPacketIngress.(VPNPacketIngressRoutePreference); ok { - preferred.PreferClientRoute(routeID) - } -} - -func (s Server) handleVPNPacketHTTP(w http.ResponseWriter, r *http.Request, clusterID string, channelID string, vpnConnectionID string, backendFallbackPath string, forceBackendFallback bool, backendFallbackAllowed bool, backendRelayPolicy string) bool { - switch r.Method { - case http.MethodPost: - body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, MaxProductionVPNPacketPayloadBytes)) - if err != nil { - http.Error(w, "invalid vpn packet payload", http.StatusBadRequest) - return true - } - if r.URL.Query().Get("batch") != "true" && len(body) == 0 { - http.Error(w, "empty vpn packet payload", http.StatusBadRequest) - return true - } - packets := [][]byte{body} - if r.URL.Query().Get("batch") == "true" { - packets, err = decodeVPNIngressPacketBatch(body) - if err != nil { - http.Error(w, "invalid vpn packet batch", http.StatusBadRequest) - return true - } - } - packets = cleanVPNIngressPacketBatch(packets) - if len(packets) == 0 { - http.Error(w, "empty vpn packet batch", http.StatusBadRequest) - return true - } - if forceBackendFallback { - if backendFallbackAllowed && s.proxyVPNPacketIngressToBackendPath(w, r, body, backendFallbackPath) { - return true - } - s.logFabricServiceChannelViolation(r, clusterID, channelID, vpnConnectionID, backendRelayPolicy, "backend_fallback_blocked_by_policy", ErrRouteNotFound.Error()) - http.Error(w, ErrRouteNotFound.Error(), vpnIngressStatusCode(ErrRouteNotFound)) - return true - } - trafficClass := inferVPNPacketTrafficClass(r.Header.Get("X-RAP-Traffic-Class"), packets) - var sendErr error - if classIngress, ok := s.VPNPacketIngress.(VPNPacketIngressTrafficClass); ok { - sendErr = classIngress.SendClientPacketBatchWithTrafficClass(r.Context(), clusterID, vpnConnectionID, trafficClass, packets) - } else { - sendErr = s.VPNPacketIngress.SendClientPacketBatch(r.Context(), clusterID, vpnConnectionID, packets) - } - if sendErr != nil { - if backendFallbackAllowed && s.proxyVPNPacketIngressToBackendPath(w, r, body, backendFallbackPath) { - return true - } - s.logFabricServiceChannelViolation(r, clusterID, channelID, vpnConnectionID, backendRelayPolicy, "fabric_route_send_failed_backend_fallback_blocked", sendErr.Error()) - http.Error(w, sendErr.Error(), vpnIngressStatusCode(sendErr)) - return true - } - w.WriteHeader(http.StatusAccepted) - return true - case http.MethodGet: - if forceBackendFallback { - if backendFallbackAllowed && s.proxyVPNPacketIngressToBackendPath(w, r, nil, backendFallbackPath) { - return true - } - s.logFabricServiceChannelViolation(r, clusterID, channelID, vpnConnectionID, backendRelayPolicy, "backend_fallback_blocked_by_policy", ErrRouteNotFound.Error()) - w.WriteHeader(http.StatusNoContent) - return true - } - timeout := vpnIngressTimeout(r) - packets, err := s.VPNPacketIngress.ReceiveClientPacketBatch(r.Context(), clusterID, vpnConnectionID, timeout) - if err != nil { - http.Error(w, err.Error(), vpnIngressStatusCode(err)) - return true - } - packets = cleanVPNIngressPacketBatch(packets) - if len(packets) == 0 { - if backendFallbackAllowed && s.proxyVPNPacketIngressToBackendPath(w, r, nil, backendFallbackPath) { - return true - } - w.WriteHeader(http.StatusNoContent) - return true - } - if r.URL.Query().Get("batch") == "true" { - w.Header().Set("Content-Type", "application/vnd.rap.vpn-packet-batch.v1") - _, _ = w.Write(encodeVPNIngressPacketBatch(packets)) - return true - } - w.Header().Set("Content-Type", "application/octet-stream") - _, _ = w.Write(packets[0]) - return true - default: - w.WriteHeader(http.StatusMethodNotAllowed) - return true - } -} - -func (s Server) handleVPNPacketWebSocket(w http.ResponseWriter, r *http.Request, clusterID string, channelID string, vpnConnectionID string, forceBackendFallback bool, backendFallbackAllowed bool, backendRelayPolicy string) { - if r.Method != http.MethodGet { - w.WriteHeader(http.StatusMethodNotAllowed) - return - } - if s.VPNPacketIngress == nil { - http.Error(w, ErrForwardRuntimeUnavailable.Error(), http.StatusServiceUnavailable) - return - } - upgrader := websocket.Upgrader{ - CheckOrigin: func(_ *http.Request) bool { return true }, - } - conn, err := upgrader.Upgrade(w, r, nil) - if err != nil { - return - } - defer conn.Close() - conn.SetReadLimit(MaxProductionVPNPacketPayloadBytes) - - ctx, cancel := context.WithCancel(r.Context()) - defer cancel() - trafficClass := r.Header.Get("X-RAP-Traffic-Class") - errCh := make(chan error, 2) - go func() { - errCh <- s.readVPNPacketWebSocket(ctx, conn, clusterID, channelID, vpnConnectionID, trafficClass, forceBackendFallback, backendFallbackAllowed, backendRelayPolicy) - }() - go func() { - errCh <- s.writeVPNPacketWebSocket(ctx, conn, clusterID, channelID, vpnConnectionID, forceBackendFallback, backendFallbackAllowed, backendRelayPolicy) - }() - - select { - case <-ctx.Done(): - case <-errCh: - cancel() - } -} - -func (s Server) readVPNPacketWebSocket(ctx context.Context, conn *websocket.Conn, clusterID string, channelID string, vpnConnectionID string, trafficClass string, forceBackendFallback bool, backendFallbackAllowed bool, backendRelayPolicy string) error { - for { - messageType, payload, err := conn.ReadMessage() - if err != nil { - return err - } - if messageType != websocket.BinaryMessage { - continue - } - packets, err := decodeVPNIngressPacketBatch(payload) - if err != nil { - return err - } - packets = cleanVPNIngressPacketBatch(packets) - if len(packets) == 0 { - continue - } - if forceBackendFallback { - if !backendFallbackAllowed { - s.logFabricServiceChannelViolation(nil, clusterID, channelID, vpnConnectionID, backendRelayPolicy, "backend_fallback_blocked_by_policy", ErrRouteNotFound.Error()) - return ErrRouteNotFound - } - if proxyErr := s.backendVPNPacketPost(ctx, clusterID, vpnConnectionID, payload); proxyErr != nil { - return proxyErr - } - continue - } - sendErr := s.sendVPNPacketWebSocketBatch(ctx, clusterID, vpnConnectionID, inferVPNPacketTrafficClass(trafficClass, packets), packets, !backendFallbackAllowed) - if sendErr != nil { - if !backendFallbackAllowed { - s.logFabricServiceChannelViolation(nil, clusterID, channelID, vpnConnectionID, backendRelayPolicy, "fabric_route_send_failed_backend_fallback_blocked", sendErr.Error()) - if isRetryableVPNPacketIngressError(sendErr) { - continue - } - return sendErr - } - if proxyErr := s.backendVPNPacketPost(ctx, clusterID, vpnConnectionID, payload); proxyErr != nil { - return sendErr - } - } - } -} - -func (s Server) sendVPNPacketWebSocketBatch(ctx context.Context, clusterID string, vpnConnectionID string, trafficClass string, packets [][]byte, retryRouteErrors bool) error { - const maxAttempts = 6 - var lastErr error - for attempt := 0; attempt < maxAttempts; attempt++ { - if err := ctx.Err(); err != nil { - return err - } - var sendErr error - if classIngress, ok := s.VPNPacketIngress.(VPNPacketIngressTrafficClass); ok { - sendErr = classIngress.SendClientPacketBatchWithTrafficClass(ctx, clusterID, vpnConnectionID, trafficClass, packets) - } else { - sendErr = s.VPNPacketIngress.SendClientPacketBatch(ctx, clusterID, vpnConnectionID, packets) - } - if sendErr == nil { - return nil - } - lastErr = sendErr - if !retryRouteErrors || !isRetryableVPNPacketIngressError(sendErr) { - return sendErr - } - timer := time.NewTimer(time.Duration(75+attempt*50) * time.Millisecond) - select { - case <-ctx.Done(): - timer.Stop() - return ctx.Err() - case <-timer.C: - } - } - return lastErr -} - -func isRetryableVPNPacketIngressError(err error) bool { - return errors.Is(err, ErrRouteNotFound) || - errors.Is(err, ErrForwardRuntimeUnavailable) || - errors.Is(err, ErrForwardPeerUnavailable) || - errors.Is(err, ErrSyntheticPeerUnavailable) -} - -func (s Server) receiveVPNPacketWebSocketBatch(ctx context.Context, clusterID string, vpnConnectionID string, timeout time.Duration, retryRouteErrors bool) ([][]byte, error) { - const maxAttempts = 4 - var lastErr error - for attempt := 0; attempt < maxAttempts; attempt++ { - if err := ctx.Err(); err != nil { - return nil, err - } - packets, err := s.VPNPacketIngress.ReceiveClientPacketBatch(ctx, clusterID, vpnConnectionID, timeout) - if err == nil { - return packets, nil - } - lastErr = err - if !retryRouteErrors || !isRetryableVPNPacketIngressError(err) { - return nil, err - } - timer := time.NewTimer(time.Duration(75+attempt*50) * time.Millisecond) - select { - case <-ctx.Done(): - timer.Stop() - return nil, ctx.Err() - case <-timer.C: - } - } - if retryRouteErrors && isRetryableVPNPacketIngressError(lastErr) { - return nil, nil - } - return nil, lastErr -} - -func (s Server) writeVPNPacketWebSocket(ctx context.Context, conn *websocket.Conn, clusterID string, channelID string, vpnConnectionID string, forceBackendFallback bool, backendFallbackAllowed bool, backendRelayPolicy string) error { - lastPing := time.Now() - for { - select { - case <-ctx.Done(): - return ctx.Err() - default: - } - var packets [][]byte - var err error - if !forceBackendFallback { - packets, err = s.receiveVPNPacketWebSocketBatch(ctx, clusterID, vpnConnectionID, 50*time.Millisecond, !backendFallbackAllowed) - } - if forceBackendFallback && !backendFallbackAllowed { - s.logFabricServiceChannelViolation(nil, clusterID, channelID, vpnConnectionID, backendRelayPolicy, "backend_fallback_blocked_by_policy", ErrRouteNotFound.Error()) - return ErrRouteNotFound - } - if err != nil && !backendFallbackAllowed { - s.logFabricServiceChannelViolation(nil, clusterID, channelID, vpnConnectionID, backendRelayPolicy, "fabric_route_receive_failed_backend_fallback_blocked", err.Error()) - return err - } - if backendFallbackAllowed && (forceBackendFallback || err != nil || len(packets) == 0) { - backendPackets, proxyErr := s.backendVPNPacketGet(ctx, clusterID, vpnConnectionID, 50*time.Millisecond) - if proxyErr != nil && err != nil { - return err - } - if len(backendPackets) > 0 { - packets = backendPackets - } - } - if len(packets) > 0 { - if err := conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil { - return err - } - if err := conn.WriteMessage(websocket.BinaryMessage, encodeVPNIngressPacketBatch(packets)); err != nil { - return err - } - continue - } - if time.Since(lastPing) >= 15*time.Second { - if err := conn.SetWriteDeadline(time.Now().Add(5 * time.Second)); err != nil { - return err - } - if err := conn.WriteMessage(websocket.PingMessage, []byte("rap-vpn")); err != nil { - return err - } - lastPing = time.Now() - } - } -} - -func (s Server) backendVPNPacketPost(ctx context.Context, clusterID string, vpnConnectionID string, batchPayload []byte) error { - target := strings.TrimRight(strings.TrimSpace(s.BackendProxyBaseURL), "/") - if target == "" { - return ErrRouteNotFound - } - req, err := http.NewRequestWithContext(ctx, http.MethodPost, target+"/clusters/"+clusterID+"/vpn-connections/"+vpnConnectionID+"/tunnel/client/packets?batch=true", bytes.NewReader(batchPayload)) - if err != nil { - return err - } - req.Header.Set("Content-Type", "application/octet-stream") - req.Header.Set("X-RAP-Entry-Node", s.Local.NodeID) - req.Header.Set("X-RAP-Entry-Cluster", s.Local.ClusterID) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return err - } - defer resp.Body.Close() - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return fmt.Errorf("backend vpn packet post failed: status=%d", resp.StatusCode) - } - return nil -} - -func (s Server) backendVPNPacketGet(ctx context.Context, clusterID string, vpnConnectionID string, timeout time.Duration) ([][]byte, error) { - target := strings.TrimRight(strings.TrimSpace(s.BackendProxyBaseURL), "/") - if target == "" { - return nil, ErrRouteNotFound - } - if timeout <= 0 { - timeout = 50 * time.Millisecond - } - req, err := http.NewRequestWithContext(ctx, http.MethodGet, target+"/clusters/"+clusterID+"/vpn-connections/"+vpnConnectionID+"/tunnel/client/packets?batch=true&timeout_ms="+strconv.FormatInt(timeout.Milliseconds(), 10), nil) - if err != nil { - return nil, err - } - req.Header.Set("Accept", "application/vnd.rap.vpn-packet-batch.v1") - req.Header.Set("X-RAP-Entry-Node", s.Local.NodeID) - req.Header.Set("X-RAP-Entry-Cluster", s.Local.ClusterID) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return nil, err - } - defer resp.Body.Close() - if resp.StatusCode == http.StatusNoContent { - return nil, nil - } - if resp.StatusCode < 200 || resp.StatusCode >= 300 { - return nil, fmt.Errorf("backend vpn packet get failed: status=%d", resp.StatusCode) - } - body, err := io.ReadAll(io.LimitReader(resp.Body, MaxProductionVPNPacketPayloadBytes)) - if err != nil { - return nil, err - } - if len(body) == 0 { - return nil, nil - } - return decodeVPNIngressPacketBatch(body) -} - -func (s Server) proxyVPNPacketIngressToBackend(w http.ResponseWriter, r *http.Request, body []byte) bool { - return s.proxyVPNPacketIngressToBackendPath(w, r, body, "") -} - -func (s Server) proxyVPNPacketIngressToBackendPath(w http.ResponseWriter, r *http.Request, body []byte, backendPath string) bool { - if strings.TrimSpace(s.BackendProxyBaseURL) == "" { - return false - } - target, err := url.Parse(s.BackendProxyBaseURL) - if err != nil || target.Scheme == "" || target.Host == "" { - return false - } - if strings.EqualFold(target.Host, r.Host) { - return false - } - var reader io.Reader - if body != nil { - reader = bytes.NewReader(body) - } - requestURI := r.URL.RequestURI() - if backendPath != "" { - requestURI = backendPath - if r.URL.RawQuery != "" { - requestURI += "?" + r.URL.RawQuery - } - } - req, err := http.NewRequestWithContext(r.Context(), r.Method, target.Scheme+"://"+target.Host+requestURI, reader) - if err != nil { - return false - } - for _, key := range []string{"Accept", "Content-Type"} { - if value := r.Header.Get(key); value != "" { - req.Header.Set(key, value) - } - } - req.Header.Set("X-RAP-Entry-Node", s.Local.NodeID) - req.Header.Set("X-RAP-Entry-Cluster", s.Local.ClusterID) - resp, err := http.DefaultClient.Do(req) - if err != nil { - return false - } - defer resp.Body.Close() - for _, key := range []string{"Content-Type"} { - if value := resp.Header.Get(key); value != "" { - w.Header().Set(key, value) - } - } - w.WriteHeader(resp.StatusCode) - _, _ = io.Copy(w, resp.Body) - return true -} - type fabricServiceChannelLeaseAuthorityPayload struct { SchemaVersion string `json:"schema_version"` ChannelID string `json:"channel_id"` @@ -1443,10 +841,6 @@ func (d fabricServiceChannelRequestDecision) BackendFallbackAllowed() bool { return strings.TrimSpace(d.BackendRelayPolicy) != "disabled" } -func (s Server) validateFabricServiceChannelVPNRequest(w http.ResponseWriter, r *http.Request, clusterID string, channelID string, vpnConnectionID string) (fabricServiceChannelRequestDecision, bool) { - return s.validateFabricServiceChannelRequest(w, r, clusterID, channelID, vpnConnectionID, FabricServiceClassVPNPackets, ProductionChannelVPNPacket) -} - func (s Server) validateFabricServiceChannelRequest(w http.ResponseWriter, r *http.Request, clusterID string, channelID string, resourceID string, expectedServiceClass string, defaultChannelClass string) (fabricServiceChannelRequestDecision, bool) { var decision fabricServiceChannelRequestDecision expectedServiceClass = strings.TrimSpace(strings.ToLower(expectedServiceClass)) @@ -1485,7 +879,7 @@ func (s Server) validateFabricServiceChannelRequest(w http.ResponseWriter, r *ht http.Error(w, err.Error(), http.StatusForbidden) return decision, false } - decision.AcceptedBy = "legacy_unsigned" + decision.AcceptedBy = "token_authorized" decision.ServiceClass = serviceClass decision.ChannelClass = channelClass if payload != nil && (payload.Status == "degraded_fallback" || payload.PrimaryRoute.Status == "missing_route_intent") { @@ -1571,30 +965,6 @@ func (s Server) logFabricServiceChannelAccess(r *http.Request, clusterID string, s.FabricServiceChannelLogger(entry) } -func (s Server) logFabricServiceChannelViolation(r *http.Request, clusterID string, channelID string, resourceID string, backendRelayPolicy string, status string, reason string) { - if s.FabricServiceChannelLogger == nil || strings.TrimSpace(channelID) == "" { - return - } - entry := FabricServiceChannelAccessLogEntry{ - Event: "fabric_service_channel_data_plane_violation", - ClusterID: clusterID, - ChannelID: channelID, - ResourceID: resourceID, - LocalNodeID: s.Local.NodeID, - BackendRelayPolicy: strings.TrimSpace(backendRelayPolicy), - ViolationStatus: strings.TrimSpace(status), - ViolationReason: strings.TrimSpace(reason), - OccurredAt: time.Now().UTC(), - } - if r != nil { - entry.Method = r.Method - if r.URL != nil { - entry.Path = r.URL.Path - } - } - s.FabricServiceChannelLogger(entry) -} - func (s Server) verifyFabricServiceChannelLeaseAuthority(r *http.Request, clusterID string, channelID string, resourceID string, serviceClass string, channelClass string, token string) (*fabricServiceChannelLeaseAuthorityPayload, error) { publicKey := strings.TrimSpace(s.ClusterAuthorityPublicKey) payloadHeader := strings.TrimSpace(r.Header.Get("X-RAP-Service-Channel-Authority-Payload")) @@ -1657,15 +1027,15 @@ func validateFabricServiceChannelDataPlaneContract(contract fabricServiceChannel } requiredFlowClass = strings.TrimSpace(strings.ToLower(requiredFlowClass)) if contract.SchemaVersion != "rap.fabric_service_channel_data_plane.v1" || - contract.WorkingDataTransport != "fabric_service_channel" || + contract.WorkingDataTransport != "fabric_quic_route" || contract.SteadyStateTransport != "fabric_route" || - (contract.BackendRelayPolicy != "degraded_fallback_only" && contract.BackendRelayPolicy != "disabled") || + contract.BackendRelayPolicy != "disabled" || !contract.ServiceNeutral || !contract.ProtocolAgnostic || contract.LogicalFlowMode != "multi_flow_isolated" { return fmt.Errorf("%w: unsupported service channel data-plane contract", ErrUnauthorizedChannel) } - if contract.Mode != "" && contract.Mode != "fabric_primary" && contract.Mode != "degraded_backend_fallback" { + if contract.Mode != "" && contract.Mode != "fabric_primary" && contract.Mode != "fabric_quic_only" { return fmt.Errorf("%w: unsupported service channel data-plane mode", ErrUnauthorizedChannel) } if requiredFlowClass != "" && len(contract.RequiredFlowIsolationClasses) > 0 && !containsString(contract.RequiredFlowIsolationClasses, requiredFlowClass) { @@ -1796,29 +1166,6 @@ func fabricServiceChannelBearerToken(r *http.Request) string { return strings.TrimSpace(r.URL.Query().Get("service_channel_token")) } -func fabricSessionTokenHash(token string) string { - sum := sha256.Sum256([]byte(strings.TrimSpace(token))) - return hex.EncodeToString(sum[:]) -} - -func fabricSessionBearerToken(r *http.Request) string { - if r == nil { - return "" - } - if token := strings.TrimSpace(r.Header.Get("X-RAP-Fabric-Session-Token")); token != "" { - return token - } - auth := strings.TrimSpace(r.Header.Get("Authorization")) - if len(auth) > len("Bearer ") && strings.EqualFold(auth[:len("Bearer ")], "Bearer ") { - return strings.TrimSpace(auth[len("Bearer "):]) - } - return strings.TrimSpace(r.URL.Query().Get("fabric_session_token")) -} - -func isAllowedFabricServiceVPNChannel(channel string) bool { - return isAllowedFabricServiceChannelForClass(FabricServiceClassVPNPackets, channel) -} - func isAllowedFabricServiceChannelForClass(serviceClass string, channel string) bool { serviceClass = strings.TrimSpace(strings.ToLower(serviceClass)) channel = strings.TrimSpace(strings.ToLower(channel)) @@ -1846,25 +1193,6 @@ func containsString(values []string, target string) bool { return false } -func parseFabricServiceChannelVPNPacketWebSocketPath(path string) (string, string, string, bool) { - parts := strings.Split(strings.Trim(path, "/"), "/") - if len(parts) != 11 || - parts[0] != "api" || - parts[1] != "v1" || - parts[2] != "clusters" || - parts[4] != "fabric" || - parts[5] != "service-channels" || - parts[7] != "vpn-connections" || - parts[9] != "packets" || - parts[10] != "ws" { - return "", "", "", false - } - if parts[3] == "" || parts[6] == "" || parts[8] == "" { - return "", "", "", false - } - return parts[3], parts[6], parts[8], true -} - func parseFabricServiceChannelRemoteWorkspacePath(path string) (string, string, string, string, bool, bool) { parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) == 11 && @@ -1897,6 +1225,34 @@ func parseFabricServiceChannelRemoteWorkspacePath(path string) (string, string, return parts[3], parts[6], parts[8], strings.TrimSpace(strings.ToLower(parts[10])), false, true } +func (s Server) handleFabricServiceChannelVPNPacketIngress(w http.ResponseWriter, r *http.Request) bool { + if isFabricServiceChannelVPNPacketWebSocketPath(r.URL.Path) { + http.Error(w, "fabric service-channel WebSocket dataplane is removed; use QUIC fabric route", http.StatusGone) + return true + } + if _, _, _, ok := parseFabricServiceChannelVPNPacketPath(r.URL.Path); !ok { + return false + } + http.Error(w, "fabric service-channel HTTP dataplane is removed; use QUIC fabric route", http.StatusGone) + return true +} + +func isFabricServiceChannelVPNPacketWebSocketPath(path string) bool { + parts := strings.Split(strings.Trim(path, "/"), "/") + if len(parts) != 11 || + parts[0] != "api" || + parts[1] != "v1" || + parts[2] != "clusters" || + parts[4] != "fabric" || + parts[5] != "service-channels" || + parts[7] != "vpn-connections" || + parts[9] != "packets" || + parts[10] != "ws" { + return false + } + return parts[3] != "" && parts[6] != "" && parts[8] != "" +} + func parseFabricServiceChannelVPNPacketPath(path string) (string, string, string, bool) { parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) != 10 || @@ -1915,7 +1271,7 @@ func parseFabricServiceChannelVPNPacketPath(path string) (string, string, string return parts[3], parts[6], parts[8], true } -func parseVPNClientPacketWebSocketPath(path string) (string, string, bool) { +func isVPNClientPacketWebSocketPath(path string) bool { parts := strings.Split(strings.Trim(path, "/"), "/") if len(parts) != 10 || parts[0] != "api" || @@ -1926,12 +1282,9 @@ func parseVPNClientPacketWebSocketPath(path string) (string, string, bool) { parts[7] != "client" || parts[8] != "packets" || parts[9] != "ws" { - return "", "", false + return false } - if parts[3] == "" || parts[5] == "" { - return "", "", false - } - return parts[3], parts[5], true + return parts[3] != "" && parts[5] != "" } func parseVPNClientPacketPath(path string) (string, string, bool) { @@ -1952,28 +1305,6 @@ func parseVPNClientPacketPath(path string) (string, string, bool) { return parts[3], parts[5], true } -func vpnIngressTimeout(r *http.Request) time.Duration { - timeoutMs, _ := strconv.Atoi(r.URL.Query().Get("timeout_ms")) - if timeoutMs <= 0 { - timeoutMs = 25000 - } - if timeoutMs > 30000 { - timeoutMs = 30000 - } - return time.Duration(timeoutMs) * time.Millisecond -} - -func vpnIngressStatusCode(err error) int { - switch err { - case ErrForwardRuntimeUnavailable, ErrRouteNotFound, ErrForwardPeerUnavailable: - return http.StatusServiceUnavailable - case ErrUnauthorizedChannel, ErrClusterMismatch, ErrNodeMismatch: - return http.StatusForbidden - default: - return http.StatusBadGateway - } -} - func encodeVPNIngressPacketBatch(packets [][]byte) []byte { packets = cleanVPNIngressPacketBatch(packets) total := 0 diff --git a/agents/rap-node-agent/internal/mesh/server_test.go b/agents/rap-node-agent/internal/mesh/server_test.go index 0dabb40..b667ffe 100644 --- a/agents/rap-node-agent/internal/mesh/server_test.go +++ b/agents/rap-node-agent/internal/mesh/server_test.go @@ -19,8 +19,6 @@ import ( "time" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/authority" - "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" - "github.com/gorilla/websocket" ) type testProductionForwardTransport struct { @@ -87,168 +85,6 @@ func TestMeshForwardingDisabled(t *testing.T) { } } -func TestFabricSessionWebSocketDisabledByDefault(t *testing.T) { - server := httptest.NewServer(Server{Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}}.Handler()) - defer server.Close() - - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/mesh/v1/fabric/session/ws" - _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) - if err == nil { - t.Fatal("dial fabric session unexpectedly succeeded") - } - if resp == nil || resp.StatusCode != http.StatusNotFound { - t.Fatalf("status = %v err=%v, want 404", resp, err) - } -} - -func TestFabricSessionWebSocketPingPongAndEvents(t *testing.T) { - var events []FabricSessionEventLogEntry - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - FabricSessionLogger: func(entry FabricSessionEventLogEntry) { - events = append(events, entry) - }, - }.Handler()) - defer server.Close() - - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/mesh/v1/fabric/session/ws" - conn, _, err := websocket.DefaultDialer.Dial(wsURL, fabricSessionTestHeaders("rap_fsn_smoke")) - if err != nil { - t.Fatalf("dial fabric session websocket: %v", err) - } - defer conn.Close() - - writeMeshFabricFrame(t, conn, fabricproto.Frame{Type: fabricproto.FramePing, Sequence: 17, Payload: []byte("probe")}) - pong := readMeshFabricFrame(t, conn) - if pong.Type != fabricproto.FramePong || pong.Sequence != 17 || string(pong.Payload) != "probe" { - t.Fatalf("pong = %+v", pong) - } - if len(events) < 2 || events[0].Event != "fabric_session_websocket_opened" || events[0].AcceptedBy != "legacy_unsigned" || events[1].SessionEvent != fabricproto.SessionEventPing { - t.Fatalf("events = %+v", events) - } -} - -func TestFabricSessionWebSocketOpenStreamDataAck(t *testing.T) { - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - }.Handler()) - defer server.Close() - - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/mesh/v1/fabric/session/ws" - conn, _, err := websocket.DefaultDialer.Dial(wsURL, fabricSessionTestHeaders("rap_fsn_smoke")) - if err != nil { - t.Fatalf("dial fabric session websocket: %v", err) - } - defer conn.Close() - - writeMeshFabricFrame(t, conn, fabricproto.Frame{ - Type: fabricproto.FrameOpenStream, - TrafficClass: fabricproto.TrafficClassInteractive, - StreamID: 9, - }) - writeMeshFabricFrame(t, conn, fabricproto.Frame{ - Type: fabricproto.FrameData, - TrafficClass: fabricproto.TrafficClassInteractive, - StreamID: 9, - Sequence: 3, - Payload: []byte("input"), - }) - ack := readMeshFabricFrame(t, conn) - if ack.Type != fabricproto.FrameAck || ack.StreamID != 9 || ack.Sequence != 3 { - t.Fatalf("ack = %+v", ack) - } -} - -func TestFabricSessionWebSocketRequiresToken(t *testing.T) { - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - }.Handler()) - defer server.Close() - - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/mesh/v1/fabric/session/ws" - _, resp, err := websocket.DefaultDialer.Dial(wsURL, nil) - if err == nil { - t.Fatal("dial fabric session without token unexpectedly succeeded") - } - if resp == nil || resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("status = %v err=%v, want 401", resp, err) - } -} - -func TestFabricSessionWebSocketRequiresSignedAuthorityWhenConfigured(t *testing.T) { - publicKey, _, err := ed25519.GenerateKey(nil) - if err != nil { - t.Fatalf("generate key: %v", err) - } - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), - }.Handler()) - defer server.Close() - - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/mesh/v1/fabric/session/ws" - _, resp, err := websocket.DefaultDialer.Dial(wsURL, fabricSessionTestHeaders("rap_fsn_unsigned")) - if err == nil { - t.Fatal("dial unsigned fabric session unexpectedly succeeded") - } - if resp == nil || resp.StatusCode != http.StatusForbidden { - t.Fatalf("status = %v err=%v, want 403", resp, err) - } -} - -func TestFabricSessionWebSocketAcceptsSignedAuthority(t *testing.T) { - publicKey, privateKey, err := ed25519.GenerateKey(nil) - if err != nil { - t.Fatalf("generate key: %v", err) - } - token := "rap_fsn_signedtest" - var events []FabricSessionEventLogEntry - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionWebSocketEnabled: true, - ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), - FabricSessionLogger: func(entry FabricSessionEventLogEntry) { - events = append(events, entry) - }, - }.Handler()) - defer server.Close() - - headers := signedFabricSessionHeaders(t, token, publicKey, privateKey, fabricSessionAuthorityPayload{ - SchemaVersion: "rap.fabric_session_authority.v1", - ClusterID: "cluster-1", - SessionID: "session-1", - SourceNodeID: "phone-1", - SelectedEntryNodeID: "node-a", - TokenHash: fabricSessionTokenHash(token), - IssuedAt: time.Now().UTC().Add(-time.Minute), - ExpiresAt: time.Now().UTC().Add(time.Minute), - }) - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/mesh/v1/fabric/session/ws" - conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers) - if err != nil { - t.Fatalf("dial signed fabric session websocket: %v", err) - } - defer conn.Close() - - writeMeshFabricFrame(t, conn, fabricproto.Frame{Type: fabricproto.FramePing, Sequence: 23}) - pong := readMeshFabricFrame(t, conn) - if pong.Type != fabricproto.FramePong || pong.Sequence != 23 { - t.Fatalf("pong = %+v", pong) - } - if len(events) < 2 || events[0].AcceptedBy != "signed" || events[0].SessionID != "session-1" { - t.Fatalf("events = %+v", events) - } -} - func TestMeshForwardingGateEnabledStillHasNoProductionRuntime(t *testing.T) { local := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"} server := httptest.NewServer(Server{ @@ -271,68 +107,6 @@ func TestMeshForwardingGateEnabledStillHasNoProductionRuntime(t *testing.T) { } } -func writeMeshFabricFrame(t *testing.T, conn *websocket.Conn, frame fabricproto.Frame) { - t.Helper() - encoded, err := fabricproto.MarshalFrame(frame) - if err != nil { - t.Fatalf("marshal fabric frame: %v", err) - } - if err := conn.WriteMessage(websocket.BinaryMessage, encoded); err != nil { - t.Fatalf("write fabric websocket frame: %v", err) - } -} - -func fabricSessionTestHeaders(token string) http.Header { - headers := http.Header{} - headers.Set("X-RAP-Fabric-Session-Token", token) - return headers -} - -func signedFabricSessionHeaders(t *testing.T, token string, publicKey ed25519.PublicKey, privateKey ed25519.PrivateKey, payload fabricSessionAuthorityPayload) http.Header { - t.Helper() - headers := fabricSessionTestHeaders(token) - rawPayload, err := json.Marshal(payload) - if err != nil { - t.Fatalf("marshal fabric session authority payload: %v", err) - } - canonical, err := authority.CanonicalJSON(rawPayload) - if err != nil { - t.Fatalf("canonical fabric session authority payload: %v", err) - } - signature := authority.Signature{ - SchemaVersion: authority.SignatureSchemaVersion, - Algorithm: authority.AlgorithmEd25519, - KeyFingerprint: authority.Fingerprint(publicKey), - Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)), - } - rawSignature, err := json.Marshal(signature) - if err != nil { - t.Fatalf("marshal fabric session authority signature: %v", err) - } - headers.Set("X-RAP-Fabric-Session-Authority-Payload", base64.StdEncoding.EncodeToString(rawPayload)) - headers.Set("X-RAP-Fabric-Session-Authority-Signature", base64.StdEncoding.EncodeToString(rawSignature)) - return headers -} - -func readMeshFabricFrame(t *testing.T, conn *websocket.Conn) fabricproto.Frame { - t.Helper() - if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { - t.Fatalf("set websocket read deadline: %v", err) - } - messageType, payload, err := conn.ReadMessage() - if err != nil { - t.Fatalf("read fabric websocket frame: %v", err) - } - if messageType != websocket.BinaryMessage { - t.Fatalf("message type = %d, want binary", messageType) - } - frame, err := fabricproto.UnmarshalFrame(payload, fabricproto.DefaultMaxPayload) - if err != nil { - t.Fatalf("unmarshal fabric websocket frame: %v", err) - } - return frame -} - func TestMeshForwardingGateDeliversFabricControlAtDestination(t *testing.T) { local := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-c"} var events []ProductionForwardLogEntry @@ -1107,347 +881,6 @@ func TestProductionForwardRejectsVPNPacketOnFabricControlRoute(t *testing.T) { } } -func TestVPNPacketIngressFallsBackToBackendRelayWhenFabricPeerUnavailable(t *testing.T) { - var backendBody []byte - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/clusters/cluster-1/vpn-connections/vpn-1/tunnel/client/packets" { - t.Fatalf("backend path = %s", r.URL.Path) - } - if r.Header.Get("X-RAP-Entry-Node") != "entry-1" { - t.Fatalf("entry header = %q", r.Header.Get("X-RAP-Entry-Node")) - } - var err error - backendBody, err = io.ReadAll(r.Body) - if err != nil { - t.Fatalf("read backend body: %v", err) - } - w.WriteHeader(http.StatusAccepted) - })) - defer backend.Close() - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: failingVPNPacketIngress{sendErr: ErrForwardPeerUnavailable}, - BackendProxyBaseURL: backend.URL + "/api/v1", - }.Handler()) - defer server.Close() - - resp, err := http.Post(server.URL+"/api/v1/clusters/cluster-1/vpn-connections/vpn-1/tunnel/client/packets", "application/octet-stream", bytes.NewReader([]byte("packet"))) - if err != nil { - t.Fatalf("post vpn packet: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - if string(backendBody) != "packet" { - t.Fatalf("backend body = %q", string(backendBody)) - } -} - -func TestVPNPacketIngressFallsBackToBackendRelayWhenFabricInboxIsEmpty(t *testing.T) { - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, _ *http.Request) { - w.Header().Set("Content-Type", "application/octet-stream") - _, _ = w.Write([]byte("reply")) - })) - defer backend.Close() - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: failingVPNPacketIngress{}, - BackendProxyBaseURL: backend.URL + "/api/v1", - }.Handler()) - defer server.Close() - - resp, err := http.Get(server.URL + "/api/v1/clusters/cluster-1/vpn-connections/vpn-1/tunnel/client/packets?timeout_ms=2") - if err != nil { - t.Fatalf("get vpn packet: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusOK) - } - body, err := io.ReadAll(resp.Body) - if err != nil { - t.Fatalf("read body: %v", err) - } - if string(body) != "reply" { - t.Fatalf("body = %q", string(body)) - } -} - -func TestFabricServiceChannelVPNPacketIngressRequiresLeaseToken(t *testing.T) { - ingress := &recordingVPNPacketIngress{} - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: ingress, - }.Handler()) - defer server.Close() - - resp, err := http.Post(server.URL+"/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/vpn-connections/vpn-1/packets", "application/octet-stream", bytes.NewReader([]byte("packet"))) - if err != nil { - t.Fatalf("post service channel packet: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusUnauthorized { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusUnauthorized) - } - ingress.mu.Lock() - defer ingress.mu.Unlock() - if len(ingress.sent) != 0 { - t.Fatalf("unexpected sent packets = %#v", ingress.sent) - } -} - -func TestFabricServiceChannelVPNPacketIngressMovesBatchOverFabricRuntime(t *testing.T) { - ingress := &recordingVPNPacketIngress{ - receive: [][]byte{[]byte("reply-1"), []byte("reply-2")}, - } - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: ingress, - }.Handler()) - defer server.Close() - - body := encodeVPNIngressPacketBatch([][]byte{[]byte("packet-1"), []byte("packet-2")}) - req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/vpn-connections/vpn-1/packets?batch=true", bytes.NewReader(body)) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("Authorization", "Bearer rap_fsc_testtoken") - req.Header.Set("X-RAP-Service-Class", FabricServiceClassVPNPackets) - req.Header.Set("X-RAP-Channel-Class", ProductionChannelVPNPacket) - req.Header.Set("X-RAP-Traffic-Class", "interactive") - req.Header.Set("X-RAP-Fabric-Channel-ID", "channel-1") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("post service channel packet batch: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("post status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - ingress.mu.Lock() - if ingress.clusterID != "cluster-1" || ingress.vpnConnectionID != "vpn-1" { - t.Fatalf("ingress ids = %s %s", ingress.clusterID, ingress.vpnConnectionID) - } - if len(ingress.sent) != 2 || string(ingress.sent[0]) != "packet-1" || string(ingress.sent[1]) != "packet-2" { - t.Fatalf("sent packets = %#v", ingress.sent) - } - if ingress.trafficClass != "interactive" { - t.Fatalf("traffic class = %q, want interactive", ingress.trafficClass) - } - ingress.mu.Unlock() - - req, err = http.NewRequest(http.MethodGet, server.URL+"/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/vpn-connections/vpn-1/packets?batch=true&timeout_ms=2", nil) - if err != nil { - t.Fatalf("new get request: %v", err) - } - req.Header.Set("X-RAP-Service-Channel-Token", "rap_fsc_testtoken") - resp, err = http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("get service channel packet batch: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusOK { - t.Fatalf("get status = %d, want %d", resp.StatusCode, http.StatusOK) - } - payload, err := io.ReadAll(resp.Body) - if err != nil { - t.Fatalf("read get body: %v", err) - } - packets, err := decodeVPNIngressPacketBatch(payload) - if err != nil { - t.Fatalf("decode get batch: %v", err) - } - if len(packets) != 2 || string(packets[0]) != "reply-1" || string(packets[1]) != "reply-2" { - t.Fatalf("reply packets = %#v", packets) - } -} - -func TestFabricServiceChannelVPNPacketIngressRequiresSignedLeaseWhenAuthorityPinned(t *testing.T) { - publicKey, _, err := ed25519.GenerateKey(nil) - if err != nil { - t.Fatalf("generate key: %v", err) - } - ingress := &recordingVPNPacketIngress{} - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: ingress, - ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), - }.Handler()) - defer server.Close() - - req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/vpn-connections/vpn-1/packets", bytes.NewReader([]byte("packet"))) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("X-RAP-Service-Channel-Token", "rap_fsc_unsigned") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("post service channel packet: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusForbidden { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden) - } - ingress.mu.Lock() - defer ingress.mu.Unlock() - if len(ingress.sent) != 0 { - t.Fatalf("unexpected sent packets = %#v", ingress.sent) - } -} - -func TestFabricServiceChannelVPNPacketIngressUsesBackendIntrospectionWhenUnsigned(t *testing.T) { - publicKey, _, err := ed25519.GenerateKey(nil) - if err != nil { - t.Fatalf("generate key: %v", err) - } - var introspected bool - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - if r.URL.Path != "/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/introspect" { - t.Fatalf("introspection path = %s", r.URL.Path) - } - introspected = true - w.Header().Set("Content-Type", "application/json") - _, _ = w.Write([]byte(`{"fabric_service_channel_introspection":{"allowed":true,"status":"allowed","selected_entry_node_id":"entry-1","allowed_channels":["vpn_packet"],"preferred_route_id":"route-1","lease_status":"ready","primary_route":{"route_id":"route-1","status":"ready"},"data_plane":{"schema_version":"rap.fabric_service_channel_data_plane.v1","mode":"fabric_primary","working_data_transport":"fabric_service_channel","steady_state_transport":"fabric_route","backend_relay_policy":"degraded_fallback_only","service_neutral":true,"protocol_agnostic":true,"logical_flow_mode":"multi_flow_isolated","required_flow_isolation_classes":["control","vpn_packet"]},"expires_at":"2099-01-01T00:00:00Z"}}`)) - })) - defer backend.Close() - ingress := &recordingVPNPacketIngress{} - var accessEvents []FabricServiceChannelAccessLogEntry - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: ingress, - BackendProxyBaseURL: backend.URL + "/api/v1", - ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), - FabricServiceChannelLogger: func(entry FabricServiceChannelAccessLogEntry) { - accessEvents = append(accessEvents, entry) - }, - }.Handler()) - defer server.Close() - - req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/vpn-connections/vpn-1/packets", bytes.NewReader([]byte("packet"))) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("X-RAP-Service-Channel-Token", "rap_fsc_unsigned") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("post service channel packet: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - if got := resp.Header.Get("X-RAP-Service-Channel-Accepted-By"); got != "introspection" { - t.Fatalf("accepted-by header = %q, want introspection", got) - } - if !introspected { - t.Fatal("backend introspection was not called") - } - if len(accessEvents) != 1 || accessEvents[0].AcceptedBy != "introspection" || accessEvents[0].PreferredRouteID != "route-1" || - !accessEvents[0].DataPlaneValid || - accessEvents[0].WorkingDataTransport != "fabric_service_channel" || - accessEvents[0].SteadyStateTransport != "fabric_route" || - accessEvents[0].BackendRelayPolicy != "degraded_fallback_only" { - t.Fatalf("unexpected access events: %+v", accessEvents) - } - ingress.mu.Lock() - defer ingress.mu.Unlock() - if len(ingress.sent) != 1 || string(ingress.sent[0]) != "packet" { - t.Fatalf("sent packets = %#v", ingress.sent) - } -} - -func TestFabricServiceChannelVPNPacketIngressVerifiesSignedLeaseAuthority(t *testing.T) { - publicKey, privateKey, err := ed25519.GenerateKey(nil) - if err != nil { - t.Fatalf("generate key: %v", err) - } - token := "rap_fsc_signedtest" - payload := fabricServiceChannelLeaseAuthorityPayload{ - SchemaVersion: "rap.fabric_service_channel_lease_authority.v1", - ChannelID: "channel-1", - ClusterID: "cluster-1", - ResourceID: "vpn-1", - ServiceClass: FabricServiceClassVPNPackets, - SelectedEntryNodeID: "entry-1", - SelectedExitNodeID: "exit-1", - AllowedChannels: []string{ProductionChannelVPNPacket}, - RouteGeneration: "rg-1", - FencingEpoch: 7, - TokenHash: fabricServiceChannelTokenHash(token), - IssuedAt: time.Now().UTC().Add(-time.Minute), - ExpiresAt: time.Now().UTC().Add(time.Minute), - DataPlane: fabricServiceChannelDataPlaneContract{ - SchemaVersion: "rap.fabric_service_channel_data_plane.v1", - Mode: "fabric_primary", - WorkingDataTransport: "fabric_service_channel", - SteadyStateTransport: "fabric_route", - BackendRelayPolicy: "degraded_fallback_only", - ProductionForwardingRequired: true, - ServiceNeutral: true, - ProtocolAgnostic: true, - LogicalFlowMode: "multi_flow_isolated", - RequiredFlowIsolationClasses: []string{"control", ProductionChannelVPNPacket}, - }, - } - payload.PrimaryRoute.RouteID = "route-signed" - payload.PrimaryRoute.Status = "authorized" - rawPayload, err := json.Marshal(payload) - if err != nil { - t.Fatalf("marshal payload: %v", err) - } - canonical, err := authority.CanonicalJSON(rawPayload) - if err != nil { - t.Fatalf("canonical payload: %v", err) - } - signature := authority.Signature{ - SchemaVersion: authority.SignatureSchemaVersion, - Algorithm: authority.AlgorithmEd25519, - KeyFingerprint: authority.Fingerprint(publicKey), - Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)), - } - rawSignature, err := json.Marshal(signature) - if err != nil { - t.Fatalf("marshal signature: %v", err) - } - ingress := &recordingVPNPacketIngress{} - var accessEvents []FabricServiceChannelAccessLogEntry - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: ingress, - ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), - FabricServiceChannelLogger: func(entry FabricServiceChannelAccessLogEntry) { - accessEvents = append(accessEvents, entry) - }, - }.Handler()) - defer server.Close() - - req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/vpn-connections/vpn-1/packets", bytes.NewReader([]byte("packet"))) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("X-RAP-Service-Channel-Token", token) - req.Header.Set("X-RAP-Service-Channel-Authority-Payload", base64.RawURLEncoding.EncodeToString(rawPayload)) - req.Header.Set("X-RAP-Service-Channel-Authority-Signature", base64.RawURLEncoding.EncodeToString(rawSignature)) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("post service channel packet: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - if len(accessEvents) != 1 || - accessEvents[0].AcceptedBy != "signed" || - accessEvents[0].PreferredRouteID != "route-signed" || - !accessEvents[0].DataPlaneValid || - accessEvents[0].WorkingDataTransport != "fabric_service_channel" || - accessEvents[0].SteadyStateTransport != "fabric_route" || - accessEvents[0].BackendRelayPolicy != "degraded_fallback_only" { - t.Fatalf("unexpected signed data-plane access events: %+v", accessEvents) - } -} - func TestFabricServiceChannelRemoteWorkspaceIngressValidatesSignedLeaseAuthority(t *testing.T) { publicKey, privateKey, err := ed25519.GenerateKey(nil) if err != nil { @@ -1470,10 +903,10 @@ func TestFabricServiceChannelRemoteWorkspaceIngressValidatesSignedLeaseAuthority ExpiresAt: time.Now().UTC().Add(time.Minute), DataPlane: fabricServiceChannelDataPlaneContract{ SchemaVersion: "rap.fabric_service_channel_data_plane.v1", - Mode: "fabric_primary", - WorkingDataTransport: "fabric_service_channel", + Mode: "fabric_quic_only", + WorkingDataTransport: "fabric_quic_route", SteadyStateTransport: "fabric_route", - BackendRelayPolicy: "degraded_fallback_only", + BackendRelayPolicy: "disabled", ProductionForwardingRequired: true, ServiceNeutral: true, ProtocolAgnostic: true, @@ -1533,7 +966,7 @@ func TestFabricServiceChannelRemoteWorkspaceIngressValidatesSignedLeaseAuthority if err := json.NewDecoder(resp.Body).Decode(&decoded); err != nil { t.Fatalf("decode response: %v", err) } - if decoded["service_class"] != FabricServiceClassRemoteWorkspace || decoded["channel_class"] != FabricServiceChannelInteractive || decoded["payload_flow"] != "not_implemented" { + if decoded["service_class"] != FabricServiceClassRemoteWorkspace || decoded["channel_class"] != FabricServiceChannelInteractive || decoded["payload_flow"] != "validated_only" { t.Fatalf("unexpected response: %+v", decoded) } if len(accessEvents) != 1 || @@ -1542,7 +975,7 @@ func TestFabricServiceChannelRemoteWorkspaceIngressValidatesSignedLeaseAuthority accessEvents[0].ChannelClass != FabricServiceChannelInteractive || accessEvents[0].PreferredRouteID != "route-rw" || !accessEvents[0].DataPlaneValid || - accessEvents[0].WorkingDataTransport != "fabric_service_channel" || + accessEvents[0].WorkingDataTransport != "fabric_quic_route" || accessEvents[0].SteadyStateTransport != "fabric_route" { t.Fatalf("unexpected remote workspace access events: %+v", accessEvents) } @@ -1566,10 +999,10 @@ func TestFabricServiceChannelRemoteWorkspaceIngressAcceptsFrameBatchProbeOnly(t ExpiresAt: time.Now().UTC().Add(time.Minute), DataPlane: fabricServiceChannelDataPlaneContract{ SchemaVersion: "rap.fabric_service_channel_data_plane.v1", - Mode: "fabric_primary", - WorkingDataTransport: "fabric_service_channel", + Mode: "fabric_quic_only", + WorkingDataTransport: "fabric_quic_route", SteadyStateTransport: "fabric_route", - BackendRelayPolicy: "degraded_fallback_only", + BackendRelayPolicy: "disabled", ServiceNeutral: true, ProtocolAgnostic: true, LogicalFlowMode: "multi_flow_isolated", @@ -1659,6 +1092,28 @@ func TestFabricServiceChannelRemoteWorkspaceIngressAcceptsFrameBatchProbeOnly(t } } +func TestFabricServiceChannelRemoteWorkspaceWebSocketIngressRemoved(t *testing.T) { + server := httptest.NewServer(Server{ + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, + VPNPacketIngress: &recordingVPNPacketIngress{}, + }.Handler()) + defer server.Close() + + req, err := http.NewRequest(http.MethodGet, server.URL+"/api/v1/clusters/cluster-1/fabric/service-channels/channel-rw/remote-workspaces/workspace-1/streams/ws", nil) + if err != nil { + t.Fatalf("new request: %v", err) + } + resp, err := http.DefaultClient.Do(req) + if err != nil { + t.Fatalf("get remote workspace websocket ingress: %v", err) + } + defer resp.Body.Close() + raw, _ := io.ReadAll(resp.Body) + if resp.StatusCode != http.StatusGone || !strings.Contains(string(raw), "use QUIC fabric route") { + t.Fatalf("status=%d body=%s", resp.StatusCode, string(raw)) + } +} + func TestRemoteWorkspaceFrameBatchProbeRejectsGuardrailViolations(t *testing.T) { valid := map[string]any{ "schema_version": "rap.remote_workspace_frame_batch.v1", @@ -1680,7 +1135,7 @@ func TestRemoteWorkspaceFrameBatchProbeRejectsGuardrailViolations(t *testing.T) mutate: func(item map[string]any) { item["probe_only"] = false }, - wantErr: "remote workspace payload forwarding is not implemented", + wantErr: "remote workspace production payload forwarding is disabled; probe_only required", }, { name: "unknown logical channel", @@ -4259,298 +3714,8 @@ func TestRemoteWorkspacePreflightAttentionReasonSummaries(t *testing.T) { } } -func TestFabricServiceChannelVPNPacketIngressHonorsDisabledBackendRelayPolicy(t *testing.T) { - publicKey, privateKey, err := ed25519.GenerateKey(nil) - if err != nil { - t.Fatalf("generate key: %v", err) - } - var backendCalled bool - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - backendCalled = true - w.WriteHeader(http.StatusAccepted) - })) - defer backend.Close() - token := "rap_fsc_nobackend" - payload := fabricServiceChannelLeaseAuthorityPayload{ - SchemaVersion: "rap.fabric_service_channel_lease_authority.v1", - ChannelID: "channel-1", - ClusterID: "cluster-1", - ResourceID: "vpn-1", - ServiceClass: FabricServiceClassVPNPackets, - SelectedEntryNodeID: "entry-1", - SelectedExitNodeID: "exit-1", - AllowedChannels: []string{ProductionChannelVPNPacket}, - TokenHash: fabricServiceChannelTokenHash(token), - ExpiresAt: time.Now().UTC().Add(time.Minute), - DataPlane: fabricServiceChannelDataPlaneContract{ - SchemaVersion: "rap.fabric_service_channel_data_plane.v1", - Mode: "fabric_primary", - WorkingDataTransport: "fabric_service_channel", - SteadyStateTransport: "fabric_route", - BackendRelayPolicy: "disabled", - ProductionForwardingRequired: true, - ServiceNeutral: true, - ProtocolAgnostic: true, - LogicalFlowMode: "multi_flow_isolated", - RequiredFlowIsolationClasses: []string{"control", ProductionChannelVPNPacket}, - }, - } - payload.PrimaryRoute.RouteID = "route-signed" - payload.PrimaryRoute.Status = "authorized" - rawPayload, err := json.Marshal(payload) - if err != nil { - t.Fatalf("marshal payload: %v", err) - } - canonical, err := authority.CanonicalJSON(rawPayload) - if err != nil { - t.Fatalf("canonical payload: %v", err) - } - signature := authority.Signature{ - SchemaVersion: authority.SignatureSchemaVersion, - Algorithm: authority.AlgorithmEd25519, - KeyFingerprint: authority.Fingerprint(publicKey), - Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)), - } - rawSignature, err := json.Marshal(signature) - if err != nil { - t.Fatalf("marshal signature: %v", err) - } - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: failingVPNPacketIngress{sendErr: ErrRouteNotFound}, - BackendProxyBaseURL: backend.URL + "/api/v1", - ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), - }.Handler()) - defer server.Close() - - req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/vpn-connections/vpn-1/packets", bytes.NewReader([]byte("packet"))) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("X-RAP-Service-Channel-Token", token) - req.Header.Set("X-RAP-Service-Channel-Authority-Payload", base64.RawURLEncoding.EncodeToString(rawPayload)) - req.Header.Set("X-RAP-Service-Channel-Authority-Signature", base64.RawURLEncoding.EncodeToString(rawSignature)) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("post service channel packet: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusServiceUnavailable { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusServiceUnavailable) - } - if backendCalled { - t.Fatal("backend relay was called despite disabled data-plane policy") - } -} - -func TestFabricServiceChannelVPNPacketWebSocketHonorsDisabledBackendRelayPolicy(t *testing.T) { - publicKey, privateKey, err := ed25519.GenerateKey(nil) - if err != nil { - t.Fatalf("generate key: %v", err) - } - backendCalled := make(chan struct{}, 1) - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - select { - case backendCalled <- struct{}{}: - default: - } - w.WriteHeader(http.StatusAccepted) - })) - defer backend.Close() - token := "rap_fsc_nobackend_ws" - payload := fabricServiceChannelLeaseAuthorityPayload{ - SchemaVersion: "rap.fabric_service_channel_lease_authority.v1", - ChannelID: "channel-1", - ClusterID: "cluster-1", - ResourceID: "vpn-1", - ServiceClass: FabricServiceClassVPNPackets, - SelectedEntryNodeID: "entry-1", - SelectedExitNodeID: "exit-1", - AllowedChannels: []string{ProductionChannelVPNPacket}, - TokenHash: fabricServiceChannelTokenHash(token), - ExpiresAt: time.Now().UTC().Add(time.Minute), - DataPlane: fabricServiceChannelDataPlaneContract{ - SchemaVersion: "rap.fabric_service_channel_data_plane.v1", - Mode: "fabric_primary", - WorkingDataTransport: "fabric_service_channel", - SteadyStateTransport: "fabric_route", - BackendRelayPolicy: "disabled", - ProductionForwardingRequired: true, - ServiceNeutral: true, - ProtocolAgnostic: true, - LogicalFlowMode: "multi_flow_isolated", - RequiredFlowIsolationClasses: []string{"control", ProductionChannelVPNPacket}, - }, - } - payload.PrimaryRoute.RouteID = "route-signed" - payload.PrimaryRoute.Status = "authorized" - rawPayload, err := json.Marshal(payload) - if err != nil { - t.Fatalf("marshal payload: %v", err) - } - canonical, err := authority.CanonicalJSON(rawPayload) - if err != nil { - t.Fatalf("canonical payload: %v", err) - } - signature := authority.Signature{ - SchemaVersion: authority.SignatureSchemaVersion, - Algorithm: authority.AlgorithmEd25519, - KeyFingerprint: authority.Fingerprint(publicKey), - Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)), - } - rawSignature, err := json.Marshal(signature) - if err != nil { - t.Fatalf("marshal signature: %v", err) - } - violations := make(chan FabricServiceChannelAccessLogEntry, 2) - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: failingVPNPacketIngress{sendErr: ErrRouteNotFound}, - BackendProxyBaseURL: backend.URL + "/api/v1", - ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), - FabricServiceChannelLogger: func(entry FabricServiceChannelAccessLogEntry) { - if entry.Event == "fabric_service_channel_data_plane_violation" { - violations <- entry - } - }, - }.Handler()) - defer server.Close() - - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/vpn-connections/vpn-1/packets/ws" - headers := http.Header{} - headers.Set("X-RAP-Service-Channel-Token", token) - headers.Set("X-RAP-Service-Channel-Authority-Payload", base64.RawURLEncoding.EncodeToString(rawPayload)) - headers.Set("X-RAP-Service-Channel-Authority-Signature", base64.RawURLEncoding.EncodeToString(rawSignature)) - conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers) - if err != nil { - t.Fatalf("dial websocket: %v", err) - } - defer conn.Close() - - if err := conn.WriteMessage(websocket.BinaryMessage, encodeVPNIngressPacketBatch([][]byte{[]byte("packet")})); err != nil { - t.Fatalf("write packet batch: %v", err) - } - - select { - case entry := <-violations: - if entry.ViolationStatus != "fabric_route_send_failed_backend_fallback_blocked" || - entry.BackendRelayPolicy != "disabled" || - entry.ChannelID != "channel-1" || - entry.ResourceID != "vpn-1" { - t.Fatalf("violation = %+v", entry) - } - case <-time.After(2 * time.Second): - t.Fatal("blocked fallback violation was not logged") - } - select { - case <-backendCalled: - t.Fatal("backend relay was called despite disabled data-plane policy") - default: - } -} - -func TestFabricServiceChannelVPNPacketIngressRejectsSignedLeaseForDifferentEntry(t *testing.T) { - publicKey, privateKey, err := ed25519.GenerateKey(nil) - if err != nil { - t.Fatalf("generate key: %v", err) - } - token := "rap_fsc_signedtest" - payload := fabricServiceChannelLeaseAuthorityPayload{ - SchemaVersion: "rap.fabric_service_channel_lease_authority.v1", - ChannelID: "channel-1", - ClusterID: "cluster-1", - ResourceID: "vpn-1", - ServiceClass: FabricServiceClassVPNPackets, - SelectedEntryNodeID: "other-entry", - SelectedExitNodeID: "exit-1", - AllowedChannels: []string{ProductionChannelVPNPacket}, - TokenHash: fabricServiceChannelTokenHash(token), - ExpiresAt: time.Now().UTC().Add(time.Minute), - } - rawPayload, err := json.Marshal(payload) - if err != nil { - t.Fatalf("marshal payload: %v", err) - } - canonical, err := authority.CanonicalJSON(rawPayload) - if err != nil { - t.Fatalf("canonical payload: %v", err) - } - signature := authority.Signature{ - SchemaVersion: authority.SignatureSchemaVersion, - Algorithm: authority.AlgorithmEd25519, - KeyFingerprint: authority.Fingerprint(publicKey), - Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)), - } - rawSignature, err := json.Marshal(signature) - if err != nil { - t.Fatalf("marshal signature: %v", err) - } - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: &recordingVPNPacketIngress{}, - ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), - }.Handler()) - defer server.Close() - - req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/vpn-connections/vpn-1/packets", bytes.NewReader([]byte("packet"))) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("X-RAP-Service-Channel-Token", token) - req.Header.Set("X-RAP-Service-Channel-Authority-Payload", base64.RawURLEncoding.EncodeToString(rawPayload)) - req.Header.Set("X-RAP-Service-Channel-Authority-Signature", base64.RawURLEncoding.EncodeToString(rawSignature)) - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("post service channel packet: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusForbidden { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusForbidden) - } -} - -func TestFabricServiceChannelVPNPacketIngressFallsBackToBackendRelay(t *testing.T) { - var backendPath string - var backendBody []byte - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - backendPath = r.URL.Path - var err error - backendBody, err = io.ReadAll(r.Body) - if err != nil { - t.Fatalf("read backend body: %v", err) - } - w.WriteHeader(http.StatusAccepted) - })) - defer backend.Close() - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: failingVPNPacketIngress{sendErr: ErrRouteNotFound}, - BackendProxyBaseURL: backend.URL + "/api/v1", - }.Handler()) - defer server.Close() - - req, err := http.NewRequest(http.MethodPost, server.URL+"/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/vpn-connections/vpn-1/packets", bytes.NewReader([]byte("packet"))) - if err != nil { - t.Fatalf("new request: %v", err) - } - req.Header.Set("X-RAP-Service-Channel-Token", "rap_fsc_testtoken") - resp, err := http.DefaultClient.Do(req) - if err != nil { - t.Fatalf("post service channel packet: %v", err) - } - defer resp.Body.Close() - if resp.StatusCode != http.StatusAccepted { - t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusAccepted) - } - if backendPath != "/api/v1/clusters/cluster-1/vpn-connections/vpn-1/tunnel/client/packets" { - t.Fatalf("backend path = %s", backendPath) - } - if string(backendBody) != "packet" { - t.Fatalf("backend body = %q", string(backendBody)) - } -} - func TestFabricServiceChannelVPNPacketIngressUsesSignedDegradedFallback(t *testing.T) { + t.Skip("degraded backend fallback removed from the QUIC-only fabric dataplane") publicKey, privateKey, err := ed25519.GenerateKey(nil) if err != nil { t.Fatalf("generate key: %v", err) @@ -4635,237 +3800,6 @@ func TestFabricServiceChannelVPNPacketIngressUsesSignedDegradedFallback(t *testi } } -func TestVPNPacketIngressWebSocketMovesBatchesBothDirections(t *testing.T) { - ingress := &recordingVPNPacketIngress{ - receive: [][]byte{[]byte("reply-1"), []byte("reply-2")}, - } - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: ingress, - }.Handler()) - defer server.Close() - - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/clusters/cluster-1/vpn-connections/vpn-1/tunnel/client/packets/ws" - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - if err != nil { - t.Fatalf("dial websocket: %v", err) - } - defer conn.Close() - - if err := conn.WriteMessage(websocket.BinaryMessage, encodeVPNIngressPacketBatch([][]byte{[]byte("packet-1"), []byte("packet-2")})); err != nil { - t.Fatalf("write packet batch: %v", err) - } - if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { - t.Fatalf("set read deadline: %v", err) - } - messageType, payload, err := conn.ReadMessage() - if err != nil { - t.Fatalf("read packet batch: %v", err) - } - if messageType != websocket.BinaryMessage { - t.Fatalf("message type = %d, want binary", messageType) - } - packets, err := decodeVPNIngressPacketBatch(payload) - if err != nil { - t.Fatalf("decode reply batch: %v", err) - } - if len(packets) != 2 || string(packets[0]) != "reply-1" || string(packets[1]) != "reply-2" { - t.Fatalf("reply packets = %#v", packets) - } - - deadline := time.Now().Add(2 * time.Second) - for { - ingress.mu.Lock() - sent := append([][]byte(nil), ingress.sent...) - clusterID := ingress.clusterID - vpnConnectionID := ingress.vpnConnectionID - ingress.mu.Unlock() - if len(sent) == 2 { - if clusterID != "cluster-1" || vpnConnectionID != "vpn-1" { - t.Fatalf("ingress ids = %s %s", clusterID, vpnConnectionID) - } - if string(sent[0]) != "packet-1" || string(sent[1]) != "packet-2" { - t.Fatalf("sent packets = %#v", sent) - } - break - } - if time.Now().After(deadline) { - t.Fatalf("sent packets = %#v", sent) - } - time.Sleep(10 * time.Millisecond) - } -} - -func TestFabricServiceChannelVPNPacketWebSocketPreservesTrafficClass(t *testing.T) { - ingress := &recordingVPNPacketIngress{ - receive: [][]byte{[]byte("reply")}, - } - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: ingress, - }.Handler()) - defer server.Close() - - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/vpn-connections/vpn-1/packets/ws" - headers := http.Header{} - headers.Set("Authorization", "Bearer rap_fsc_testtoken") - headers.Set("X-RAP-Service-Class", FabricServiceClassVPNPackets) - headers.Set("X-RAP-Channel-Class", ProductionChannelVPNPacket) - headers.Set("X-RAP-Traffic-Class", "interactive") - headers.Set("X-RAP-Fabric-Channel-ID", "channel-1") - conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers) - if err != nil { - t.Fatalf("dial websocket: %v", err) - } - defer conn.Close() - - if err := conn.WriteMessage(websocket.BinaryMessage, encodeVPNIngressPacketBatch([][]byte{[]byte("packet")})); err != nil { - t.Fatalf("write packet batch: %v", err) - } - if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { - t.Fatalf("set read deadline: %v", err) - } - if _, _, err := conn.ReadMessage(); err != nil { - t.Fatalf("read packet batch: %v", err) - } - - deadline := time.Now().Add(2 * time.Second) - for { - ingress.mu.Lock() - trafficClass := ingress.trafficClass - sent := append([][]byte(nil), ingress.sent...) - ingress.mu.Unlock() - if trafficClass == "interactive" && len(sent) == 1 && string(sent[0]) == "packet" { - break - } - if time.Now().After(deadline) { - t.Fatalf("traffic class = %q sent packets = %#v, want interactive packet", trafficClass, sent) - } - time.Sleep(10 * time.Millisecond) - } -} - -func TestFabricServiceChannelVPNPacketWebSocketInfersInteractiveTrafficClass(t *testing.T) { - ingress := &recordingVPNPacketIngress{ - receive: [][]byte{[]byte("reply")}, - } - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: ingress, - }.Handler()) - defer server.Close() - - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/clusters/cluster-1/fabric/service-channels/channel-1/vpn-connections/vpn-1/packets/ws" - headers := http.Header{} - headers.Set("Authorization", "Bearer rap_fsc_testtoken") - headers.Set("X-RAP-Service-Class", FabricServiceClassVPNPackets) - headers.Set("X-RAP-Channel-Class", ProductionChannelVPNPacket) - headers.Set("X-RAP-Fabric-Channel-ID", "channel-1") - conn, _, err := websocket.DefaultDialer.Dial(wsURL, headers) - if err != nil { - t.Fatalf("dial websocket: %v", err) - } - defer conn.Close() - - packet := testVPNIPv4TCPPacket([4]byte{10, 77, 0, 2}, [4]byte{192, 168, 200, 95}, 51000, 3389, 0x02) - if err := conn.WriteMessage(websocket.BinaryMessage, encodeVPNIngressPacketBatch([][]byte{packet})); err != nil { - t.Fatalf("write packet batch: %v", err) - } - if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { - t.Fatalf("set read deadline: %v", err) - } - if _, _, err := conn.ReadMessage(); err != nil { - t.Fatalf("read packet batch: %v", err) - } - - deadline := time.Now().Add(2 * time.Second) - for { - ingress.mu.Lock() - trafficClass := ingress.trafficClass - sent := append([][]byte(nil), ingress.sent...) - ingress.mu.Unlock() - if trafficClass == "interactive" && len(sent) == 1 { - break - } - if time.Now().After(deadline) { - t.Fatalf("traffic class = %q sent packets = %#v, want inferred interactive packet", trafficClass, sent) - } - time.Sleep(10 * time.Millisecond) - } -} - -func TestVPNPacketIngressWebSocketFallsBackToBackendRelay(t *testing.T) { - var backendBody []byte - postSeen := make(chan struct{}, 1) - backend := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - switch r.Method { - case http.MethodPost: - if r.URL.Path != "/api/v1/clusters/cluster-1/vpn-connections/vpn-1/tunnel/client/packets" || r.URL.Query().Get("batch") != "true" { - t.Fatalf("backend post target = %s?%s", r.URL.Path, r.URL.RawQuery) - } - var err error - backendBody, err = io.ReadAll(r.Body) - if err != nil { - t.Fatalf("read backend body: %v", err) - } - select { - case postSeen <- struct{}{}: - default: - } - w.WriteHeader(http.StatusAccepted) - case http.MethodGet: - if r.URL.Path != "/api/v1/clusters/cluster-1/vpn-connections/vpn-1/tunnel/client/packets" || r.URL.Query().Get("batch") != "true" { - t.Fatalf("backend get target = %s?%s", r.URL.Path, r.URL.RawQuery) - } - w.Header().Set("Content-Type", "application/vnd.rap.vpn-packet-batch.v1") - _, _ = w.Write(encodeVPNIngressPacketBatch([][]byte{[]byte("backend-reply")})) - default: - w.WriteHeader(http.StatusMethodNotAllowed) - } - })) - defer backend.Close() - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - VPNPacketIngress: failingVPNPacketIngress{sendErr: ErrRouteNotFound, receiveErr: ErrRouteNotFound}, - BackendProxyBaseURL: backend.URL + "/api/v1", - }.Handler()) - defer server.Close() - - wsURL := "ws" + strings.TrimPrefix(server.URL, "http") + "/api/v1/clusters/cluster-1/vpn-connections/vpn-1/tunnel/client/packets/ws" - conn, _, err := websocket.DefaultDialer.Dial(wsURL, nil) - if err != nil { - t.Fatalf("dial websocket: %v", err) - } - defer conn.Close() - - sentPayload := encodeVPNIngressPacketBatch([][]byte{[]byte("packet")}) - if err := conn.WriteMessage(websocket.BinaryMessage, sentPayload); err != nil { - t.Fatalf("write packet batch: %v", err) - } - if err := conn.SetReadDeadline(time.Now().Add(2 * time.Second)); err != nil { - t.Fatalf("set read deadline: %v", err) - } - _, payload, err := conn.ReadMessage() - if err != nil { - t.Fatalf("read backend packet batch: %v", err) - } - packets, err := decodeVPNIngressPacketBatch(payload) - if err != nil { - t.Fatalf("decode backend batch: %v", err) - } - if len(packets) != 1 || string(packets[0]) != "backend-reply" { - t.Fatalf("backend reply packets = %#v", packets) - } - select { - case <-postSeen: - case <-time.After(2 * time.Second): - t.Fatal("backend POST was not observed") - } - if !bytes.Equal(backendBody, sentPayload) { - t.Fatalf("backend body = %q want %q", string(backendBody), string(sentPayload)) - } -} - func TestNewProductionVPNPacketBatchEnvelopeRoundTripsPayload(t *testing.T) { now := time.Now().UTC() envelope, err := NewProductionVPNPacketBatchEnvelope(ProductionVPNPacketEnvelopeInput{ diff --git a/agents/rap-node-agent/mobile/fabricvpn/fabric_control_live_test.go b/agents/rap-node-agent/mobile/fabricvpn/fabric_control_live_test.go new file mode 100644 index 0000000..b14d192 --- /dev/null +++ b/agents/rap-node-agent/mobile/fabricvpn/fabric_control_live_test.go @@ -0,0 +1,49 @@ +package fabricvpn + +import ( + "encoding/json" + "os" + "strings" + "testing" +) + +func TestLiveFabricControlRequest(t *testing.T) { + cfg := strings.TrimSpace(os.Getenv("RAP_LIVE_FABRIC_CONTROL_CONFIG")) + if cfg == "" { + t.Skip("set RAP_LIVE_FABRIC_CONTROL_CONFIG to run live fabric control test") + } + path := strings.TrimSpace(os.Getenv("RAP_LIVE_FABRIC_CONTROL_PATH")) + if path == "" { + path = "/organizations/?user_id=3fded8a8-f19b-4974-919f-44d34ac5f63d" + } + method := strings.TrimSpace(os.Getenv("RAP_LIVE_FABRIC_CONTROL_METHOD")) + if method == "" { + method = "GET" + } + body := strings.TrimSpace(os.Getenv("RAP_LIVE_FABRIC_CONTROL_BODY")) + manager := NewManager() + if err := manager.Start(cfg); err != nil { + t.Fatalf("start manager: %v", err) + } + defer manager.Stop() + request := map[string]any{"method": method, "path": path} + if body != "" { + var raw json.RawMessage + if err := json.Unmarshal([]byte(body), &raw); err != nil { + t.Fatalf("invalid request body: %v", err) + } + request["body"] = raw + } + payload, err := json.Marshal(request) + if err != nil { + t.Fatal(err) + } + response, err := manager.ControlRequest(string(payload)) + if err != nil { + t.Fatalf("control request failed: %v", err) + } + if !strings.Contains(response, "status_code") { + t.Fatalf("unexpected control response: %s", response) + } + t.Log(response) +} diff --git a/agents/rap-node-agent/mobile/fabricvpn/fabricvpn.go b/agents/rap-node-agent/mobile/fabricvpn/fabricvpn.go index 3f94ba1..090fe6b 100644 --- a/agents/rap-node-agent/mobile/fabricvpn/fabricvpn.go +++ b/agents/rap-node-agent/mobile/fabricvpn/fabricvpn.go @@ -243,7 +243,7 @@ func (m *Manager) connect(ctx context.Context, cfg runtimeConfig, cancel context if lastErr == nil { lastErr = fmt.Errorf("no QUIC exit endpoints available") } - return lastErr + return fmt.Errorf("fabric bootstrap failed after %d endpoint candidates: %w", len(cfg.Endpoints), lastErr) } func (m *Manager) protectedQUICDialer() func(context.Context, string, *tls.Config, *quic.Config) (*quic.Conn, error) { @@ -447,11 +447,17 @@ func (m *Manager) ControlRequest(payloadJSON string) (string, error) { select { case <-ctx.Done(): return "", ctx.Err() - case err := <-session.Errors(): + case err, ok := <-session.Errors(): + if !ok { + return "", fmt.Errorf("fabric control error stream closed") + } if err != nil { return "", err } - case frame := <-session.Frames(): + case frame, ok := <-session.Frames(): + if !ok { + return "", fmt.Errorf("fabric control stream closed") + } if frame.Type != fabricproto.FrameData || frame.StreamID != mesh.FabricControlForwardQUICStreamID { continue } @@ -460,7 +466,7 @@ func (m *Manager) ControlRequest(payloadJSON string) (string, error) { return "", err } if response.Error != "" { - return "", fmt.Errorf(response.Error) + return "", fmt.Errorf("%s", response.Error) } return string(response.Payload), nil } diff --git a/backend/internal/modules/cluster/models.go b/backend/internal/modules/cluster/models.go index fec4e5f..1b40c55 100644 --- a/backend/internal/modules/cluster/models.go +++ b/backend/internal/modules/cluster/models.go @@ -166,6 +166,7 @@ type DockerInstallProfile struct { BackendURL string `json:"backend_url"` ControlPlaneEndpoints []string `json:"control_plane_endpoints,omitempty"` ArtifactEndpoints []string `json:"artifact_endpoints,omitempty"` + FabricRegistryRecords json.RawMessage `json:"fabric_registry_records,omitempty"` DockerImageArtifact *DockerArtifact `json:"docker_image_artifact,omitempty"` JoinToken string `json:"join_token"` NodeName string `json:"node_name"` @@ -203,6 +204,7 @@ type WindowsInstallProfile struct { BackendURL string `json:"backend_url"` ControlPlaneEndpoints []string `json:"control_plane_endpoints,omitempty"` ArtifactEndpoints []string `json:"artifact_endpoints,omitempty"` + FabricRegistryRecords json.RawMessage `json:"fabric_registry_records,omitempty"` NodeAgentArtifact *DockerArtifact `json:"node_agent_artifact,omitempty"` JoinToken string `json:"join_token"` NodeName string `json:"node_name"` @@ -235,6 +237,7 @@ type LinuxInstallProfile struct { BackendURL string `json:"backend_url"` ControlPlaneEndpoints []string `json:"control_plane_endpoints,omitempty"` ArtifactEndpoints []string `json:"artifact_endpoints,omitempty"` + FabricRegistryRecords json.RawMessage `json:"fabric_registry_records,omitempty"` NodeAgentArtifact *DockerArtifact `json:"node_agent_artifact,omitempty"` JoinToken string `json:"join_token"` NodeName string `json:"node_name"` @@ -372,6 +375,28 @@ type NodeUpdatePlan struct { ProductionForwarding bool `json:"production_forwarding"` } +type NodeBridgeReplayProductPlan struct { + Product string `json:"product"` + RecoveryBridgeMode string `json:"recovery_bridge_mode,omitempty"` + RecoveryBridgeReplayReady bool `json:"recovery_bridge_replay_ready"` + LastStatusReason string `json:"last_status_reason,omitempty"` + UpdatePlan NodeUpdatePlan `json:"update_plan"` +} + +type NodeBridgeReplayPlan struct { + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + NodeID string `json:"node_id"` + NodeName string `json:"node_name,omitempty"` + HealthStatus string `json:"health_status,omitempty"` + HeartbeatStale bool `json:"heartbeat_stale"` + BridgeHoldRequired bool `json:"bridge_hold_required"` + RecoveryBridgeReplayReady bool `json:"recovery_bridge_replay_ready"` + BridgeHoldReasons []string `json:"bridge_hold_reasons,omitempty"` + BridgeActions []string `json:"bridge_actions,omitempty"` + Products []NodeBridgeReplayProductPlan `json:"products,omitempty"` +} + type NodeUpdateStatus struct { ID string `json:"id"` ClusterID string `json:"cluster_id"` @@ -388,6 +413,77 @@ type NodeUpdateStatus struct { ObservedAt time.Time `json:"observed_at"` } +type StaleNodeRiskReport struct { + ClusterID string `json:"cluster_id"` + GeneratedAt time.Time `json:"generated_at"` + HeartbeatStaleAfterSeconds int `json:"heartbeat_stale_after_seconds"` + LegacyRemovalAllowed bool `json:"legacy_removal_allowed"` + BridgeHoldRequired bool `json:"bridge_hold_required"` + BridgeHoldNodeIDs []string `json:"bridge_hold_node_ids,omitempty"` + BridgeHoldReasons []string `json:"bridge_hold_reasons,omitempty"` + BlockedOperations []string `json:"blocked_operations,omitempty"` + Nodes []StaleNodeRiskNode `json:"nodes"` + Summary StaleNodeRiskSummary `json:"summary"` +} + +type StaleNodeRiskSummary struct { + TotalNodes int `json:"total_nodes"` + StaleNodes int `json:"stale_nodes"` + BlockedNodes int `json:"blocked_nodes"` + DirectPeerAlertNodes int `json:"direct_peer_alert_nodes"` + ArtifactGapNodes int `json:"artifact_gap_nodes"` + UnknownProfileNodes int `json:"unknown_profile_nodes"` + WaitingUpdateStatusNodes int `json:"waiting_update_status_nodes"` + UnknownVersionNodes int `json:"unknown_version_nodes"` + LegacyRecoveryContractNodes int `json:"legacy_recovery_contract_nodes"` + RecoveryBridgeRequiredNodes int `json:"recovery_bridge_required_nodes"` + RecoveryBridgeReplayReadyNodes int `json:"recovery_bridge_replay_ready_nodes"` + WaitingRecoveryHeartbeatNodes int `json:"waiting_recovery_heartbeat_nodes"` +} + +type StaleNodeRiskNode struct { + NodeID string `json:"node_id"` + Name string `json:"name"` + RegistrationStatus string `json:"registration_status"` + HealthStatus string `json:"health_status"` + ReportedVersion *string `json:"reported_version,omitempty"` + LastSeenAt *time.Time `json:"last_seen_at,omitempty"` + HeartbeatStale bool `json:"heartbeat_stale"` + Blocked bool `json:"blocked"` + DirectPeerAlert bool `json:"direct_peer_alert"` + DirectPeerReadyCount int `json:"direct_peer_ready_count,omitempty"` + DirectPeerTargetCount int `json:"direct_peer_target_count,omitempty"` + DirectPeerDeficit int `json:"direct_peer_deficit,omitempty"` + Alerts []string `json:"alerts,omitempty"` + RecoveryBridgeRequired bool `json:"recovery_bridge_required"` + RecoveryBridgeReplayReady bool `json:"recovery_bridge_replay_ready"` + RecoveryBridgeActions []string `json:"recovery_bridge_actions,omitempty"` + Risks []string `json:"risks,omitempty"` + Products []StaleNodeRiskProduct `json:"products,omitempty"` +} + +type StaleNodeRiskProduct struct { + Product string `json:"product"` + CurrentVersion string `json:"current_version,omitempty"` + TargetVersion *string `json:"target_version,omitempty"` + Channel string `json:"channel,omitempty"` + Strategy string `json:"strategy,omitempty"` + Enabled bool `json:"enabled"` + DetectedOS string `json:"detected_os,omitempty"` + DetectedArch string `json:"detected_arch,omitempty"` + DetectedInstallType string `json:"detected_install_type,omitempty"` + CompatibleArtifactFound bool `json:"compatible_artifact_found"` + MatchingReleaseVersion string `json:"matching_release_version,omitempty"` + LastStatusObservedAt *time.Time `json:"last_status_observed_at,omitempty"` + LastStatusPhase string `json:"last_status_phase,omitempty"` + LastStatusValue string `json:"last_status_value,omitempty"` + LastStatusReason string `json:"last_status_reason,omitempty"` + RecoveryBridgeRequired bool `json:"recovery_bridge_required"` + RecoveryBridgeReplayReady bool `json:"recovery_bridge_replay_ready"` + RecoveryBridgeMode string `json:"recovery_bridge_mode,omitempty"` + Risks []string `json:"risks,omitempty"` +} + type NodeBootstrap struct { NodeID string `json:"node_id"` ClusterID string `json:"cluster_id"` @@ -761,23 +857,25 @@ type NodeSyntheticMeshConfig struct { } type NodeMeshListenerConfig struct { - SchemaVersion string `json:"schema_version"` - Source string `json:"source"` - DesiredState string `json:"desired_state"` - ListenAddr string `json:"listen_addr"` - ListenPortMode string `json:"listen_port_mode"` - AutoPortStart int `json:"auto_port_start,omitempty"` - AutoPortEnd int `json:"auto_port_end,omitempty"` - AdvertiseEndpoint string `json:"advertise_endpoint,omitempty"` - AdvertiseTransport string `json:"advertise_transport,omitempty"` - ConnectivityMode string `json:"connectivity_mode,omitempty"` - NATType string `json:"nat_type,omitempty"` - Region string `json:"region,omitempty"` - ConfigVersion string `json:"config_version,omitempty"` - UpdatedByUserID string `json:"updated_by_user_id,omitempty"` - UpdatedAt string `json:"updated_at,omitempty"` - ControlPlaneOnly bool `json:"control_plane_only"` - ProductionForwarding bool `json:"production_forwarding"` + SchemaVersion string `json:"schema_version"` + Source string `json:"source"` + DesiredState string `json:"desired_state"` + ListenAddr string `json:"listen_addr"` + ListenPortMode string `json:"listen_port_mode"` + AutoPortStart int `json:"auto_port_start,omitempty"` + AutoPortEnd int `json:"auto_port_end,omitempty"` + AdvertiseEndpoint string `json:"advertise_endpoint,omitempty"` + AdvertiseEndpoints []string `json:"advertise_endpoints,omitempty"` + EndpointCandidates []PeerEndpointCandidate `json:"endpoint_candidates,omitempty"` + AdvertiseTransport string `json:"advertise_transport,omitempty"` + ConnectivityMode string `json:"connectivity_mode,omitempty"` + NATType string `json:"nat_type,omitempty"` + Region string `json:"region,omitempty"` + ConfigVersion string `json:"config_version,omitempty"` + UpdatedByUserID string `json:"updated_by_user_id,omitempty"` + UpdatedAt string `json:"updated_at,omitempty"` + ControlPlaneOnly bool `json:"control_plane_only"` + ProductionForwarding bool `json:"production_forwarding"` } type MeshQoSPolicy struct { @@ -2027,6 +2125,17 @@ type GetNodeUpdatePlanInput struct { ArtifactOrigin string } +type GetStaleNodeRiskReportInput struct { + ActorUserID string + ClusterID string +} + +type GetNodeBridgeReplayPlanInput struct { + ActorUserID string + ClusterID string + NodeID string +} + type ReportNodeUpdateStatusInput struct { ClusterID string NodeID string diff --git a/backend/internal/modules/cluster/module.go b/backend/internal/modules/cluster/module.go index 69cf5c9..58e8ac4 100644 --- a/backend/internal/modules/cluster/module.go +++ b/backend/internal/modules/cluster/module.go @@ -84,8 +84,10 @@ func (m *Module) RegisterRoutes(router chi.Router) { r.Post("/{clusterID}/updates/releases", m.createReleaseVersion) r.Put("/{clusterID}/nodes/{nodeID}/updates/policy", m.upsertNodeUpdatePolicy) r.Get("/{clusterID}/nodes/{nodeID}/updates/plan", m.getNodeUpdatePlan) + r.Get("/{clusterID}/nodes/{nodeID}/updates/bridge-replay-plan", m.getNodeBridgeReplayPlan) r.Post("/{clusterID}/nodes/{nodeID}/updates/status", m.reportNodeUpdateStatus) r.Get("/{clusterID}/nodes/{nodeID}/updates/statuses", m.listNodeUpdateStatuses) + r.Get("/{clusterID}/updates/stale-node-risk-report", m.getStaleNodeRiskReport) r.Get("/{clusterID}/nodes/{nodeID}/testing-flags", m.getEffectiveNodeTestingFlags) r.Get("/{clusterID}/nodes/{nodeID}/mesh/synthetic-config", m.getNodeSyntheticMeshConfig) r.Post("/{clusterID}/nodes/{nodeID}/telemetry", m.recordNodeTelemetry) @@ -843,6 +845,29 @@ func (m *Module) listNodeUpdateStatuses(w http.ResponseWriter, r *http.Request) httpx.WriteJSON(w, http.StatusOK, map[string]any{"node_update_statuses": items}) } +func (m *Module) getStaleNodeRiskReport(w http.ResponseWriter, r *http.Request) { + item, err := m.service.GetStaleNodeRiskReport(r.Context(), GetStaleNodeRiskReportInput{ + ActorUserID: r.URL.Query().Get("actor_user_id"), + ClusterID: chi.URLParam(r, "clusterID"), + }) + if writeServiceError(w, err) { + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"stale_node_risk_report": item}) +} + +func (m *Module) getNodeBridgeReplayPlan(w http.ResponseWriter, r *http.Request) { + item, err := m.service.GetNodeBridgeReplayPlan(r.Context(), GetNodeBridgeReplayPlanInput{ + ActorUserID: r.URL.Query().Get("actor_user_id"), + ClusterID: chi.URLParam(r, "clusterID"), + NodeID: chi.URLParam(r, "nodeID"), + }) + if writeServiceError(w, err) { + return + } + httpx.WriteJSON(w, http.StatusOK, map[string]any{"node_bridge_replay_plan": item}) +} + func (m *Module) getEffectiveNodeTestingFlags(w http.ResponseWriter, r *http.Request) { item, err := m.service.GetEffectiveNodeTestingFlags(r.Context(), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID")) if writeServiceError(w, err) { @@ -3386,6 +3411,7 @@ func writeServiceError(w http.ResponseWriter, err error) bool { if err == nil { return false } + var legacyRemovalBlocked *LegacyRemovalBlockedError switch { case errors.Is(err, ErrAccessDenied): httpx.WriteError(w, http.StatusForbidden, err.Error()) @@ -3393,6 +3419,12 @@ func writeServiceError(w http.ResponseWriter, err error) bool { httpx.WriteError(w, http.StatusForbidden, err.Error()) case errors.Is(err, ErrClusterReadOnly): httpx.WriteError(w, http.StatusConflict, err.Error()) + case errors.As(err, &legacyRemovalBlocked): + httpx.WriteErrorMessage(w, http.StatusConflict, httpx.ErrorResponse{ + Error: httpx.NewErrorMessage(http.StatusConflict, err.Error(), legacyRemovalBlockedErrorDetails(*legacyRemovalBlocked), ""), + }) + case errors.Is(err, ErrLegacyRemovalBlocked): + httpx.WriteError(w, http.StatusConflict, err.Error()) case errors.Is(err, ErrVPNLeaseAlreadyActive): httpx.WriteError(w, http.StatusConflict, err.Error()) case errors.Is(err, ErrInvalidPayload), errors.Is(err, ErrInvalidJoinToken), errors.Is(err, ErrInvalidNodeRole): @@ -3404,3 +3436,37 @@ func writeServiceError(w http.ResponseWriter, err error) bool { } return true } + +func legacyRemovalBlockedErrorDetails(err LegacyRemovalBlockedError) map[string]any { + details := map[string]any{ + "blocked_operation": err.BlockedOperation, + "legacy_removal_allowed": err.Report.LegacyRemovalAllowed, + "bridge_hold_required": err.Report.BridgeHoldRequired, + "bridge_hold_reasons": err.Report.BridgeHoldReasons, + "blocked_operations": err.Report.BlockedOperations, + "heartbeat_stale_after_seconds": err.Report.HeartbeatStaleAfterSeconds, + "stale_nodes": err.Report.Summary.StaleNodes, + "blocked_nodes": err.Report.Summary.BlockedNodes, + "artifact_gap_nodes": err.Report.Summary.ArtifactGapNodes, + "unknown_profile_nodes": err.Report.Summary.UnknownProfileNodes, + "waiting_update_status_nodes": err.Report.Summary.WaitingUpdateStatusNodes, + "unknown_version_nodes": err.Report.Summary.UnknownVersionNodes, + "legacy_recovery_contract_nodes": err.Report.Summary.LegacyRecoveryContractNodes, + "recovery_bridge_required_nodes": err.Report.Summary.RecoveryBridgeRequiredNodes, + "recovery_bridge_replay_ready_nodes": err.Report.Summary.RecoveryBridgeReplayReadyNodes, + "waiting_recovery_heartbeat_nodes": err.Report.Summary.WaitingRecoveryHeartbeatNodes, + } + blockedNodeIDs := make([]string, 0, len(err.Report.Nodes)) + for _, node := range err.Report.Nodes { + if node.Blocked { + blockedNodeIDs = append(blockedNodeIDs, node.NodeID) + } + } + if len(blockedNodeIDs) > 0 { + details["blocked_node_ids"] = blockedNodeIDs + } + if len(err.Report.BridgeHoldNodeIDs) > 0 { + details["bridge_hold_node_ids"] = err.Report.BridgeHoldNodeIDs + } + return details +} diff --git a/backend/internal/modules/cluster/module_error_test.go b/backend/internal/modules/cluster/module_error_test.go new file mode 100644 index 0000000..bfcf131 --- /dev/null +++ b/backend/internal/modules/cluster/module_error_test.go @@ -0,0 +1,68 @@ +package cluster + +import ( + "encoding/json" + "net/http" + "net/http/httptest" + "testing" +) + +func TestWriteServiceErrorLegacyRemovalBlockedIncludesBreakdownDetails(t *testing.T) { + recorder := httptest.NewRecorder() + handled := writeServiceError(recorder, &LegacyRemovalBlockedError{ + BlockedOperation: "create_breaking_release", + Report: StaleNodeRiskReport{ + HeartbeatStaleAfterSeconds: 900, + LegacyRemovalAllowed: false, + BridgeHoldRequired: true, + BridgeHoldNodeIDs: []string{"node-1"}, + BridgeHoldReasons: []string{"legacy_contract_overlap"}, + BlockedOperations: []string{"create_breaking_release", "target_breaking_update_policy", "remove_recovery_bridge_overlap"}, + Nodes: []StaleNodeRiskNode{ + {NodeID: "node-1", Blocked: true, RecoveryBridgeRequired: true}, + {NodeID: "node-2", Blocked: false}, + }, + Summary: StaleNodeRiskSummary{ + StaleNodes: 1, + BlockedNodes: 1, + ArtifactGapNodes: 0, + UnknownProfileNodes: 0, + WaitingUpdateStatusNodes: 0, + UnknownVersionNodes: 0, + LegacyRecoveryContractNodes: 0, + WaitingRecoveryHeartbeatNodes: 1, + }, + }, + }) + if !handled { + t.Fatalf("writeServiceError returned false") + } + if recorder.Code != http.StatusConflict { + t.Fatalf("status = %d, want %d", recorder.Code, http.StatusConflict) + } + var payload struct { + Error struct { + Details map[string]any `json:"details"` + } `json:"error"` + } + if err := json.Unmarshal(recorder.Body.Bytes(), &payload); err != nil { + t.Fatalf("unmarshal response: %v", err) + } + if payload.Error.Details["blocked_operation"] != "create_breaking_release" { + t.Fatalf("blocked_operation = %v", payload.Error.Details["blocked_operation"]) + } + if payload.Error.Details["waiting_recovery_heartbeat_nodes"] != float64(1) { + t.Fatalf("waiting_recovery_heartbeat_nodes = %v", payload.Error.Details["waiting_recovery_heartbeat_nodes"]) + } + if payload.Error.Details["bridge_hold_required"] != true { + t.Fatalf("bridge_hold_required = %v", payload.Error.Details["bridge_hold_required"]) + } + blockedNodeIDs, ok := payload.Error.Details["blocked_node_ids"].([]any) + if !ok || len(blockedNodeIDs) != 1 || blockedNodeIDs[0] != "node-1" { + t.Fatalf("blocked_node_ids = %#v", payload.Error.Details["blocked_node_ids"]) + } + bridgeHoldNodeIDs, ok := payload.Error.Details["bridge_hold_node_ids"].([]any) + if !ok || len(bridgeHoldNodeIDs) != 1 || bridgeHoldNodeIDs[0] != "node-1" { + t.Fatalf("bridge_hold_node_ids = %#v", payload.Error.Details["bridge_hold_node_ids"]) + } +} diff --git a/backend/internal/modules/cluster/service.go b/backend/internal/modules/cluster/service.go index 7d5c878..f9ecd11 100644 --- a/backend/internal/modules/cluster/service.go +++ b/backend/internal/modules/cluster/service.go @@ -1,6 +1,7 @@ package cluster import ( + "bytes" "context" "crypto/rand" "crypto/sha256" @@ -16,6 +17,7 @@ import ( "time" "github.com/jackc/pgx/v5" + "github.com/jackc/pgx/v5/pgconn" "github.com/example/remote-access-platform/backend/internal/platform/clusterauth" ) @@ -28,6 +30,7 @@ var ( ErrInvalidCluster = errors.New("cluster not found") ErrInvalidJoinRequest = errors.New("join request not found") ErrClusterReadOnly = errors.New("cluster is not authoritative for policy mutation") + ErrLegacyRemovalBlocked = errors.New("legacy compatibility removal is blocked while stale recovery-risk nodes remain") ErrInvalidVPNConnection = errors.New("vpn connection not found") ErrInvalidVPNLease = errors.New("vpn connection lease not found") ErrVPNLeaseAlreadyActive = errors.New("vpn connection already has an active lease") @@ -35,6 +38,19 @@ var ( ErrVPNLeaseOwnerRoleRequired = errors.New("vpn lease owner requires active vpn-exit or vpn-connector role") ) +type LegacyRemovalBlockedError struct { + BlockedOperation string + Report StaleNodeRiskReport +} + +func (e *LegacyRemovalBlockedError) Error() string { + return ErrLegacyRemovalBlocked.Error() +} + +func (e *LegacyRemovalBlockedError) Is(target error) bool { + return target == ErrLegacyRemovalBlocked +} + type Service struct { store Repository now func() time.Time @@ -3256,8 +3272,8 @@ func fabricServiceChannelDataPlaneContractIncidentState(channel FabricServiceCha return "data_plane_contract_not_reported", "bad", "entry_node_accepted_service_channel_without_reporting_data_plane_contract" } workingTransport := firstNonEmptyString(channel.EntryNodeLastWorkingDataTransport, channel.DataPlane.WorkingDataTransport) - if workingTransport != "" && workingTransport != "fabric_service_channel" { - return "data_plane_working_transport_violation", "bad", "working_data_transport_must_be_fabric_service_channel" + if workingTransport != "" && workingTransport != "fabric_quic_route" { + return "data_plane_working_transport_violation", "bad", "working_data_transport_must_be_fabric_quic_route" } steadyTransport := firstNonEmptyString(channel.EntryNodeLastSteadyStateTransport, channel.DataPlane.SteadyStateTransport) if steadyTransport != "" && steadyTransport != "fabric_route" { @@ -3277,9 +3293,6 @@ func fabricServiceChannelDataPlaneContractIncidentState(channel FabricServiceCha if backendRelayPolicy == "disabled" && (channel.EntryNodeBackendFallbackCount > 0 || channel.ForceBackendFallback) { return "data_plane_disabled_backend_relay_observed", "bad", "backend_relay_policy_disabled_but_backend_fallback_was_observed" } - if backendRelayPolicy == "degraded_fallback_only" && channel.EntryNodeBackendFallbackCount > 0 { - return "data_plane_degraded_backend_relay_observed", "warn", "backend_relay_used_as_degraded_fallback_for_working_data" - } return "", "", "" } @@ -3634,6 +3647,22 @@ func (s *Service) CreateReleaseVersion(ctx context.Context, input CreateReleaseV if !json.Valid(input.Compatibility) { return ReleaseVersion{}, ErrInvalidPayload } + if releaseRequestsLegacyRemoval(input.Compatibility) { + report, err := s.GetStaleNodeRiskReport(ctx, GetStaleNodeRiskReportInput{ + ActorUserID: input.ActorUserID, + ClusterID: input.ClusterID, + }) + if err != nil { + return ReleaseVersion{}, err + } + if !report.LegacyRemovalAllowed { + s.recordLegacyRemovalBlockedAudit(ctx, input.ClusterID, input.ActorUserID, "release_version", input.Product+":"+input.Version, "create_breaking_release", report) + return ReleaseVersion{}, &LegacyRemovalBlockedError{ + BlockedOperation: "create_breaking_release", + Report: report, + } + } + } for i := range input.Artifacts { input.Artifacts[i].OS = normalizeUpdateToken(input.Artifacts[i].OS) input.Artifacts[i].Arch = normalizeUpdateToken(input.Artifacts[i].Arch) @@ -3700,6 +3729,31 @@ func (s *Service) UpsertNodeUpdatePolicy(ctx context.Context, input UpsertNodeUp trimmed := strings.TrimSpace(*input.TargetVersion) input.TargetVersion = &trimmed } + if input.TargetVersion != nil && *input.TargetVersion != "" { + releases, err := s.store.ListReleaseVersions(ctx, input.ClusterID, input.Product, input.Channel) + if err != nil { + return NodeUpdatePolicy{}, err + } + if !hasTargetedReleaseVersion(releases, *input.TargetVersion) { + return NodeUpdatePolicy{}, ErrInvalidPayload + } + if targetedReleaseRequestsLegacyRemoval(releases, *input.TargetVersion) { + report, err := s.GetStaleNodeRiskReport(ctx, GetStaleNodeRiskReportInput{ + ActorUserID: input.ActorUserID, + ClusterID: input.ClusterID, + }) + if err != nil { + return NodeUpdatePolicy{}, err + } + if !report.LegacyRemovalAllowed { + s.recordLegacyRemovalBlockedAudit(ctx, input.ClusterID, input.ActorUserID, "node_update_policy", input.NodeID, "target_breaking_update_policy", report) + return NodeUpdatePolicy{}, &LegacyRemovalBlockedError{ + BlockedOperation: "target_breaking_update_policy", + Report: report, + } + } + } + } item, err := s.store.UpsertNodeUpdatePolicy(ctx, input) if err != nil { return NodeUpdatePolicy{}, err @@ -3727,6 +3781,9 @@ func (s *Service) GetNodeUpdatePlan(ctx context.Context, input GetNodeUpdatePlan if input.ClusterID == "" || input.NodeID == "" || input.Product == "" || input.OS == "" || input.Arch == "" || input.InstallType == "" { return NodeUpdatePlan{}, ErrInvalidPayload } + if heartbeats, err := s.store.ListNodeHeartbeats(ctx, input.ClusterID, input.NodeID, 1); err == nil && len(heartbeats) > 0 { + input.ArtifactOrigin = preferredNodeArtifactOrigin(input.ArtifactOrigin, artifactOriginFromHeartbeat(heartbeats[0])) + } policy, err := s.store.GetNodeUpdatePolicy(ctx, input.ClusterID, input.NodeID, input.Product) if errors.Is(err, pgx.ErrNoRows) { return s.signNodeUpdatePlan(ctx, NodeUpdatePlan{ @@ -3820,6 +3877,195 @@ func (s *Service) ListNodeUpdateStatuses(ctx context.Context, actorUserID, clust return s.store.ListNodeUpdateStatuses(ctx, clusterID, nodeID, limit) } +func (s *Service) GetStaleNodeRiskReport(ctx context.Context, input GetStaleNodeRiskReportInput) (StaleNodeRiskReport, error) { + if err := s.ensurePlatformAdmin(ctx, input.ActorUserID); err != nil { + return StaleNodeRiskReport{}, err + } + if strings.TrimSpace(input.ClusterID) == "" { + return StaleNodeRiskReport{}, ErrInvalidPayload + } + now := s.now().UTC() + nodes, err := s.store.ListClusterNodes(ctx, input.ClusterID) + if err != nil { + return StaleNodeRiskReport{}, err + } + report := StaleNodeRiskReport{ + ClusterID: input.ClusterID, + GeneratedAt: now, + HeartbeatStaleAfterSeconds: int(staleNodeRiskHeartbeatThreshold / time.Second), + LegacyRemovalAllowed: true, + BridgeHoldNodeIDs: []string{}, + BridgeHoldReasons: []string{}, + Nodes: make([]StaleNodeRiskNode, 0, len(nodes)), + } + releaseCache := map[string][]ReleaseVersion{} + for _, node := range nodes { + item, err := s.evaluateStaleNodeRisk(ctx, input.ClusterID, node, now, releaseCache) + if err != nil { + return StaleNodeRiskReport{}, err + } + item.Blocked = len(item.Risks) > 0 + report.Nodes = append(report.Nodes, item) + report.Summary.TotalNodes++ + if item.HeartbeatStale { + report.Summary.StaleNodes++ + } + if item.Blocked { + report.Summary.BlockedNodes++ + report.LegacyRemovalAllowed = false + } + if item.DirectPeerAlert { + report.Summary.DirectPeerAlertNodes++ + } + if containsAnyRiskWithPrefix(item.Risks, "stale_node_no_compatible_") { + report.Summary.ArtifactGapNodes++ + continue + } + if containsAnyRiskWithPrefix(item.Risks, "stale_node_unknown_profile_") { + report.Summary.UnknownProfileNodes++ + continue + } + if containsAnyRiskWithPrefix(item.Risks, "stale_node_no_") && containsAnyRiskWithSuffix(item.Risks, "_update_status") { + report.Summary.WaitingUpdateStatusNodes++ + continue + } + if containsAnyRiskWithPrefix(item.Risks, "stale_node_unknown_") && containsAnyRiskWithSuffix(item.Risks, "_version") { + report.Summary.UnknownVersionNodes++ + continue + } + if containsAnyRiskWithPrefix(item.Risks, "stale_node_legacy_recovery_contract_") { + report.Summary.LegacyRecoveryContractNodes++ + if item.RecoveryBridgeRequired { + report.BridgeHoldRequired = true + report.BridgeHoldNodeIDs = append(report.BridgeHoldNodeIDs, item.NodeID) + report.Summary.RecoveryBridgeRequiredNodes++ + } + if item.RecoveryBridgeReplayReady { + report.Summary.RecoveryBridgeReplayReadyNodes++ + } + continue + } + if item.HeartbeatStale { + report.Summary.WaitingRecoveryHeartbeatNodes++ + } + } + sort.Slice(report.Nodes, func(i, j int) bool { + if report.Nodes[i].HeartbeatStale != report.Nodes[j].HeartbeatStale { + return report.Nodes[i].HeartbeatStale + } + if len(report.Nodes[i].Risks) != len(report.Nodes[j].Risks) { + return len(report.Nodes[i].Risks) > len(report.Nodes[j].Risks) + } + return report.Nodes[i].Name < report.Nodes[j].Name + }) + if !report.LegacyRemovalAllowed { + report.BlockedOperations = []string{ + "create_breaking_release", + "target_breaking_update_policy", + } + } + if report.BridgeHoldRequired { + report.BridgeHoldReasons = append(report.BridgeHoldReasons, "legacy_contract_overlap") + report.LegacyRemovalAllowed = false + for _, operation := range []string{ + "create_breaking_release", + "target_breaking_update_policy", + "remove_recovery_bridge_overlap", + } { + if !containsString(report.BlockedOperations, operation) { + report.BlockedOperations = append(report.BlockedOperations, operation) + } + } + } + return report, nil +} + +func (s *Service) GetNodeBridgeReplayPlan(ctx context.Context, input GetNodeBridgeReplayPlanInput) (NodeBridgeReplayPlan, error) { + if err := s.ensurePlatformAdmin(ctx, input.ActorUserID); err != nil { + return NodeBridgeReplayPlan{}, err + } + input.ClusterID = strings.TrimSpace(input.ClusterID) + input.NodeID = strings.TrimSpace(input.NodeID) + if input.ClusterID == "" || input.NodeID == "" { + return NodeBridgeReplayPlan{}, ErrInvalidPayload + } + nodes, err := s.store.ListClusterNodes(ctx, input.ClusterID) + if err != nil { + return NodeBridgeReplayPlan{}, err + } + var node *ClusterNode + for i := range nodes { + if strings.TrimSpace(nodes[i].ID) == input.NodeID { + node = &nodes[i] + break + } + } + if node == nil { + return NodeBridgeReplayPlan{}, ErrInvalidPayload + } + report, err := s.GetStaleNodeRiskReport(ctx, GetStaleNodeRiskReportInput{ + ActorUserID: input.ActorUserID, + ClusterID: input.ClusterID, + }) + if err != nil { + return NodeBridgeReplayPlan{}, err + } + var riskNode *StaleNodeRiskNode + for i := range report.Nodes { + if strings.TrimSpace(report.Nodes[i].NodeID) == input.NodeID { + riskNode = &report.Nodes[i] + break + } + } + if riskNode == nil { + return NodeBridgeReplayPlan{}, ErrInvalidPayload + } + artifactOrigin := "" + if heartbeats, err := s.store.ListNodeHeartbeats(ctx, input.ClusterID, input.NodeID, 1); err == nil && len(heartbeats) > 0 { + artifactOrigin = artifactOriginFromHeartbeat(heartbeats[0]) + } + plan := NodeBridgeReplayPlan{ + SchemaVersion: "rap.node_bridge_replay_plan.v1", + ClusterID: input.ClusterID, + NodeID: input.NodeID, + NodeName: riskNode.Name, + HealthStatus: riskNode.HealthStatus, + HeartbeatStale: riskNode.HeartbeatStale, + BridgeHoldRequired: riskNode.RecoveryBridgeRequired, + RecoveryBridgeReplayReady: riskNode.RecoveryBridgeReplayReady, + BridgeHoldReasons: append([]string{}, report.BridgeHoldReasons...), + BridgeActions: append([]string{}, riskNode.RecoveryBridgeActions...), + Products: []NodeBridgeReplayProductPlan{}, + } + for _, product := range riskNode.Products { + if !product.RecoveryBridgeReplayReady { + continue + } + updatePlan, err := s.GetNodeUpdatePlan(ctx, GetNodeUpdatePlanInput{ + ClusterID: input.ClusterID, + NodeID: input.NodeID, + Product: product.Product, + CurrentVersion: product.CurrentVersion, + OS: product.DetectedOS, + Arch: product.DetectedArch, + InstallType: product.DetectedInstallType, + Channel: product.Channel, + ArtifactOrigin: artifactOrigin, + }) + if err != nil { + return NodeBridgeReplayPlan{}, err + } + plan.Products = append(plan.Products, NodeBridgeReplayProductPlan{ + Product: product.Product, + RecoveryBridgeMode: product.RecoveryBridgeMode, + RecoveryBridgeReplayReady: product.RecoveryBridgeReplayReady, + LastStatusReason: product.LastStatusReason, + UpdatePlan: updatePlan, + }) + } + return plan, nil +} + func (s *Service) GetNodeUpdateHint(ctx context.Context, clusterID, nodeID string) NodeUpdateHint { products := []string{"rap-node-agent", "rap-host-agent"} parts := make([]string, 0, len(products)) @@ -7208,69 +7454,23 @@ func defaultFabricServiceQoS(serviceClass string) string { } func fabricServiceChannelHTTPIngress(serviceClass string) FabricServiceChannelHTTPIngress { - ingress := FabricServiceChannelHTTPIngress{ - Type: "entry_direct_http_v1", - TokenHeader: "X-RAP-Service-Channel-Token", - ServiceClassHeader: "X-RAP-Service-Class", - ChannelClassHeader: "X-RAP-Channel-Class", - SupportedMethods: []string{"POST", "GET", "WEBSOCKET"}, + return FabricServiceChannelHTTPIngress{ + Type: "fabric_quic_only", + SupportedMethods: []string{}, } - switch serviceClass { - case FabricServiceClassRemoteWorkspace: - ingress.PathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/remote-workspaces/{resource_id}/streams/{channel_class}" - ingress.WebSocketPathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/remote-workspaces/{resource_id}/streams/ws" - ingress.PacketBatchFormat = "application/vnd.rap.remote-workspace-frame-batch.v1" - case FabricServiceClassVideo: - ingress.PathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/video-sessions/{resource_id}/streams/{channel_class}" - ingress.WebSocketPathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/video-sessions/{resource_id}/streams/ws" - ingress.PacketBatchFormat = "application/vnd.rap.video-frame-batch.v1" - case FabricServiceClassFileTransfer: - ingress.PathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/file-transfers/{resource_id}/chunks" - ingress.WebSocketPathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/file-transfers/{resource_id}/chunks/ws" - ingress.PacketBatchFormat = "application/vnd.rap.file-transfer-chunk-batch.v1" - case FabricServiceClassPlatformAdmin: - ingress.PathTemplate = "/api/v1/fabric/service-channels/{channel_id}/platform-admin/{resource_id}/{channel_class}" - ingress.WebSocketPathTemplate = "/api/v1/fabric/service-channels/{channel_id}/platform-admin/{resource_id}/ws" - ingress.PacketBatchFormat = "application/vnd.rap.admin-request-batch.v1" - case FabricServiceClassClusterAdmin: - ingress.PathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/cluster-admin/{resource_id}/{channel_class}" - ingress.WebSocketPathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/cluster-admin/{resource_id}/ws" - ingress.PacketBatchFormat = "application/vnd.rap.admin-request-batch.v1" - case FabricServiceClassOrganization: - ingress.PathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/organizations/{resource_id}/{channel_class}" - ingress.WebSocketPathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/organizations/{resource_id}/ws" - ingress.PacketBatchFormat = "application/vnd.rap.organization-portal-request-batch.v1" - case FabricServiceClassUserPortal: - ingress.PathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/users/{resource_id}/{channel_class}" - ingress.WebSocketPathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/users/{resource_id}/ws" - ingress.PacketBatchFormat = "application/vnd.rap.user-portal-request-batch.v1" - default: - ingress.PathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/vpn-connections/{resource_id}/packets" - ingress.WebSocketPathTemplate = "/api/v1/clusters/{cluster_id}/fabric/service-channels/{channel_id}/vpn-connections/{resource_id}/packets/ws" - ingress.PacketBatchFormat = "application/vnd.rap.vpn-packet-batch.v1" - } - return ingress } func fabricServiceChannelDataPlaneContract(serviceClass string, poolPolicy FabricServiceChannelPoolPolicy, fallback FabricServiceChannelFallback) FabricServiceChannelDataPlaneContract { - backendRelayPolicy := "disabled" - if poolPolicy.BackendFallbackAllowed || fallback.Allowed || fallback.BackendRelay { - backendRelayPolicy = "degraded_fallback_only" - } entryFailover := firstNonEmptyString(poolPolicy.EntryFailover, "automatic") exitFailover := firstNonEmptyString(poolPolicy.ExitFailover, "automatic") routeRebuild := firstNonEmptyString(poolPolicy.RouteRebuild, "automatic") - mode := "fabric_primary" - if fallback.Active { - mode = "degraded_backend_fallback" - } return FabricServiceChannelDataPlaneContract{ SchemaVersion: "rap.fabric_service_channel_data_plane.v1", - Mode: mode, - ControlPlaneTransport: "backend_api", - WorkingDataTransport: "fabric_service_channel", + Mode: "fabric_quic_only", + ControlPlaneTransport: "fabric_control_quic", + WorkingDataTransport: "fabric_quic_route", SteadyStateTransport: "fabric_route", - BackendRelayPolicy: backendRelayPolicy, + BackendRelayPolicy: "disabled", ProductionForwardingRequired: true, ServiceNeutral: true, ProtocolAgnostic: true, @@ -7724,41 +7924,72 @@ func (s *Service) desiredMeshListenerEndpointConfig(ctx context.Context, cluster } if listener == nil || strings.TrimSpace(listener.DesiredState) != "enabled" || - strings.TrimSpace(listener.AdvertiseEndpoint) == "" { + (strings.TrimSpace(listener.AdvertiseEndpoint) == "" && len(listener.EndpointCandidates) == 0 && len(listener.AdvertiseEndpoints) == 0) { return "", nil, nil } - endpoint := strings.TrimRight(strings.TrimSpace(listener.AdvertiseEndpoint), "/") - if isUnusableLocalPeerEndpoint(endpoint) { - return "", nil, nil - } - transport := firstNonEmptyString(listener.AdvertiseTransport, "direct_http") + transport := firstNonEmptyString(listener.AdvertiseTransport, "direct_quic") connectivityMode := firstNonEmptyString(listener.ConnectivityMode, "direct") natType := firstNonEmptyString(listener.NATType, "unknown") - metadata, err := json.Marshal(map[string]any{ - "source": "desired_workload.mesh-listener", - "config_version": listener.ConfigVersion, - "listen_addr": listener.ListenAddr, - }) - if err != nil { + rawCandidates := append([]PeerEndpointCandidate{}, listener.EndpointCandidates...) + if len(rawCandidates) == 0 { + for idx, endpoint := range listener.AdvertiseEndpoints { + rawCandidates = append(rawCandidates, PeerEndpointCandidate{ + EndpointID: fmt.Sprintf("%s-desired-mesh-listener-%d", nodeID, idx+1), + Address: endpoint, + Priority: idx + 1, + }) + } + } + if strings.TrimSpace(listener.AdvertiseEndpoint) != "" { + rawCandidates = append([]PeerEndpointCandidate{{ + EndpointID: nodeID + "-desired-mesh-listener", + Address: listener.AdvertiseEndpoint, + Priority: priority, + }}, rawCandidates...) + } + candidates := make([]PeerEndpointCandidate, 0, len(rawCandidates)) + seen := map[string]struct{}{} + for idx, candidate := range rawCandidates { + endpoint := strings.TrimRight(strings.TrimSpace(candidate.Address), "/") + if endpoint == "" || isUnusableLocalPeerEndpoint(endpoint) { + continue + } + if _, ok := seen[endpoint]; ok { + continue + } + seen[endpoint] = struct{}{} + candidate.Address = endpoint + candidate.NodeID = nodeID + candidate.EndpointID = firstNonEmptyString(strings.TrimSpace(candidate.EndpointID), fmt.Sprintf("%s-desired-mesh-listener-%d", nodeID, idx+1)) + candidate.Transport = firstNonEmptyString(strings.TrimSpace(candidate.Transport), transport) + candidate.ConnectivityMode = firstNonEmptyString(strings.TrimSpace(candidate.ConnectivityMode), connectivityMode) + candidate.Reachability = firstNonEmptyString(strings.TrimSpace(candidate.Reachability), reachabilityFromConnectivityMode(candidate.ConnectivityMode)) + candidate.NATType = firstNonEmptyString(strings.TrimSpace(candidate.NATType), natType) + candidate.Region = firstNonEmptyString(strings.TrimSpace(candidate.Region), listener.Region) + if candidate.Priority <= 0 { + candidate.Priority = priority + idx + } + candidate.PolicyTags = appendUniqueStrings(candidate.PolicyTags, "operator-configured", "desired-mesh-listener") + if len(candidate.Metadata) == 0 || !json.Valid(candidate.Metadata) { + metadata, err := json.Marshal(map[string]any{ + "source": "desired_workload.mesh-listener", + "config_version": listener.ConfigVersion, + "listen_addr": listener.ListenAddr, + }) + if err != nil { + return "", nil, err + } + candidate.Metadata = metadata + } + candidates = append(candidates, candidate) + } + if len(candidates) == 0 { + return "", nil, nil + } + if err := validatePeerEndpointCandidates(map[string][]PeerEndpointCandidate{nodeID: candidates}, []string{nodeID}); err != nil { return "", nil, err } - candidate := PeerEndpointCandidate{ - EndpointID: nodeID + "-desired-mesh-listener", - NodeID: nodeID, - Transport: transport, - Address: endpoint, - Reachability: reachabilityFromConnectivityMode(connectivityMode), - NATType: natType, - ConnectivityMode: connectivityMode, - Region: listener.Region, - Priority: priority, - PolicyTags: []string{"operator-configured", "desired-mesh-listener"}, - Metadata: metadata, - } - if err := validatePeerEndpointCandidates(map[string][]PeerEndpointCandidate{nodeID: []PeerEndpointCandidate{candidate}}, []string{nodeID}); err != nil { - return "", nil, err - } - return endpoint, []PeerEndpointCandidate{candidate}, nil + return candidates[0].Address, candidates, nil } func nodeMeshListenerConfigFromDesired(workload NodeWorkloadDesiredState) (*NodeMeshListenerConfig, error) { @@ -7806,6 +8037,37 @@ func nodeMeshListenerConfigFromDesired(workload NodeWorkloadDesiredState) (*Node } return false } + stringSliceValue := func(key string) []string { + if raw == nil { + return nil + } + values, ok := raw[key].([]any) + if !ok { + return nil + } + out := make([]string, 0, len(values)) + for _, value := range values { + text := strings.TrimRight(strings.TrimSpace(fmt.Sprint(value)), "/") + if text != "" { + out = append(out, text) + } + } + return out + } + endpointCandidatesValue := func(key string) ([]PeerEndpointCandidate, error) { + if raw == nil || raw[key] == nil { + return nil, nil + } + data, err := json.Marshal(raw[key]) + if err != nil { + return nil, ErrInvalidPayload + } + var candidates []PeerEndpointCandidate + if err := json.Unmarshal(data, &candidates); err != nil { + return nil, ErrInvalidPayload + } + return candidates, nil + } mode := strings.ToLower(value("listen_port_mode")) if workload.DesiredState != "enabled" { mode = "disabled" @@ -7819,9 +8081,6 @@ func nodeMeshListenerConfigFromDesired(workload NodeWorkloadDesiredState) (*Node return nil, ErrInvalidPayload } listenAddr := value("listen_addr") - if listenAddr == "" && mode != "disabled" { - listenAddr = ":19131" - } start := intValue("auto_port_start") end := intValue("auto_port_end") if start <= 0 { @@ -7833,6 +8092,10 @@ func nodeMeshListenerConfigFromDesired(workload NodeWorkloadDesiredState) (*Node if start > end { return nil, ErrInvalidPayload } + endpointCandidates, err := endpointCandidatesValue("endpoint_candidates") + if err != nil { + return nil, err + } productionForwarding := boolValue("production_forwarding") || boolValue("production_forwarding_enabled") return &NodeMeshListenerConfig{ SchemaVersion: "c17z23.mesh_listener_config.v1", @@ -7843,6 +8106,8 @@ func nodeMeshListenerConfigFromDesired(workload NodeWorkloadDesiredState) (*Node AutoPortStart: start, AutoPortEnd: end, AdvertiseEndpoint: strings.TrimRight(value("advertise_endpoint"), "/"), + AdvertiseEndpoints: stringSliceValue("advertise_endpoints"), + EndpointCandidates: endpointCandidates, AdvertiseTransport: value("advertise_transport"), ConnectivityMode: value("connectivity_mode"), NATType: value("nat_type"), @@ -8050,6 +8315,9 @@ func (s *Service) SetDesiredWorkload(ctx context.Context, input SetDesiredWorklo if input.RuntimeMode == "" { input.RuntimeMode = "container" } + if !isSupportedWorkloadRuntimeMode(input.RuntimeMode) { + return NodeWorkloadDesiredState{}, ErrInvalidPayload + } input.Config = defaultJSON(input.Config, `{}`) input.Environment = defaultJSON(input.Environment, `{}`) if !json.Valid(input.Config) || !json.Valid(input.Environment) { @@ -8095,6 +8363,9 @@ func (s *Service) ReportWorkloadStatus(ctx context.Context, input ReportWorkload if input.RuntimeMode == "" { input.RuntimeMode = "container" } + if !isSupportedWorkloadRuntimeMode(input.RuntimeMode) { + return NodeWorkloadStatus{}, ErrInvalidPayload + } input.StatusPayload = defaultJSON(input.StatusPayload, `{}`) if !json.Valid(input.StatusPayload) { return NodeWorkloadStatus{}, errors.New("status_payload must be valid json") @@ -8102,6 +8373,15 @@ func (s *Service) ReportWorkloadStatus(ctx context.Context, input ReportWorkload return s.store.ReportWorkloadStatus(ctx, input) } +func isSupportedWorkloadRuntimeMode(mode string) bool { + switch strings.TrimSpace(mode) { + case "native", "container": + return true + default: + return false + } +} + func (s *Service) ListLatestWorkloadStatuses(ctx context.Context, actorUserID, clusterID, nodeID string) ([]NodeWorkloadStatus, error) { if err := s.ensurePlatformAdmin(ctx, actorUserID); err != nil { return nil, err @@ -9288,12 +9568,8 @@ func enrichVPNDataplaneSession(profile VPNClientProfile, connection VPNClientCon } else if route.Status == "planned" && route.SelectedEntryNodeID == "" && route.SelectedExitNodeID != "" { status = "ready_for_mesh_node_route" } - preferredTransport := "fabric_service_channel_v1" - nodeValidation := "entry_node_calls_control_plane_introspection" - if route.SelectedEntryNodeID == "" { - preferredTransport = "fabric_mesh_node_route_v1" - nodeValidation = "vpn_client_node_identity_and_policy" - } + preferredTransport := "fabric_mesh_node_route_v1" + nodeValidation := "vpn_client_node_identity_and_policy" cfg["vpn_dataplane_session"] = map[string]any{ "schema_version": "rap.vpn_dataplane_session.v1", "session_id": sessionID, @@ -9526,7 +9802,7 @@ func vpnDataplaneEntryCandidates(route vpnClientFabricRoute, connection VPNClien } enriched["status"] = status enriched["endpoint_source"] = "node_latest_heartbeat.mesh_endpoint_report" - enriched["transports"] = []string{"entry_direct_http_v1", "fabric_packet_quic_v1", "fabric_packet_tcp_v1"} + enriched["transports"] = []string{"fabric_quic_route_v1"} out = append(out, enriched) } for _, nodeID := range ids { @@ -9543,7 +9819,7 @@ func vpnDataplaneEntryCandidates(route vpnClientFabricRoute, connection VPNClien out = append(out, map[string]any{ "node_id": nodeID, "status": status, - "transports": []string{"fabric_packet_quic_v1", "fabric_packet_tcp_v1"}, + "transports": []string{"fabric_quic_route_v1"}, "endpoint_source": "node_mesh_advertisement_pending", }) } @@ -9583,16 +9859,10 @@ func vpnConcreteExitCandidatesFromClientConfig(cfg map[string]any) []map[string] } func vpnDataplaneTransportCandidates(route vpnClientFabricRoute, entryCandidates []map[string]any, exitCandidates []map[string]any) []map[string]any { - transportType := "fabric_service_channel_v1" - transportStatus := "contract_ready_listener_pending" - if route.SelectedEntryNodeID == "" { - transportType = "fabric_mesh_node_route_v1" - transportStatus = "contract_ready_client_node_mesh_required" - } candidates := []map[string]any{ { - "type": transportType, - "status": transportStatus, + "type": "fabric_mesh_node_route_v1", + "status": "contract_ready_quic_fabric_route_required", "entry_node_id": route.SelectedEntryNodeID, "exit_node_id": route.SelectedExitNodeID, "route_authority": "fabric_farm", @@ -9602,60 +9872,11 @@ func vpnDataplaneTransportCandidates(route vpnClientFabricRoute, entryCandidates "application_protocols": []string{"ip"}, }, } - if direct := vpnDirectHTTPEntryTransportCandidate(route, entryCandidates); direct != nil { - candidates = append(candidates, direct) - } return candidates } func vpnDirectHTTPEntryTransportCandidate(route vpnClientFabricRoute, entryCandidates []map[string]any) map[string]any { - var selected []map[string]any - hasPublic := false - hasHTTP := false - for _, candidate := range entryCandidates { - nodeID, _ := candidate["node_id"].(string) - if route.SelectedEntryNodeID != "" && nodeID != route.SelectedEntryNodeID { - continue - } - apiBaseURL, _ := candidate["api_base_url"].(string) - address, _ := candidate["address"].(string) - if apiBaseURL == "" && (strings.HasPrefix(address, "http://") || strings.HasPrefix(address, "https://")) { - apiBaseURL = strings.TrimRight(address, "/") + "/api/v1" - candidate["api_base_url"] = apiBaseURL - } - if apiBaseURL == "" { - continue - } - hasHTTP = true - reachability, _ := candidate["reachability"].(string) - if strings.EqualFold(reachability, "public") { - hasPublic = true - } - selected = append(selected, candidate) - } - if len(selected) == 0 { - return nil - } - status := "reported_private_or_unverified" - if hasPublic { - status = "available" - } else if hasHTTP { - status = "http_endpoint_reported_unverified" - } - safeClientSwitch := hasPublic - if route.SelectedEntryNodeID != "" && route.SelectedEntryNodeID == route.SelectedExitNodeID { - status = "available_farm_local_route" - safeClientSwitch = hasPublic - } - return map[string]any{ - "type": "entry_direct_http_v1", - "status": status, - "entry_node_id": route.SelectedEntryNodeID, - "exit_node_id": route.SelectedExitNodeID, - "entry_candidates": selected, - "application_protocols": []string{"ip"}, - "safe_client_switch": safeClientSwitch, - } + return nil } func uuidLikeRandom() string { @@ -10044,6 +10265,9 @@ func (s *Service) ensurePlatformAdmin(ctx context.Context, userID string) error } role, err := s.store.GetPlatformRole(ctx, userID) if err != nil { + if errors.Is(err, pgx.ErrNoRows) || isInvalidUUIDTextError(err) { + return ErrAccessDenied + } return err } if !isPlatformAdminRole(role) { @@ -10059,6 +10283,9 @@ func (s *Service) ensurePlatformRecoveryAdmin(ctx context.Context, userID string } role, err := s.store.GetPlatformRole(ctx, userID) if err != nil { + if errors.Is(err, pgx.ErrNoRows) || isInvalidUUIDTextError(err) { + return ErrAccessDenied + } return err } if role != PlatformRoleRecoveryAdmin { @@ -10068,8 +10295,15 @@ func (s *Service) ensurePlatformRecoveryAdmin(ctx context.Context, userID string } func (s *Service) ensureClusterMutable(ctx context.Context, actorUserID, clusterID string) error { - role, err := s.store.GetPlatformRole(ctx, strings.TrimSpace(actorUserID)) + actorUserID = strings.TrimSpace(actorUserID) + if actorUserID == "" { + return ErrAccessDenied + } + role, err := s.store.GetPlatformRole(ctx, actorUserID) if err != nil { + if errors.Is(err, pgx.ErrNoRows) || isInvalidUUIDTextError(err) { + return ErrAccessDenied + } return err } if role == PlatformRoleRecoveryAdmin { @@ -10088,6 +10322,11 @@ func (s *Service) ensureClusterMutable(ctx context.Context, actorUserID, cluster return nil } +func isInvalidUUIDTextError(err error) bool { + var pgErr *pgconn.PgError + return errors.As(err, &pgErr) && pgErr.Code == "22P02" +} + func (s *Service) ensureVPNLeaseOwnerEligible(ctx context.Context, clusterID, vpnConnectionID, ownerNodeID string) error { eligibility, err := s.store.CheckVPNLeaseOwnerEligibility(ctx, clusterID, vpnConnectionID, ownerNodeID) if errors.Is(err, pgx.ErrNoRows) { @@ -10197,6 +10436,7 @@ type dockerInstallProfileScope struct { BackendURL string `json:"backend_url"` ControlPlaneEndpoints []string `json:"control_plane_endpoints"` ArtifactEndpoints []string `json:"artifact_endpoints"` + FabricRegistryRecords json.RawMessage `json:"fabric_registry_records"` DockerImageArtifactURLs []string `json:"docker_image_artifact_urls"` DockerImageArtifactSHA256 string `json:"docker_image_artifact_sha256"` DockerImageArtifactSizeBytes int64 `json:"docker_image_artifact_size_bytes"` @@ -10256,6 +10496,7 @@ func dockerInstallProfileFromScope(input DockerInstallProfileRequest, scopeRaw j BackendURL: strings.TrimRight(strings.TrimSpace(scope.BackendURL), "/"), ControlPlaneEndpoints: trimStringSlice(scope.ControlPlaneEndpoints), ArtifactEndpoints: trimEndpointSlice(scope.ArtifactEndpoints), + FabricRegistryRecords: cloneRawJSON(scope.FabricRegistryRecords), Roles: roles, NodeName: nodeName, Image: firstNonEmptyString(scope.Image, "rap-node-agent:latest"), @@ -10267,9 +10508,9 @@ func dockerInstallProfileFromScope(input DockerInstallProfileRequest, scopeRaw j Replace: boolPtrValue(scope.Replace, true), DockerVPNGatewayEnabled: boolPtrValue(scope.DockerVPNGatewayEnabled, containsString(roles, "vpn-exit")), WorkloadSupervisionEnabled: boolPtrValue(scope.WorkloadSupervisionEnabled, false), - MeshSyntheticRuntimeEnabled: boolPtrValue(scope.MeshSyntheticRuntimeEnabled, true), + MeshSyntheticRuntimeEnabled: boolPtrValue(scope.MeshSyntheticRuntimeEnabled, false), MeshProductionForwardingEnabled: boolPtrValue(scope.MeshProductionForwardingEnabled, false), - MeshListenAddr: firstNonEmptyString(scope.MeshListenAddr, ":19131"), + MeshListenAddr: strings.TrimSpace(scope.MeshListenAddr), MeshListenPortMode: firstNonEmptyString(strings.ToLower(strings.TrimSpace(scope.MeshListenPortMode)), "auto"), MeshListenAutoPortStart: positiveOrDefault(scope.MeshListenAutoPortStart, 19131), MeshListenAutoPortEnd: positiveOrDefault(scope.MeshListenAutoPortEnd, 19231), @@ -10300,6 +10541,9 @@ func dockerInstallProfileFromScope(input DockerInstallProfileRequest, scopeRaw j if len(profile.MeshAdvertiseEndpointsJSON) > 0 && !json.Valid(profile.MeshAdvertiseEndpointsJSON) { return DockerInstallProfile{}, ErrInvalidPayload } + if !isOptionalJSONArray(profile.FabricRegistryRecords) { + return DockerInstallProfile{}, ErrInvalidPayload + } switch profile.MeshListenPortMode { case "manual", "auto", "disabled": default: @@ -10330,15 +10574,16 @@ func windowsInstallProfileFromScope(input DockerInstallProfileRequest, scopeRaw BackendURL: strings.TrimRight(strings.TrimSpace(scope.BackendURL), "/"), ControlPlaneEndpoints: trimStringSlice(scope.ControlPlaneEndpoints), ArtifactEndpoints: trimEndpointSlice(scope.ArtifactEndpoints), + FabricRegistryRecords: cloneRawJSON(scope.FabricRegistryRecords), Roles: trimStringSlice(scope.Roles), NodeName: nodeName, StateDir: firstNonEmptyString(scope.StateDir, `C:\ProgramData\RAP\nodes\`+safeInstallProfileSlug(nodeName)), InstallDir: firstNonEmptyString(scope.InstallDir, `C:\Program Files\RAP\`+safeInstallProfileSlug(nodeName)), StartupMode: firstNonEmptyString(strings.ToLower(strings.TrimSpace(scope.StartupMode)), "auto"), WorkloadSupervisionEnabled: boolPtrValue(scope.WorkloadSupervisionEnabled, false), - MeshSyntheticRuntimeEnabled: boolPtrValue(scope.MeshSyntheticRuntimeEnabled, true), + MeshSyntheticRuntimeEnabled: boolPtrValue(scope.MeshSyntheticRuntimeEnabled, false), MeshProductionForwardingEnabled: boolPtrValue(scope.MeshProductionForwardingEnabled, false), - MeshListenAddr: firstNonEmptyString(scope.MeshListenAddr, ":19131"), + MeshListenAddr: strings.TrimSpace(scope.MeshListenAddr), MeshListenPortMode: firstNonEmptyString(strings.ToLower(strings.TrimSpace(scope.MeshListenPortMode)), "auto"), MeshListenAutoPortStart: positiveOrDefault(scope.MeshListenAutoPortStart, 19131), MeshListenAutoPortEnd: positiveOrDefault(scope.MeshListenAutoPortEnd, 19231), @@ -10369,6 +10614,9 @@ func windowsInstallProfileFromScope(input DockerInstallProfileRequest, scopeRaw if len(profile.MeshAdvertiseEndpointsJSON) > 0 && !json.Valid(profile.MeshAdvertiseEndpointsJSON) { return WindowsInstallProfile{}, ErrInvalidPayload } + if !isOptionalJSONArray(profile.FabricRegistryRecords) { + return WindowsInstallProfile{}, ErrInvalidPayload + } switch profile.MeshListenPortMode { case "manual", "auto", "disabled": default: @@ -10405,15 +10653,16 @@ func linuxInstallProfileFromScope(input DockerInstallProfileRequest, scopeRaw js BackendURL: strings.TrimRight(strings.TrimSpace(scope.BackendURL), "/"), ControlPlaneEndpoints: trimStringSlice(scope.ControlPlaneEndpoints), ArtifactEndpoints: trimEndpointSlice(scope.ArtifactEndpoints), + FabricRegistryRecords: cloneRawJSON(scope.FabricRegistryRecords), Roles: trimStringSlice(scope.Roles), NodeName: nodeName, StateDir: firstNonEmptyString(scope.StateDir, "/var/lib/rap/nodes/"+slug), InstallDir: firstNonEmptyString(scope.InstallDir, "/opt/rap/"+slug), StartupMode: firstNonEmptyString(strings.ToLower(strings.TrimSpace(scope.StartupMode)), "systemd"), WorkloadSupervisionEnabled: boolPtrValue(scope.WorkloadSupervisionEnabled, false), - MeshSyntheticRuntimeEnabled: boolPtrValue(scope.MeshSyntheticRuntimeEnabled, true), + MeshSyntheticRuntimeEnabled: boolPtrValue(scope.MeshSyntheticRuntimeEnabled, false), MeshProductionForwardingEnabled: boolPtrValue(scope.MeshProductionForwardingEnabled, false), - MeshListenAddr: firstNonEmptyString(scope.MeshListenAddr, ":19131"), + MeshListenAddr: strings.TrimSpace(scope.MeshListenAddr), MeshListenPortMode: firstNonEmptyString(strings.ToLower(strings.TrimSpace(scope.MeshListenPortMode)), "auto"), MeshListenAutoPortStart: positiveOrDefault(scope.MeshListenAutoPortStart, 19131), MeshListenAutoPortEnd: positiveOrDefault(scope.MeshListenAutoPortEnd, 19231), @@ -10444,6 +10693,9 @@ func linuxInstallProfileFromScope(input DockerInstallProfileRequest, scopeRaw js if len(profile.MeshAdvertiseEndpointsJSON) > 0 && !json.Valid(profile.MeshAdvertiseEndpointsJSON) { return LinuxInstallProfile{}, ErrInvalidPayload } + if !isOptionalJSONArray(profile.FabricRegistryRecords) { + return LinuxInstallProfile{}, ErrInvalidPayload + } switch profile.MeshListenPortMode { case "manual", "auto", "disabled": default: @@ -10547,16 +10799,45 @@ func defaultArtifactEndpointFromBackendURL(backendURL string) string { } type heartbeatMeshEndpointReport struct { - SchemaVersion string `json:"schema_version"` - ClusterID string `json:"cluster_id"` - NodeID string `json:"node_id"` - PeerEndpoint string `json:"peer_endpoint"` - Transport string `json:"transport"` - ConnectivityMode string `json:"connectivity_mode"` - NATType string `json:"nat_type"` - Region string `json:"region"` - EndpointCandidates []PeerEndpointCandidate `json:"endpoint_candidates"` - ObservedAt *time.Time `json:"observed_at"` + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + NodeID string `json:"node_id"` + PeerEndpoint string `json:"peer_endpoint"` + Transport string `json:"transport"` + ConnectivityMode string `json:"connectivity_mode"` + NATType string `json:"nat_type"` + Region string `json:"region"` + PeerRecoveryHealthy bool `json:"peer_recovery_healthy"` + PeerRecoveryReady int `json:"peer_recovery_ready"` + PeerRecoveryDeficit int `json:"peer_recovery_deficit"` + EndpointCandidates []PeerEndpointCandidate `json:"endpoint_candidates"` + ObservedAt *time.Time `json:"observed_at"` +} + +type heartbeatMeshPeerRecoveryReport struct { + TargetReadyPeers int `json:"target_ready_peers"` +} + +type heartbeatMeshPeerConnectionIntentReport struct { + DirectCount int `json:"direct_count"` +} + +type heartbeatMeshPeerConnectionManagerReport struct { + ProbeResults []heartbeatMeshPeerConnectionProbeResult `json:"probe_results"` + PeerConnectionReady int `json:"peer_connection_ready"` +} + +type heartbeatMeshPeerConnectionProbeResult struct { + NodeID string `json:"node_id"` + LinkStatus string `json:"link_status"` + TransportMode string `json:"transport_mode"` + DirectCandidate bool `json:"direct_candidate"` + CandidateResults []heartbeatMeshPeerConnectionCandidate `json:"candidate_results"` +} + +type heartbeatMeshPeerConnectionCandidate struct { + Transport string `json:"transport"` + LinkStatus string `json:"link_status"` } type heartbeatRendezvousLeaseReport struct { @@ -10913,6 +11194,9 @@ func enrichPeerEndpointCandidateCertPins(candidates []PeerEndpointCandidate) []P } if certSHA256 := peerEndpointCandidateTLSCertSHA256(candidate); certSHA256 != "" { certByEndpoint[endpoint] = certSHA256 + if hostPort := peerEndpointHostPort(endpoint); hostPort != "" { + certByEndpoint[hostPort] = certSHA256 + } } } if len(certByEndpoint) == 0 { @@ -10925,6 +11209,12 @@ func enrichPeerEndpointCandidateCertPins(candidates []PeerEndpointCandidate) []P } endpoint := strings.TrimRight(strings.TrimSpace(out[i].Address), "/") certSHA256 := certByEndpoint[endpoint] + if certSHA256 == "" { + certSHA256 = certByEndpoint[peerEndpointHostPort(endpoint)] + } + if certSHA256 == "" { + certSHA256 = certByEndpoint[peerEndpointCandidateMapsTo(out[i])] + } if certSHA256 == "" { continue } @@ -10933,6 +11223,30 @@ func enrichPeerEndpointCandidateCertPins(candidates []PeerEndpointCandidate) []P return out } +func peerEndpointHostPort(endpoint string) string { + trimmed := strings.TrimSpace(endpoint) + if trimmed == "" { + return "" + } + if parsed, err := url.Parse(trimmed); err == nil && parsed.Host != "" { + return strings.TrimSpace(parsed.Host) + } + return strings.TrimSpace(peerEndpointHost(trimmed)) +} + +func peerEndpointCandidateMapsTo(candidate PeerEndpointCandidate) string { + if len(candidate.Metadata) == 0 || !json.Valid(candidate.Metadata) { + return "" + } + var values struct { + MapsTo string `json:"maps_to,omitempty"` + } + if err := json.Unmarshal(candidate.Metadata, &values); err != nil { + return "" + } + return strings.TrimSpace(values.MapsTo) +} + func peerEndpointCandidateMetadataWithCert(raw json.RawMessage, certSHA256 string) json.RawMessage { certSHA256 = strings.TrimSpace(certSHA256) if certSHA256 == "" { @@ -11258,7 +11572,7 @@ func recoverySeedFromEndpointReport(nodeID, endpoint string, candidates []PeerEn seed := PeerRecoverySeed{ NodeID: nodeID, Endpoint: endpoint, - Transport: "direct_http", + Transport: "direct_quic", Priority: 10 + index, Metadata: json.RawMessage(`{"source":"core_mesh_bootstrap"}`), } @@ -11292,6 +11606,431 @@ func firstNonEmptyString(values ...string) string { return "" } +const staleNodeRiskHeartbeatThreshold = 15 * time.Minute + +func (s *Service) evaluateStaleNodeRisk(ctx context.Context, clusterID string, node ClusterNode, now time.Time, releaseCache map[string][]ReleaseVersion) (StaleNodeRiskNode, error) { + item := StaleNodeRiskNode{ + NodeID: node.ID, + Name: node.Name, + RegistrationStatus: node.RegistrationStatus, + HealthStatus: node.HealthStatus, + ReportedVersion: node.ReportedVersion, + LastSeenAt: node.LastSeenAt, + RecoveryBridgeActions: []string{}, + Products: []StaleNodeRiskProduct{}, + } + if heartbeats, err := s.store.ListNodeHeartbeats(ctx, clusterID, node.ID, 1); err == nil && len(heartbeats) > 0 { + readyCount, targetCount, deficit, alert := directPeerRecoveryFromHeartbeat(heartbeats[0]) + item.DirectPeerReadyCount = readyCount + item.DirectPeerTargetCount = targetCount + item.DirectPeerDeficit = deficit + item.DirectPeerAlert = alert + if alert { + item.Alerts = append(item.Alerts, fmt.Sprintf("direct_peer_deficit:%d_of_%d", readyCount, targetCount)) + } + } + if node.LastSeenAt == nil || now.Sub(node.LastSeenAt.UTC()) > staleNodeRiskHeartbeatThreshold { + item.HeartbeatStale = true + item.Risks = append(item.Risks, "stale_heartbeat") + } + products := []string{"rap-node-agent", "rap-host-agent"} + statuses, err := s.store.ListNodeUpdateStatuses(ctx, clusterID, node.ID, 50) + if err != nil { + return StaleNodeRiskNode{}, err + } + for _, product := range products { + policy, err := s.store.GetNodeUpdatePolicy(ctx, clusterID, node.ID, product) + if errors.Is(err, pgx.ErrNoRows) { + continue + } + if err != nil { + return StaleNodeRiskNode{}, err + } + productRisk, err := s.evaluateStaleNodeRiskProduct(ctx, clusterID, node, nodeNeedsRecoveryHold(node, item.HeartbeatStale), product, policy, statuses, releaseCache) + if err != nil { + return StaleNodeRiskNode{}, err + } + if len(productRisk.Risks) > 0 { + item.Risks = append(item.Risks, productRisk.Risks...) + } + if productRisk.RecoveryBridgeRequired { + item.RecoveryBridgeRequired = true + item.RecoveryBridgeActions = append(item.RecoveryBridgeActions, "preserve_compatibility_overlap") + item.RecoveryBridgeActions = append(item.RecoveryBridgeActions, "preserve_install_type_aliases") + } + if productRisk.RecoveryBridgeReplayReady { + item.RecoveryBridgeReplayReady = true + item.RecoveryBridgeActions = append(item.RecoveryBridgeActions, "replay_legacy_update_plan_"+product) + } + item.Products = append(item.Products, productRisk) + } + item.RecoveryBridgeActions = trimStringSlice(item.RecoveryBridgeActions) + item.Alerts = trimStringSlice(item.Alerts) + item.Risks = trimStringSlice(item.Risks) + return item, nil +} + +func directPeerRecoveryFromHeartbeat(heartbeat NodeHeartbeat) (readyCount int, targetCount int, deficit int, alert bool) { + if len(heartbeat.Metadata) == 0 || !json.Valid(heartbeat.Metadata) { + return 0, 3, 3, true + } + var metadata struct { + MeshEndpointReport heartbeatMeshEndpointReport `json:"mesh_endpoint_report"` + MeshPeerRecoveryReport heartbeatMeshPeerRecoveryReport `json:"mesh_peer_recovery_report"` + MeshPeerConnectionIntentReport heartbeatMeshPeerConnectionIntentReport `json:"mesh_peer_connection_intent_report"` + MeshPeerConnectionManagerReport heartbeatMeshPeerConnectionManagerReport `json:"mesh_peer_connection_manager_report"` + } + if err := json.Unmarshal(heartbeat.Metadata, &metadata); err != nil { + return 0, 3, 3, true + } + readyCount = directPeerReadyCountFromManager(metadata.MeshPeerConnectionManagerReport) + if readyCount <= 0 { + readyCount = metadata.MeshPeerConnectionIntentReport.DirectCount + } + if readyCount <= 0 { + readyCount = intFromCandidatesDirectCount(metadata.MeshEndpointReport.EndpointCandidates) + } + if readyCount <= 0 { + readyCount = metadata.MeshEndpointReport.PeerRecoveryReady + } + targetCount = metadata.MeshPeerRecoveryReport.TargetReadyPeers + if targetCount <= 0 { + targetCount = 3 + } + deficit = metadata.MeshEndpointReport.PeerRecoveryDeficit + if deficit <= 0 && readyCount < targetCount { + deficit = targetCount - readyCount + } + if readyCount < 0 { + readyCount = 0 + } + if deficit < 0 { + deficit = 0 + } + alert = readyCount < targetCount + return readyCount, targetCount, deficit, alert +} + +func intFromCandidatesDirectCount(candidates []PeerEndpointCandidate) int { + count := 0 + for _, candidate := range candidates { + if strings.Contains(strings.ToLower(strings.TrimSpace(candidate.Transport)), "quic") && + isDirectConnectivityMode(candidate.ConnectivityMode) { + count++ + } + } + return count +} + +func directPeerReadyCountFromManager(report heartbeatMeshPeerConnectionManagerReport) int { + if report.PeerConnectionReady > 0 { + return report.PeerConnectionReady + } + if len(report.ProbeResults) == 0 { + return 0 + } + ready := map[string]struct{}{} + for _, probe := range report.ProbeResults { + nodeID := strings.TrimSpace(probe.NodeID) + if nodeID == "" || !strings.EqualFold(strings.TrimSpace(probe.LinkStatus), "reachable") { + continue + } + if isDirectTransportMode(probe.TransportMode) { + ready[nodeID] = struct{}{} + continue + } + for _, candidate := range probe.CandidateResults { + if !strings.EqualFold(strings.TrimSpace(candidate.LinkStatus), "reachable") { + continue + } + if isDirectTransportMode(candidate.Transport) { + ready[nodeID] = struct{}{} + break + } + } + } + return len(ready) +} + +func isDirectConnectivityMode(mode string) bool { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "direct", "private_lan", "corp_lan", "corporate_lan": + return true + default: + return false + } +} + +func isDirectTransportMode(mode string) bool { + switch strings.ToLower(strings.TrimSpace(mode)) { + case "direct", "direct_quic", "private_lan", "reverse_quic": + return true + default: + return false + } +} + +func (s *Service) evaluateStaleNodeRiskProduct(ctx context.Context, clusterID string, node ClusterNode, recoveryHoldActive bool, product string, policy NodeUpdatePolicy, statuses []NodeUpdateStatus, releaseCache map[string][]ReleaseVersion) (StaleNodeRiskProduct, error) { + currentVersion, latestStatus := currentVersionFromNodeAndStatuses(node, product, statuses) + detectedOS, detectedArch, detectedInstallType, profileKnown := inferUpdateProfile(product, statuses) + item := StaleNodeRiskProduct{ + Product: product, + CurrentVersion: currentVersion, + TargetVersion: policy.TargetVersion, + Channel: policy.Channel, + Strategy: policy.Strategy, + Enabled: policy.Enabled, + DetectedOS: detectedOS, + DetectedArch: detectedArch, + DetectedInstallType: detectedInstallType, + } + if latestStatus != nil { + item.LastStatusObservedAt = &latestStatus.ObservedAt + item.LastStatusPhase = latestStatus.Phase + item.LastStatusValue = latestStatus.Status + item.LastStatusReason = nodeUpdateStatusReason(*latestStatus) + } + if !policy.Enabled || !isNodeRecoveryRiskRelevant(node) || !recoveryHoldActive { + return item, nil + } + if !profileKnown { + item.Risks = append(item.Risks, "stale_node_unknown_profile_"+product) + return item, nil + } + cacheKey := product + "|" + policy.Channel + releases, ok := releaseCache[cacheKey] + if !ok { + var err error + releases, err = s.store.ListReleaseVersions(ctx, clusterID, product, policy.Channel) + if err != nil { + return StaleNodeRiskProduct{}, err + } + releaseCache[cacheKey] = releases + } + release, artifact, found := selectReleaseArtifact(releases, GetNodeUpdatePlanInput{ + ClusterID: clusterID, + NodeID: node.ID, + Product: product, + CurrentVersion: currentVersion, + OS: detectedOS, + Arch: detectedArch, + InstallType: detectedInstallType, + Channel: policy.Channel, + }, policy) + if found { + item.CompatibleArtifactFound = true + item.MatchingReleaseVersion = release.Version + _ = artifact + } else { + item.Risks = append(item.Risks, "stale_node_no_compatible_"+product+"_artifact") + } + if currentVersion == "" { + item.Risks = append(item.Risks, "stale_node_unknown_"+product+"_version") + } + if latestStatus == nil { + item.Risks = append(item.Risks, "stale_node_no_"+product+"_update_status") + } else if item.CompatibleArtifactFound && strings.EqualFold(strings.TrimSpace(item.LastStatusReason), "no_matching_artifact") { + item.Risks = append(item.Risks, "stale_node_legacy_recovery_contract_"+product) + item.RecoveryBridgeRequired = true + item.RecoveryBridgeReplayReady = true + item.RecoveryBridgeMode = "legacy_contract_overlap" + } + item.Risks = trimStringSlice(item.Risks) + return item, nil +} + +func isNodeRecoveryRiskRelevant(node ClusterNode) bool { + return node.RegistrationStatus == NodeRegistrationActive +} + +func nodeNeedsRecoveryHold(node ClusterNode, heartbeatStale bool) bool { + if heartbeatStale { + return true + } + return !strings.EqualFold(strings.TrimSpace(node.HealthStatus), "healthy") +} + +func currentVersionFromNodeAndStatuses(node ClusterNode, product string, statuses []NodeUpdateStatus) (string, *NodeUpdateStatus) { + if product == "rap-node-agent" && node.ReportedVersion != nil { + if version := strings.TrimSpace(*node.ReportedVersion); version != "" { + if status := latestNodeUpdateStatusForProduct(statuses, product); status != nil { + return version, status + } + return version, nil + } + } + status := latestNodeUpdateStatusForProduct(statuses, product) + if status == nil { + return "", nil + } + return strings.TrimSpace(status.CurrentVersion), status +} + +func latestNodeUpdateStatusForProduct(statuses []NodeUpdateStatus, product string) *NodeUpdateStatus { + var latest *NodeUpdateStatus + for i := range statuses { + if statuses[i].Product != product { + continue + } + if latest == nil || statuses[i].ObservedAt.After(latest.ObservedAt) { + latest = &statuses[i] + } + } + return latest +} + +func inferUpdateProfile(product string, statuses []NodeUpdateStatus) (osValue string, arch string, installType string, known bool) { + arch = "amd64" + windowsObserved := false + for _, status := range statuses { + if nodeUpdateStatusLooksWindows(status) { + windowsObserved = true + break + } + } + if windowsObserved { + if product == "rap-host-agent" { + return "windows", arch, "windows_binary", true + } + return "windows", arch, "windows_service", true + } + if product == "rap-host-agent" { + return "linux", arch, "linux_binary", true + } + return "", arch, "", false +} + +func nodeUpdateStatusReason(status NodeUpdateStatus) string { + var payload map[string]any + if len(status.Payload) == 0 || json.Unmarshal(status.Payload, &payload) != nil { + return "" + } + return strings.TrimSpace(stringFromAny(payload["reason"])) +} + +func containsAnyRiskWithPrefix(risks []string, prefix string) bool { + for _, risk := range risks { + if strings.HasPrefix(strings.TrimSpace(risk), prefix) { + return true + } + } + return false +} + +func containsAnyRiskWithSuffix(risks []string, suffix string) bool { + for _, risk := range risks { + if strings.HasSuffix(strings.TrimSpace(risk), suffix) { + return true + } + } + return false +} + +func releaseRequestsLegacyRemoval(raw json.RawMessage) bool { + if len(raw) == 0 || !json.Valid(raw) { + return false + } + var payload map[string]any + if err := json.Unmarshal(raw, &payload); err != nil { + return false + } + return truthyCompatibilityFlag(payload, "legacy_removal") || + truthyCompatibilityFlag(payload, "remove_legacy_formats") || + truthyCompatibilityFlag(payload, "remove_compatibility_formats") || + truthyCompatibilityFlag(payload, "breaking_compatibility") +} + +func truthyCompatibilityFlag(payload map[string]any, key string) bool { + value, ok := payload[key] + if !ok { + return false + } + flag, ok := value.(bool) + return ok && flag +} + +func targetedReleaseRequestsLegacyRemoval(releases []ReleaseVersion, targetVersion string) bool { + targetVersion = strings.TrimSpace(targetVersion) + if targetVersion == "" { + return false + } + for _, release := range releases { + if strings.TrimSpace(release.Version) != targetVersion { + continue + } + return releaseRequestsLegacyRemoval(release.Compatibility) + } + return false +} + +func hasTargetedReleaseVersion(releases []ReleaseVersion, targetVersion string) bool { + targetVersion = strings.TrimSpace(targetVersion) + if targetVersion == "" { + return false + } + for _, release := range releases { + if strings.TrimSpace(release.Version) == targetVersion { + return true + } + } + return false +} + +func (s *Service) recordLegacyRemovalBlockedAudit( + ctx context.Context, + clusterID string, + actorUserID string, + targetType string, + targetID string, + blockedOperation string, + report StaleNodeRiskReport, +) { + clusterID = strings.TrimSpace(clusterID) + actorUserID = strings.TrimSpace(actorUserID) + targetID = strings.TrimSpace(targetID) + if clusterID == "" || actorUserID == "" { + return + } + payload, err := json.Marshal(map[string]any{ + "blocked_operation": blockedOperation, + "blocked_operations": report.BlockedOperations, + "bridge_hold_required": report.BridgeHoldRequired, + "bridge_hold_reasons": report.BridgeHoldReasons, + "bridge_hold_node_ids": report.BridgeHoldNodeIDs, + "stale_nodes": report.Summary.StaleNodes, + "blocked_nodes": report.Summary.BlockedNodes, + "artifact_gap_nodes": report.Summary.ArtifactGapNodes, + "unknown_profile_nodes": report.Summary.UnknownProfileNodes, + "waiting_update_status_nodes": report.Summary.WaitingUpdateStatusNodes, + "unknown_version_nodes": report.Summary.UnknownVersionNodes, + "legacy_recovery_contract_nodes": report.Summary.LegacyRecoveryContractNodes, + "recovery_bridge_required_nodes": report.Summary.RecoveryBridgeRequiredNodes, + "recovery_bridge_replay_ready_nodes": report.Summary.RecoveryBridgeReplayReadyNodes, + "waiting_recovery_heartbeat_nodes": report.Summary.WaitingRecoveryHeartbeatNodes, + "legacy_removal_allowed": report.LegacyRemovalAllowed, + "production_forwarding": false, + }) + if err != nil { + return + } + clusterIDCopy := clusterID + actorUserIDCopy := actorUserID + var targetIDPtr *string + if targetID != "" { + targetIDCopy := targetID + targetIDPtr = &targetIDCopy + } + _ = s.store.RecordAudit(ctx, ClusterAuditEvent{ + ClusterID: &clusterIDCopy, + ActorUserID: &actorUserIDCopy, + EventType: "legacy_compatibility_removal.blocked", + TargetType: targetType, + TargetID: targetIDPtr, + Payload: payload, + CreatedAt: s.now(), + }) +} + func trimStringSlice(values []string) []string { out := []string{} for _, value := range values { @@ -11330,9 +12069,7 @@ func selectReleaseArtifact(releases []ReleaseVersion, input GetNodeUpdatePlanInp continue } for _, artifact := range release.Artifacts { - if normalizeUpdateToken(artifact.OS) == input.OS && - normalizeUpdateToken(artifact.Arch) == input.Arch && - normalizeUpdateToken(artifact.InstallType) == input.InstallType { + if releaseArtifactMatchesUpdateRequest(artifact, input) { artifact.URLs = releaseArtifactURLs(artifact) return release, artifact, true } @@ -11341,6 +12078,31 @@ func selectReleaseArtifact(releases []ReleaseVersion, input GetNodeUpdatePlanInp return ReleaseVersion{}, ReleaseArtifact{}, false } +func releaseArtifactMatchesUpdateRequest(artifact ReleaseArtifact, input GetNodeUpdatePlanInput) bool { + if normalizeUpdateToken(artifact.OS) != input.OS || + normalizeUpdateToken(artifact.Arch) != input.Arch { + return false + } + artifactInstallType := normalizeUpdateToken(artifact.InstallType) + if artifactInstallType == input.InstallType { + return true + } + if input.Product == "rap-host-agent" && input.OS == "windows" { + return hostAgentWindowsInstallTypeEquivalent(artifactInstallType, input.InstallType) + } + return false +} + +func hostAgentWindowsInstallTypeEquivalent(left, right string) bool { + left = normalizeUpdateToken(left) + right = normalizeUpdateToken(right) + if left == right { + return true + } + return (left == "windows_binary" || left == "windows_service") && + (right == "windows_binary" || right == "windows_service") +} + func releaseArtifactURLs(artifact ReleaseArtifact) []string { out := trimEndpointSlice(append([]string{artifact.URL}, artifact.URLs...)) if len(artifact.Metadata) > 0 && json.Valid(artifact.Metadata) { @@ -11369,9 +12131,62 @@ func normalizeArtifactOrigin(value string) string { if err != nil || parsed.Scheme == "" || parsed.Host == "" { return "" } + return canonicalArtifactOrigin(parsed.Scheme + "://" + parsed.Host) +} + +func canonicalArtifactOrigin(value string) string { + value = strings.TrimRight(strings.TrimSpace(value), "/") + if value == "" { + return "" + } + parsed, err := url.Parse(value) + if err != nil || parsed.Scheme == "" || parsed.Host == "" { + return "" + } + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + port := strings.TrimSpace(parsed.Port()) + if (host == "94.141.118.222" || host == "vpn.cin.su") && port == "19191" { + return "https://vpn.cin.su" + } return parsed.Scheme + "://" + parsed.Host } +func artifactOriginFromHeartbeat(heartbeat NodeHeartbeat) string { + var metadata struct { + MeshOutboundSessionReport struct { + ControlPlaneURL string `json:"control_plane_url"` + } `json:"mesh_outbound_session_report"` + } + if len(heartbeat.Metadata) == 0 || !json.Valid(heartbeat.Metadata) { + return "" + } + if err := json.Unmarshal(heartbeat.Metadata, &metadata); err != nil { + return "" + } + return normalizeArtifactOrigin(metadata.MeshOutboundSessionReport.ControlPlaneURL) +} + +func preferredNodeArtifactOrigin(current, fallback string) string { + current = normalizeArtifactOrigin(current) + fallback = normalizeArtifactOrigin(fallback) + if fallback == "" { + return current + } + if current == "" { + return fallback + } + parsedCurrent, errCurrent := url.Parse(current) + if errCurrent != nil { + return current + } + host := strings.ToLower(strings.TrimSpace(parsedCurrent.Hostname())) + switch host { + case "", "127.0.0.1", "localhost", "192.168.200.61": + return fallback + } + return current +} + func absolutizeReleaseArtifact(artifact ReleaseArtifact, origin string) ReleaseArtifact { if origin == "" { return artifact @@ -11386,18 +12201,71 @@ func absolutizeReleaseArtifact(artifact ReleaseArtifact, origin string) ReleaseA func absolutizeArtifactURL(raw, origin string) string { raw = strings.TrimSpace(raw) if raw == "" || origin == "" { - return raw + return canonicalArtifactURL(raw) } parsed, err := url.Parse(raw) if err == nil && parsed.IsAbs() { - return raw + if !artifactURLLooksLocal(parsed) { + return canonicalArtifactURL(raw) + } + base, baseErr := url.Parse(origin) + if baseErr != nil || base.Scheme == "" || base.Host == "" { + return canonicalArtifactURL(raw) + } + rewritten := &url.URL{ + Scheme: base.Scheme, + Host: base.Host, + Path: parsed.Path, + RawPath: parsed.RawPath, + RawQuery: parsed.RawQuery, + Fragment: parsed.Fragment, + } + return canonicalArtifactURL(rewritten.String()) } if strings.HasPrefix(raw, "/") { - return origin + raw + return canonicalArtifactURL(origin + raw) + } + return canonicalArtifactURL(raw) +} + +func canonicalArtifactURL(raw string) string { + raw = strings.TrimSpace(raw) + if raw == "" { + return "" + } + parsed, err := url.Parse(raw) + if err != nil || !parsed.IsAbs() { + return raw + } + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + port := strings.TrimSpace(parsed.Port()) + if (host == "94.141.118.222" || host == "vpn.cin.su") && port == "19191" { + parsed.Scheme = "https" + parsed.Host = "vpn.cin.su" + return parsed.String() } return raw } +func artifactURLLooksLocal(parsed *url.URL) bool { + if parsed == nil { + return false + } + host := strings.ToLower(strings.TrimSpace(parsed.Hostname())) + switch host { + case "", "localhost": + return true + } + ip := net.ParseIP(host) + if ip == nil { + return false + } + if ip.IsLoopback() || ip.IsPrivate() || ip.IsLinkLocalUnicast() { + return true + } + return false +} + func (s *Service) hostAgentPlatformMismatch(ctx context.Context, input GetNodeUpdatePlanInput) (bool, error) { if input.Product != "rap-host-agent" { return false, nil @@ -11471,6 +12339,26 @@ func nonNegativeOrDefault(value, fallback int) int { return fallback } +func cloneRawJSON(raw json.RawMessage) json.RawMessage { + trimmed := bytes.TrimSpace(raw) + if len(trimmed) == 0 { + return nil + } + return append(json.RawMessage(nil), trimmed...) +} + +func isOptionalJSONArray(raw json.RawMessage) bool { + raw = bytes.TrimSpace(raw) + if len(raw) == 0 { + return true + } + if !json.Valid(raw) { + return false + } + var items []json.RawMessage + return json.Unmarshal(raw, &items) == nil +} + func safeInstallProfileSlug(value string) string { value = strings.ToLower(strings.TrimSpace(value)) var b strings.Builder @@ -13403,6 +14291,34 @@ func isPeerEndpointNATType(value string) bool { } } +func appendUniqueStrings(values []string, additions ...string) []string { + seen := make(map[string]struct{}, len(values)+len(additions)) + out := make([]string, 0, len(values)+len(additions)) + for _, value := range values { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + for _, value := range additions { + value = strings.TrimSpace(value) + if value == "" { + continue + } + if _, ok := seen[value]; ok { + continue + } + seen[value] = struct{}{} + out = append(out, value) + } + return out +} + func controlPlaneAllowedChannels(channels []string) []string { out := []string{} for _, channel := range channels { diff --git a/backend/internal/modules/cluster/service_test.go b/backend/internal/modules/cluster/service_test.go index 114cda5..e5b6862 100644 --- a/backend/internal/modules/cluster/service_test.go +++ b/backend/internal/modules/cluster/service_test.go @@ -405,8 +405,8 @@ func TestFabricAdminServiceClassesAreScopedToAdminRoles(t *testing.T) { 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) + if ingress.Type != "fabric_quic_only" || ingress.PathTemplate != "" || ingress.WebSocketPathTemplate != "" || len(ingress.SupportedMethods) != 0 { + t.Fatalf("ingress must not expose HTTP/WebSocket transport paths: %+v", ingress) } }) } @@ -478,6 +478,7 @@ func TestGetDockerInstallProfileBuildsRuntimeProfileFromTokenScope(t *testing.T) "roles": ["core-mesh"], "image": "registry.example.test/rap-node-agent:1", "artifact_endpoints": ["https://cache-a.example.test/artifacts/"], + "fabric_registry_records": [{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api","service_id":"control-a"}], "docker_image_artifact_sha256": "abc123", "mesh_connectivity_mode": "outbound_only", "mesh_region": "customer-a", @@ -509,9 +510,11 @@ func TestGetDockerInstallProfileBuildsRuntimeProfileFromTokenScope(t *testing.T) profile.DockerImageArtifact.FileName != "registry.example.test-rap-node-agent-1.tar" || profile.DockerImageArtifact.URLs[0] != "https://cache-a.example.test/artifacts/registry.example.test-rap-node-agent-1.tar" || profile.DockerImageArtifact.SHA256 != "abc123" || + string(profile.FabricRegistryRecords) != `[{"schema":"rap.fabric.registry.gossip_record.v1","service_class":"control-api","service_id":"control-a"}]` || profile.EnrollmentPollTimeoutSeconds != 0 || !profile.PullImage || - !profile.MeshSyntheticRuntimeEnabled || + profile.MeshSyntheticRuntimeEnabled || + profile.MeshListenAddr != "" || profile.MeshProductionForwardingEnabled { t.Fatalf("unexpected profile: %+v", profile) } @@ -746,6 +749,59 @@ func TestSetDesiredWorkloadRequiresPlatformAdmin(t *testing.T) { } } +func TestSetDesiredWorkloadUnknownActorReturnsAccessDenied(t *testing.T) { + store := &fakeRepository{platformRoleErr: pgx.ErrNoRows} + service := NewService(store) + + _, err := service.SetDesiredWorkload(context.Background(), SetDesiredWorkloadInput{ + ActorUserID: "missing-user", + ClusterID: "cluster-1", + NodeID: "node-1", + ServiceType: "mesh-listener", + DesiredState: "enabled", + RuntimeMode: "container", + }) + if !errors.Is(err, ErrAccessDenied) { + t.Fatalf("err = %v, want ErrAccessDenied", err) + } +} + +func TestSetDesiredWorkloadInvalidActorUUIDReturnsAccessDenied(t *testing.T) { + store := &fakeRepository{} + service := NewService(store) + + _, err := service.SetDesiredWorkload(context.Background(), SetDesiredWorkloadInput{ + ActorUserID: "codex", + ClusterID: "cluster-1", + NodeID: "node-1", + ServiceType: "mesh-listener", + DesiredState: "enabled", + RuntimeMode: "container", + }) + if !errors.Is(err, ErrAccessDenied) { + t.Fatalf("err = %v, want ErrAccessDenied", err) + } +} + +func TestSetDesiredWorkloadRejectsUnsupportedRuntimeMode(t *testing.T) { + store := &fakeRepository{platformRole: "platform_admin"} + service := NewService(store) + + _, err := service.SetDesiredWorkload(context.Background(), SetDesiredWorkloadInput{ + ActorUserID: "admin-1", + ClusterID: "cluster-1", + NodeID: "node-1", + ServiceType: "mesh-listener", + DesiredState: "enabled", + RuntimeMode: "systemd", + Config: json.RawMessage(`{}`), + Environment: json.RawMessage(`{}`), + }) + if !errors.Is(err, ErrInvalidPayload) { + t.Fatalf("err = %v, want ErrInvalidPayload", err) + } +} + func TestListDesiredWorkloadsAllowsNodeScopedAgentReadWithoutActor(t *testing.T) { store := &fakeRepository{ desiredWorkloads: []NodeWorkloadDesiredState{{ @@ -786,6 +842,21 @@ func TestReportWorkloadStatusDefaultsToSafeStubState(t *testing.T) { } } +func TestReportWorkloadStatusRejectsUnsupportedRuntimeMode(t *testing.T) { + store := &fakeRepository{} + service := NewService(store) + + _, err := service.ReportWorkloadStatus(context.Background(), ReportWorkloadStatusInput{ + ClusterID: "cluster-1", + NodeID: "node-1", + ServiceType: "mesh-listener", + RuntimeMode: "systemd", + }) + if !errors.Is(err, ErrInvalidPayload) { + t.Fatalf("err = %v, want ErrInvalidPayload", err) + } +} + func TestReportMeshLinkDoesNotRequirePlatformAdmin(t *testing.T) { store := &fakeRepository{} service := NewService(store) @@ -862,7 +933,7 @@ func TestGetVPNClientProfileEnsuresFabricVPNPacketRouteIntents(t *testing.T) { if !ok { t.Fatalf("missing vpn_dataplane_session in %#v", cfg) } - if session["preferred_transport"] != "fabric_service_channel_v1" || session["fallback_transport"] != "none" || session["backend_relay_allowed"] != false { + if session["preferred_transport"] != "fabric_mesh_node_route_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) @@ -905,21 +976,21 @@ func TestGetVPNClientProfileEnsuresFabricVPNPacketRouteIntents(t *testing.T) { t.Fatalf("unexpected entry candidate: %#v", entryCandidate) } transportCandidates := session["transport_candidates"].([]any) - var foundDirect bool + var foundQUICRoute bool for _, rawCandidate := range transportCandidates { candidate := rawCandidate.(map[string]any) - if candidate["type"] == "entry_direct_http_v1" { - foundDirect = true - if candidate["status"] != "available" || candidate["safe_client_switch"] != true { - t.Fatalf("unexpected direct entry transport candidate: %#v", candidate) + if candidate["type"] == "fabric_mesh_node_route_v1" { + foundQUICRoute = true + if candidate["status"] != "contract_ready_quic_fabric_route_required" || candidate["backend_relay_allowed"] != false { + t.Fatalf("unexpected QUIC fabric route candidate: %#v", candidate) } } } - if !foundDirect { - t.Fatalf("missing entry_direct_http_v1 in %#v", transportCandidates) + if !foundQUICRoute || len(transportCandidates) != 1 { + t.Fatalf("missing single QUIC fabric route candidate in %#v", transportCandidates) } auth := session["auth"].(map[string]any) - if auth["type"] != "control_plane_issued_bearer" || auth["node_validation"] != "entry_node_calls_control_plane_introspection" { + if auth["type"] != "control_plane_issued_bearer" || auth["node_validation"] != "vpn_client_node_identity_and_policy" { t.Fatalf("unexpected dataplane session auth: %#v", auth) } @@ -970,6 +1041,7 @@ func TestGetVPNClientProfileForwardsPreferredExit(t *testing.T) { } func TestVPNDirectHTTPEntryTransportUsesFarmLocalRouteWhenEntryIsExit(t *testing.T) { + t.Skip("direct HTTP entry transport removed from the QUIC-only fabric dataplane") candidate := vpnDirectHTTPEntryTransportCandidate(vpnClientFabricRoute{ SelectedEntryNodeID: "node-1", SelectedExitNodeID: "node-1", @@ -987,6 +1059,7 @@ func TestVPNDirectHTTPEntryTransportUsesFarmLocalRouteWhenEntryIsExit(t *testing } func TestVPNDirectHTTPEntryTransportIgnoresLegacyLocalGatewayShortcut(t *testing.T) { + t.Skip("direct HTTP entry transport removed from the QUIC-only fabric dataplane") candidate := vpnDirectHTTPEntryTransportCandidate(vpnClientFabricRoute{ SelectedEntryNodeID: "node-1", SelectedExitNodeID: "node-1", @@ -1219,11 +1292,12 @@ func TestNodeUpdatePlanAbsolutizesRelativeArtifactURLs(t *testing.T) { if plan.Artifact == nil { t.Fatal("expected artifact") } - if plan.Artifact.URL != "http://vpn.cin.su:19191/downloads/rap-node-agent-0.2.93-docker-amd64.tar" { + if plan.Artifact.URL != "https://vpn.cin.su/downloads/rap-node-agent-0.2.93-docker-amd64.tar" { t.Fatalf("artifact URL was not absolutized: %q", plan.Artifact.URL) } - wantMirror := "http://vpn.cin.su:19191/downloads/mirror.tar" - if len(plan.Artifact.URLs) < 2 || plan.Artifact.URLs[1] != wantMirror || plan.Artifact.URLs[2] != "https://cdn.example.test/agent.tar" { + wantMirror := "https://vpn.cin.su/downloads/mirror.tar" + wantCDN := "https://cdn.example.test/agent.tar" + if len(plan.Artifact.URLs) < 3 || plan.Artifact.URLs[1] != wantMirror || plan.Artifact.URLs[2] != wantCDN { t.Fatalf("artifact URLs were not preserved/absolutized: %#v", plan.Artifact.URLs) } } @@ -1344,6 +1418,853 @@ func TestHostAgentUpdatePlanAllowsWindowsArtifactForObservedWindowsNode(t *testi } } +func TestHostAgentUpdatePlanAcceptsWindowsServiceArtifactForWindowsBinaryRequest(t *testing.T) { + store := &fakeRepository{ + platformRole: PlatformRoleAdmin, + releaseVersions: []ReleaseVersion{ + { + ID: "release-host", + ClusterID: "cluster-1", + Product: "rap-host-agent", + Version: "0.2.95", + Channel: "stable", + Status: "active", + Artifacts: []ReleaseArtifact{ + {ID: "windows-service", ClusterID: "cluster-1", Product: "rap-host-agent", Version: "0.2.95", OS: "windows", Arch: "amd64", InstallType: "windows_service", Kind: "binary", URL: "/downloads/rap-host-agent.exe", SHA256: "windows-sha"}, + }, + }, + }, + nodeUpdatePolicies: map[string]NodeUpdatePolicy{ + "node-1|rap-host-agent": { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-host-agent", + Channel: "stable", + Strategy: "rolling", + Enabled: true, + RollbackAllowed: true, + HealthWindowSec: 180, + }, + }, + updateStatuses: []NodeUpdateStatus{ + { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-node-agent", + Phase: "plan", + Status: "noop", + Payload: json.RawMessage(`{"binary_path":"C:\\Program Files\\RAP\\node\\rap-node-agent.exe"}`), + }, + }, + } + service := NewService(store) + + plan, err := service.GetNodeUpdatePlan(context.Background(), GetNodeUpdatePlanInput{ + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-host-agent", + CurrentVersion: "0.2.92", + OS: "windows", + Arch: "amd64", + InstallType: "windows_binary", + }) + if err != nil { + t.Fatalf("update plan: %v", err) + } + if plan.Action != "update" || plan.Artifact == nil || plan.Artifact.ID != "windows-service" { + t.Fatalf("unexpected windows compatibility plan: %+v", plan) + } +} + +func TestStaleNodeRiskReportTracksWindowsRecoveryCompatibility(t *testing.T) { + staleAt := time.Now().UTC().Add(-time.Hour) + reportedVersion := "0.2.309-latencyaware" + store := &fakeRepository{ + platformRole: PlatformRoleAdmin, + clusterNodes: []ClusterNode{ + { + ID: "node-1", + NodeKey: "node-key-1", + Name: "ifcm-rufms-s-mo1cr", + RegistrationStatus: NodeRegistrationActive, + HealthStatus: "healthy", + ReportedVersion: &reportedVersion, + LastSeenAt: &staleAt, + MembershipStatus: "active", + }, + }, + releaseVersions: []ReleaseVersion{ + { + ID: "release-node", + ClusterID: "cluster-1", + Product: "rap-node-agent", + Version: "0.2.318-quic-decoupled", + Channel: "stable", + Status: "active", + Artifacts: []ReleaseArtifact{ + {ID: "node-win-svc", ClusterID: "cluster-1", Product: "rap-node-agent", Version: "0.2.318-quic-decoupled", OS: "windows", Arch: "amd64", InstallType: "windows_service", Kind: "binary", URL: "/downloads/rap-node-agent.exe", SHA256: "node-sha"}, + }, + }, + { + ID: "release-host", + ClusterID: "cluster-1", + Product: "rap-host-agent", + Version: "0.2.284-quorumauthority", + Channel: "stable", + Status: "active", + Artifacts: []ReleaseArtifact{ + {ID: "host-win-svc", ClusterID: "cluster-1", Product: "rap-host-agent", Version: "0.2.284-quorumauthority", OS: "windows", Arch: "amd64", InstallType: "windows_service", Kind: "binary", URL: "/downloads/rap-host-agent.exe", SHA256: "host-sha"}, + }, + }, + }, + nodeUpdatePolicies: map[string]NodeUpdatePolicy{ + "node-1|rap-node-agent": { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-node-agent", + Channel: "stable", + Strategy: "manual", + Enabled: true, + RollbackAllowed: true, + HealthWindowSec: 180, + }, + "node-1|rap-host-agent": { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-host-agent", + Channel: "stable", + Strategy: "rolling", + Enabled: true, + RollbackAllowed: true, + HealthWindowSec: 180, + }, + }, + updateStatuses: []NodeUpdateStatus{ + { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-node-agent", + CurrentVersion: "0.2.309-latencyaware", + Phase: "plan", + Status: "noop", + ObservedAt: time.Now().UTC().Add(-30 * time.Minute), + Payload: json.RawMessage(`{"binary_path":"C:\\Program Files\\RAP\\ifcm-rufms-s-mo1cr\\rap-node-agent.exe","task":"RAP Node Agent ifcm-rufms-s-mo1cr","reason":"already_current"}`), + }, + { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-host-agent", + CurrentVersion: "0.2.183", + Phase: "plan", + Status: "noop", + ObservedAt: time.Now().UTC().Add(-30 * time.Minute), + Payload: json.RawMessage(`{"binary_path":"C:\\Program Files\\RAP\\ifcm-rufms-s-mo1cr\\rap-host-agent.exe","reason":"no_matching_artifact"}`), + }, + }, + heartbeats: map[string][]NodeHeartbeat{ + "node-1": { + { + ClusterID: "cluster-1", + NodeID: "node-1", + Metadata: json.RawMessage(`{ + "mesh_endpoint_report": { + "peer_recovery_ready": 2, + "peer_recovery_deficit": 1 + }, + "mesh_peer_recovery_report": { + "target_ready_peers": 3 + }, + "mesh_outbound_session_report": { + "control_plane_url": "http://vpn.cin.su:19191/api/v1", + "status": "ready" + } + }`), + }, + }, + }, + } + service := NewService(store) + + report, err := service.GetStaleNodeRiskReport(context.Background(), GetStaleNodeRiskReportInput{ + ActorUserID: "admin-1", + ClusterID: "cluster-1", + }) + if err != nil { + t.Fatalf("risk report: %v", err) + } + if len(report.Nodes) != 1 { + t.Fatalf("expected one node, got %d", len(report.Nodes)) + } + node := report.Nodes[0] + if !node.HeartbeatStale { + t.Fatalf("expected stale heartbeat: %+v", node) + } + if !node.DirectPeerAlert || node.DirectPeerReadyCount != 2 || node.DirectPeerTargetCount != 3 || node.DirectPeerDeficit != 1 { + t.Fatalf("expected direct peer alert 2/3 with deficit 1: %+v", node) + } + if !node.Blocked { + t.Fatalf("expected stale node to be marked blocked: %+v", node) + } + if report.LegacyRemovalAllowed { + t.Fatalf("legacy removal must stay blocked while stale nodes remain: %+v", report) + } + if len(report.BlockedOperations) != 3 { + t.Fatalf("expected blocked operations to include bridge hold removal: %+v", report) + } + if report.Summary.ArtifactGapNodes != 0 { + t.Fatalf("artifact gap nodes = %d, want 0", report.Summary.ArtifactGapNodes) + } + if report.Summary.DirectPeerAlertNodes != 1 { + t.Fatalf("direct peer alert nodes = %d, want 1", report.Summary.DirectPeerAlertNodes) + } + if report.Summary.UnknownProfileNodes != 0 { + t.Fatalf("unknown profile nodes = %d, want 0", report.Summary.UnknownProfileNodes) + } + if report.Summary.WaitingUpdateStatusNodes != 0 { + t.Fatalf("waiting update status nodes = %d, want 0", report.Summary.WaitingUpdateStatusNodes) + } + if report.Summary.UnknownVersionNodes != 0 { + t.Fatalf("unknown version nodes = %d, want 0", report.Summary.UnknownVersionNodes) + } + if report.Summary.LegacyRecoveryContractNodes != 1 { + t.Fatalf("legacy recovery contract nodes = %d, want 1", report.Summary.LegacyRecoveryContractNodes) + } + if !report.BridgeHoldRequired { + t.Fatalf("expected bridge hold to be active: %+v", report) + } + if len(report.BridgeHoldNodeIDs) != 1 || report.BridgeHoldNodeIDs[0] != "node-1" { + t.Fatalf("bridge hold node ids = %+v, want node-1", report.BridgeHoldNodeIDs) + } + if !containsString(report.BlockedOperations, "remove_recovery_bridge_overlap") { + t.Fatalf("expected remove_recovery_bridge_overlap to be blocked: %+v", report.BlockedOperations) + } + if report.Summary.WaitingRecoveryHeartbeatNodes != 0 { + t.Fatalf("waiting recovery heartbeat nodes = %d, want 0", report.Summary.WaitingRecoveryHeartbeatNodes) + } + var hostProduct *StaleNodeRiskProduct + for i := range node.Products { + if node.Products[i].Product == "rap-host-agent" { + hostProduct = &node.Products[i] + break + } + } + if hostProduct == nil { + t.Fatalf("host-agent product risk missing: %+v", node) + } + if !hostProduct.CompatibleArtifactFound { + t.Fatalf("expected compatible host-agent artifact via windows compatibility alias: %+v", hostProduct) + } + if containsAnyRiskWithPrefix(hostProduct.Risks, "stale_node_no_compatible_rap-host-agent_artifact") { + t.Fatalf("host-agent artifact gap risk should be cleared: %+v", hostProduct) + } + if !containsAnyRiskWithPrefix(hostProduct.Risks, "stale_node_legacy_recovery_contract_") { + t.Fatalf("expected stale legacy recovery contract risk on host-agent: %+v", hostProduct) + } +} + +func TestDirectPeerRecoveryFromHeartbeatCountsReverseQUICAsDirect(t *testing.T) { + heartbeat := NodeHeartbeat{ + Metadata: json.RawMessage(`{ + "mesh_peer_recovery_report": { + "target_ready_peers": 3 + }, + "mesh_peer_connection_manager_report": { + "probe_results": [ + { + "node_id": "peer-a", + "link_status": "reachable", + "transport_mode": "direct" + }, + { + "node_id": "peer-b", + "link_status": "reachable", + "transport_mode": "relay_control", + "candidate_results": [ + { + "transport": "reverse_quic", + "link_status": "reachable" + } + ] + }, + { + "node_id": "peer-c", + "link_status": "reachable", + "transport_mode": "relay_control", + "candidate_results": [ + { + "transport": "relay_quic", + "link_status": "reachable" + } + ] + } + ] + } + }`), + } + readyCount, targetCount, deficit, alert := directPeerRecoveryFromHeartbeat(heartbeat) + if readyCount != 2 || targetCount != 3 || deficit != 1 || !alert { + t.Fatalf("unexpected direct peer recovery metrics: ready=%d target=%d deficit=%d alert=%t", readyCount, targetCount, deficit, alert) + } +} + +func TestDirectPeerRecoveryFromHeartbeatUsesManagerReadyCountWithoutFreshProbeResults(t *testing.T) { + heartbeat := NodeHeartbeat{ + Metadata: json.RawMessage(`{ + "mesh_peer_recovery_report": { + "target_ready_peers": 3 + }, + "mesh_peer_connection_manager_report": { + "peer_connection_ready": 1, + "probe_results": [] + } + }`), + } + readyCount, targetCount, deficit, alert := directPeerRecoveryFromHeartbeat(heartbeat) + if readyCount != 1 || targetCount != 3 || deficit != 2 || !alert { + t.Fatalf("unexpected direct peer recovery metrics: ready=%d target=%d deficit=%d alert=%t", readyCount, targetCount, deficit, alert) + } +} + +func TestGetNodeUpdatePlanPrefersHeartbeatControlOriginForLocalRequests(t *testing.T) { + store := &fakeRepository{ + nodeUpdatePolicies: map[string]NodeUpdatePolicy{ + "node-1|rap-node-agent": { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-node-agent", + Channel: "stable", + Strategy: "pinned", + Enabled: true, + RollbackAllowed: true, + HealthWindowSec: 180, + }, + }, + releaseVersions: []ReleaseVersion{ + { + ID: "release-node", + ClusterID: "cluster-1", + Product: "rap-node-agent", + Version: "0.2.318-quic-decoupled", + Channel: "stable", + Status: "active", + Artifacts: []ReleaseArtifact{ + {ID: "node-win-svc", ClusterID: "cluster-1", Product: "rap-node-agent", Version: "0.2.318-quic-decoupled", OS: "windows", Arch: "amd64", InstallType: "windows_service", Kind: "binary", URL: "http://192.168.200.61:18080/downloads/rap-node-agent.exe", SHA256: "node-sha"}, + }, + }, + }, + heartbeats: map[string][]NodeHeartbeat{ + "node-1": { + { + ClusterID: "cluster-1", + NodeID: "node-1", + Metadata: json.RawMessage(`{ + "mesh_outbound_session_report": { + "control_plane_url": "http://vpn.cin.su:19191/api/v1", + "status": "ready" + } + }`), + }, + }, + }, + } + service := NewService(store) + plan, err := service.GetNodeUpdatePlan(context.Background(), GetNodeUpdatePlanInput{ + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-node-agent", + CurrentVersion: "0.2.309-latencyaware", + OS: "windows", + Arch: "amd64", + InstallType: "windows_service", + Channel: "stable", + ArtifactOrigin: "http://192.168.200.61:18121", + }) + if err != nil { + t.Fatalf("update plan: %v", err) + } + if plan.Artifact == nil || !strings.HasPrefix(plan.Artifact.URL, "https://vpn.cin.su/") { + t.Fatalf("expected node-reachable artifact origin, got %+v", plan.Artifact) + } +} + +func TestStaleNodeRiskReportKeepsRecoveryBridgeForOfflineNodeWithoutHeartbeatStale(t *testing.T) { + now := time.Now().UTC() + recentSeen := now.Add(-5 * time.Minute) + reportedVersion := "0.2.309-latencyaware" + store := &fakeRepository{ + platformRole: PlatformRoleAdmin, + clusterNodes: []ClusterNode{ + { + ID: "node-1", + NodeKey: "node-key-1", + Name: "ifcm-rufms-s-mo1cr", + RegistrationStatus: NodeRegistrationActive, + HealthStatus: "offline", + ReportedVersion: &reportedVersion, + LastSeenAt: &recentSeen, + MembershipStatus: "active", + }, + }, + releaseVersions: []ReleaseVersion{ + { + ID: "release-host", + ClusterID: "cluster-1", + Product: "rap-host-agent", + Version: "0.2.284-quorumauthority", + Channel: "stable", + Status: "active", + Artifacts: []ReleaseArtifact{ + {ID: "host-win-svc", ClusterID: "cluster-1", Product: "rap-host-agent", Version: "0.2.284-quorumauthority", OS: "windows", Arch: "amd64", InstallType: "windows_service", Kind: "binary", URL: "/downloads/rap-host-agent.exe", SHA256: "host-sha"}, + }, + }, + }, + nodeUpdatePolicies: map[string]NodeUpdatePolicy{ + "node-1|rap-host-agent": { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-host-agent", + Channel: "stable", + Strategy: "rolling", + Enabled: true, + RollbackAllowed: true, + HealthWindowSec: 180, + }, + }, + updateStatuses: []NodeUpdateStatus{ + { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-host-agent", + CurrentVersion: "0.2.183", + Phase: "plan", + Status: "noop", + ObservedAt: now.Add(-10 * time.Minute), + Payload: json.RawMessage(`{"binary_path":"C:\\Program Files\\RAP\\ifcm-rufms-s-mo1cr\\rap-host-agent.exe","reason":"no_matching_artifact"}`), + }, + }, + heartbeats: map[string][]NodeHeartbeat{ + "node-1": { + { + ClusterID: "cluster-1", + NodeID: "node-1", + Metadata: json.RawMessage(`{ + "mesh_outbound_session_report": { + "control_plane_url": "http://vpn.cin.su:19191/api/v1", + "status": "ready" + } + }`), + }, + }, + }, + } + service := NewService(store) + service.now = func() time.Time { return now } + + report, err := service.GetStaleNodeRiskReport(context.Background(), GetStaleNodeRiskReportInput{ + ActorUserID: "admin-1", + ClusterID: "cluster-1", + }) + if err != nil { + t.Fatalf("risk report: %v", err) + } + if len(report.Nodes) != 1 { + t.Fatalf("expected one node, got %d", len(report.Nodes)) + } + node := report.Nodes[0] + if node.HeartbeatStale { + t.Fatalf("heartbeat should be fresh in this test: %+v", node) + } + if !node.RecoveryBridgeRequired { + t.Fatalf("expected recovery bridge to remain required for offline node: %+v", node) + } + if !report.BridgeHoldRequired { + t.Fatalf("expected report-level bridge hold to stay active: %+v", report) + } + if report.Summary.RecoveryBridgeRequiredNodes != 1 { + t.Fatalf("recovery bridge required nodes = %d, want 1", report.Summary.RecoveryBridgeRequiredNodes) + } + if !containsAnyRiskWithPrefix(node.Risks, "stale_node_legacy_recovery_contract_") { + t.Fatalf("expected legacy recovery contract risk to survive without heartbeat stale: %+v", node.Risks) + } +} + +func TestGetNodeBridgeReplayPlanBuildsReplayForLegacyContractNode(t *testing.T) { + now := time.Now().UTC() + staleAt := now.Add(-time.Hour) + reportedVersion := "0.2.309-latencyaware" + store := &fakeRepository{ + platformRole: PlatformRoleAdmin, + clusterNodes: []ClusterNode{ + { + ID: "node-1", + NodeKey: "node-key-1", + Name: "ifcm-rufms-s-mo1cr", + RegistrationStatus: NodeRegistrationActive, + HealthStatus: "offline", + ReportedVersion: &reportedVersion, + LastSeenAt: &staleAt, + MembershipStatus: "active", + }, + }, + releaseVersions: []ReleaseVersion{ + { + ID: "release-host", + ClusterID: "cluster-1", + Product: "rap-host-agent", + Version: "0.2.284-quorumauthority", + Channel: "stable", + Status: "active", + Artifacts: []ReleaseArtifact{ + {ID: "host-win-svc", ClusterID: "cluster-1", Product: "rap-host-agent", Version: "0.2.284-quorumauthority", OS: "windows", Arch: "amd64", InstallType: "windows_service", Kind: "binary", URL: "/downloads/rap-host-agent.exe", SHA256: "host-sha"}, + }, + }, + }, + nodeUpdatePolicies: map[string]NodeUpdatePolicy{ + "node-1|rap-host-agent": { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-host-agent", + Channel: "stable", + Strategy: "rolling", + Enabled: true, + RollbackAllowed: true, + HealthWindowSec: 180, + }, + }, + updateStatuses: []NodeUpdateStatus{ + { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-host-agent", + CurrentVersion: "0.2.183", + Phase: "plan", + Status: "noop", + ObservedAt: now.Add(-10 * time.Minute), + Payload: json.RawMessage(`{"binary_path":"C:\\Program Files\\RAP\\ifcm-rufms-s-mo1cr\\rap-host-agent.exe","reason":"no_matching_artifact"}`), + }, + }, + heartbeats: map[string][]NodeHeartbeat{ + "node-1": { + { + ClusterID: "cluster-1", + NodeID: "node-1", + Metadata: json.RawMessage(`{ + "mesh_outbound_session_report": { + "control_plane_url": "http://vpn.cin.su:19191/api/v1", + "status": "ready" + } + }`), + }, + }, + }, + } + service := NewService(store) + service.now = func() time.Time { return now } + if origin := artifactOriginFromHeartbeat(store.heartbeats["node-1"][0]); origin != "https://vpn.cin.su" { + t.Fatalf("artifact origin = %q, want %q", origin, "https://vpn.cin.su") + } + + plan, err := service.GetNodeBridgeReplayPlan(context.Background(), GetNodeBridgeReplayPlanInput{ + ActorUserID: "admin-1", + ClusterID: "cluster-1", + NodeID: "node-1", + }) + if err != nil { + t.Fatalf("bridge replay plan: %v", err) + } + if !plan.BridgeHoldRequired { + t.Fatalf("expected bridge hold required: %+v", plan) + } + if !plan.RecoveryBridgeReplayReady { + t.Fatalf("expected replay ready: %+v", plan) + } + if len(plan.Products) != 1 { + t.Fatalf("expected one replay product, got %+v", plan.Products) + } + product := plan.Products[0] + if product.Product != "rap-host-agent" { + t.Fatalf("unexpected replay product: %+v", product) + } + if product.RecoveryBridgeMode != "legacy_contract_overlap" { + t.Fatalf("unexpected replay mode: %+v", product) + } + if product.UpdatePlan.Action != "update" { + t.Fatalf("expected update action, got %+v", product.UpdatePlan) + } + if product.UpdatePlan.TargetVersion != "0.2.284-quorumauthority" { + t.Fatalf("unexpected target version: %+v", product.UpdatePlan) + } + if product.UpdatePlan.Artifact == nil || product.UpdatePlan.Artifact.SHA256 != "host-sha" { + t.Fatalf("expected matching artifact in replay plan: %+v", product.UpdatePlan) + } + if product.UpdatePlan.Artifact.URL != "https://vpn.cin.su/downloads/rap-host-agent.exe" { + t.Fatalf("unexpected replay artifact url: %+v", product.UpdatePlan.Artifact) + } +} + +func TestCreateReleaseVersionBlocksLegacyRemovalWhileStaleNodesRemain(t *testing.T) { + staleAt := time.Now().UTC().Add(-time.Hour) + reportedVersion := "0.2.309-latencyaware" + store := &fakeRepository{ + platformRole: PlatformRoleAdmin, + clusterNodes: []ClusterNode{ + { + ID: "node-1", + NodeKey: "node-key-1", + Name: "ifcm-rufms-s-mo1cr", + RegistrationStatus: NodeRegistrationActive, + HealthStatus: "offline", + ReportedVersion: &reportedVersion, + LastSeenAt: &staleAt, + MembershipStatus: "active", + }, + }, + } + service := NewService(store) + + _, err := service.CreateReleaseVersion(context.Background(), CreateReleaseVersionInput{ + ActorUserID: "admin-1", + ClusterID: "cluster-1", + Product: "rap-node-agent", + Version: "0.2.400-breaking", + Channel: "stable", + Status: "active", + Compatibility: json.RawMessage(`{ + "legacy_removal": true + }`), + Artifacts: []ReleaseArtifactInput{ + { + OS: "linux", + Arch: "amd64", + InstallType: "docker", + Kind: "image", + URL: "https://example.test/rap-node-agent.tar", + SHA256: "sha256-1", + SizeBytes: 123, + Metadata: json.RawMessage(`{}`), + }, + }, + }) + if !errors.Is(err, ErrLegacyRemovalBlocked) { + t.Fatalf("err = %v, want ErrLegacyRemovalBlocked", err) + } + if len(store.auditEvents) == 0 || store.auditEvents[0].EventType != "legacy_compatibility_removal.blocked" { + t.Fatalf("expected blocked audit event, got %+v", store.auditEvents) + } + var payload map[string]any + if err := json.Unmarshal(store.auditEvents[0].Payload, &payload); err != nil { + t.Fatalf("unmarshal audit payload: %v", err) + } + value, ok := payload["waiting_recovery_heartbeat_nodes"].(float64) + if !ok || int(value) != 1 { + t.Fatalf("waiting_recovery_heartbeat_nodes = %v, want 1 in audit payload", payload["waiting_recovery_heartbeat_nodes"]) + } + value, ok = payload["legacy_recovery_contract_nodes"].(float64) + if !ok || int(value) != 0 { + t.Fatalf("legacy_recovery_contract_nodes = %v, want 0 in audit payload", payload["legacy_recovery_contract_nodes"]) + } + bridgeHoldRequired, ok := payload["bridge_hold_required"].(bool) + if !ok || bridgeHoldRequired { + t.Fatalf("bridge_hold_required = %v, want false in audit payload", payload["bridge_hold_required"]) + } +} + +func TestAbsolutizeArtifactURLPreservesPublicAbsoluteURL(t *testing.T) { + raw := "http://94.141.118.222:19191/downloads/rap-node-agent.exe" + origin := "http://vpn.cin.su:19191" + want := "https://vpn.cin.su/downloads/rap-node-agent.exe" + if got := absolutizeArtifactURL(raw, origin); got != want { + t.Fatalf("artifact url = %q, want %q", got, want) + } +} + +func TestBridgeReplayPlanCanonicalizesLegacyPublicArtifactURL(t *testing.T) { + now := time.Date(2026, 5, 18, 15, 0, 0, 0, time.UTC) + reportedVersion := "0.2.318-quic-decoupled" + store := &fakeRepository{ + platformRole: PlatformRoleAdmin, + clusterNodes: []ClusterNode{{ + ID: "node-1", + NodeKey: "node-key-1", + Name: "ifcm-rufms-s-mo1cr", + RegistrationStatus: NodeRegistrationActive, + HealthStatus: "offline", + ReportedVersion: &reportedVersion, + LastSeenAt: ptrTime(now.Add(-time.Hour)), + MembershipStatus: "active", + }}, + releaseVersions: []ReleaseVersion{{ + ID: "release-host", + ClusterID: "cluster-1", + Product: "rap-host-agent", + Version: "0.2.284-quorumauthority", + Channel: "stable", + Status: "active", + Artifacts: []ReleaseArtifact{{ + ID: "host-win-svc", + ClusterID: "cluster-1", + Product: "rap-host-agent", + Version: "0.2.284-quorumauthority", + OS: "windows", + Arch: "amd64", + InstallType: "windows_service", + Kind: "binary", + URL: "http://94.141.118.222:19191/downloads/rap-host-agent.exe", + SHA256: "host-sha", + }}, + }}, + nodeUpdatePolicies: map[string]NodeUpdatePolicy{ + "node-1|rap-host-agent": { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-host-agent", + Channel: "stable", + Strategy: "rolling", + Enabled: true, + RollbackAllowed: true, + HealthWindowSec: 180, + }, + }, + updateStatuses: []NodeUpdateStatus{{ + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-host-agent", + CurrentVersion: "0.2.183", + Phase: "plan", + Status: "noop", + ObservedAt: now.Add(-10 * time.Minute), + Payload: json.RawMessage(`{"binary_path":"C:\\Program Files\\RAP\\ifcm-rufms-s-mo1cr\\rap-host-agent.exe","reason":"no_matching_artifact"}`), + }}, + heartbeats: map[string][]NodeHeartbeat{ + "node-1": {{ + ClusterID: "cluster-1", + NodeID: "node-1", + Metadata: json.RawMessage(`{ + "mesh_outbound_session_report": { + "control_plane_url": "http://vpn.cin.su:19191/api/v1", + "status": "ready" + } + }`), + }}, + }, + } + service := NewService(store) + service.now = func() time.Time { return now } + + plan, err := service.GetNodeBridgeReplayPlan(context.Background(), GetNodeBridgeReplayPlanInput{ + ActorUserID: "admin-1", + ClusterID: "cluster-1", + NodeID: "node-1", + }) + if err != nil { + t.Fatalf("bridge replay plan: %v", err) + } + if len(plan.Products) != 1 || plan.Products[0].UpdatePlan.Artifact == nil { + t.Fatalf("unexpected replay plan: %+v", plan) + } + if got := plan.Products[0].UpdatePlan.Artifact.URL; got != "https://vpn.cin.su/downloads/rap-host-agent.exe" { + t.Fatalf("artifact url = %q, want canonical public artifact url", got) + } +} + +func TestUpsertNodeUpdatePolicyBlocksTargetingBreakingReleaseWhileStaleNodesRemain(t *testing.T) { + staleAt := time.Now().UTC().Add(-time.Hour) + reportedVersion := "0.2.309-latencyaware" + store := &fakeRepository{ + platformRole: PlatformRoleAdmin, + clusterNodes: []ClusterNode{ + { + ID: "node-1", + NodeKey: "node-key-1", + Name: "ifcm-rufms-s-mo1cr", + RegistrationStatus: NodeRegistrationActive, + HealthStatus: "offline", + ReportedVersion: &reportedVersion, + LastSeenAt: &staleAt, + MembershipStatus: "active", + }, + }, + releaseVersions: []ReleaseVersion{ + { + ID: "release-breaking", + ClusterID: "cluster-1", + Product: "rap-node-agent", + Version: "0.2.400-breaking", + Channel: "stable", + Status: "active", + Compatibility: json.RawMessage(`{"legacy_removal": true}`), + Artifacts: []ReleaseArtifact{ + {ID: "docker", ClusterID: "cluster-1", Product: "rap-node-agent", Version: "0.2.400-breaking", OS: "linux", Arch: "amd64", InstallType: "docker", Kind: "image", URL: "https://example.test/rap-node-agent.tar", SHA256: "sha256-1"}, + }, + }, + }, + } + service := NewService(store) + + targetVersion := "0.2.400-breaking" + _, err := service.UpsertNodeUpdatePolicy(context.Background(), UpsertNodeUpdatePolicyInput{ + ActorUserID: "admin-1", + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-node-agent", + Channel: "stable", + TargetVersion: &targetVersion, + Strategy: "manual", + Enabled: true, + RollbackAllowed: true, + HealthWindowSec: 180, + }) + if !errors.Is(err, ErrLegacyRemovalBlocked) { + t.Fatalf("err = %v, want ErrLegacyRemovalBlocked", err) + } + if len(store.auditEvents) == 0 || store.auditEvents[0].EventType != "legacy_compatibility_removal.blocked" { + t.Fatalf("expected blocked audit event, got %+v", store.auditEvents) + } +} + +func TestUpsertNodeUpdatePolicyRejectsUnknownTargetVersion(t *testing.T) { + service := NewService(&fakeRepository{ + platformRole: PlatformRoleAdmin, + clusterNodes: []ClusterNode{ + { + ID: "node-1", + NodeKey: "node-key-1", + Name: "home-1", + RegistrationStatus: NodeRegistrationActive, + HealthStatus: "healthy", + MembershipStatus: "active", + }, + }, + releaseVersions: []ReleaseVersion{ + { + ID: "release-known", + ClusterID: "cluster-1", + Product: "rap-node-agent", + Version: "0.2.309-latencyaware", + Channel: "stable", + Status: "active", + }, + }, + }) + + targetVersion := "0.2.400-breaking" + _, err := service.UpsertNodeUpdatePolicy(context.Background(), UpsertNodeUpdatePolicyInput{ + ActorUserID: "admin-1", + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-node-agent", + Channel: "stable", + TargetVersion: &targetVersion, + Strategy: "manual", + Enabled: true, + RollbackAllowed: true, + HealthWindowSec: 180, + }) + if !errors.Is(err, ErrInvalidPayload) { + t.Fatalf("err = %v, want ErrInvalidPayload", err) + } +} + func TestNodeUpdatePlanNoopsWhenPolicyMissing(t *testing.T) { service := NewService(&fakeRepository{}) @@ -1856,6 +2777,127 @@ func TestGetNodeSyntheticMeshConfigUsesDesiredMeshListenerAdvertiseEndpointForPe } } +func TestGetNodeSyntheticMeshConfigUsesDesiredMeshListenerEndpointCandidates(t *testing.T) { + now := time.Date(2026, 5, 1, 9, 15, 0, 0, time.UTC) + version := "home-1-multi-provider" + service := NewService(&fakeRepository{ + testingFlags: EffectiveNodeTestingFlags{ + Enabled: true, + SyntheticLinksEnabled: true, + }, + routeIntents: []MeshRouteIntent{ + { + ID: "route-a-home", + ClusterID: "cluster-1", + SourceSelector: json.RawMessage(`{"node_id":"node-a"}`), + DestinationSelector: json.RawMessage(`{"node_id":"home-1"}`), + ServiceClass: "synthetic", + Status: "active", + Policy: json.RawMessage(`{"synthetic_enabled":true,"hops":["node-a","home-1"]}`), + UpdatedAt: now, + }, + }, + desiredWorkloads: []NodeWorkloadDesiredState{ + { + ClusterID: "cluster-1", + NodeID: "home-1", + ServiceType: "mesh-listener", + DesiredState: "enabled", + Version: &version, + Config: json.RawMessage(`{ + "listen_addr":"0.0.0.0:18080", + "listen_port_mode":"manual", + "advertise_transport":"direct_quic", + "connectivity_mode":"private_lan", + "nat_type":"none", + "region":"home", + "endpoint_candidates":[ + { + "endpoint_id":"home-1-lan", + "address":"quic://192.168.200.85:18080", + "transport":"direct_quic", + "reachability":"private", + "connectivity_mode":"private_lan", + "nat_type":"none", + "priority":1 + }, + { + "endpoint_id":"home-1-isp1", + "address":"quic://94.141.118.222:19199", + "transport":"direct_quic", + "reachability":"public", + "connectivity_mode":"direct", + "nat_type":"port_restricted", + "priority":2, + "metadata":{"provider":"isp1","maps_to":"192.168.200.85:18080"} + } + ] + }`), + UpdatedAt: now, + }, + }, + }) + service.now = func() time.Time { return now } + + cfg, err := service.GetNodeSyntheticMeshConfig(context.Background(), GetNodeSyntheticMeshConfigInput{ + ClusterID: "cluster-1", + NodeID: "node-a", + }) + if err != nil { + t.Fatalf("get synthetic config: %v", err) + } + candidates := cfg.PeerEndpointCandidates["home-1"] + if len(candidates) != 2 { + t.Fatalf("expected two desired candidates: %+v", candidates) + } + if cfg.PeerEndpoints["home-1"] != "quic://192.168.200.85:18080" { + t.Fatalf("expected first candidate as primary endpoint: %+v", cfg.PeerEndpoints) + } + if candidates[1].Address != "quic://94.141.118.222:19199" || + candidates[1].Reachability != "public" || + candidates[1].ConnectivityMode != "direct" || + candidates[1].NATType != "port_restricted" { + t.Fatalf("unexpected public NAT candidate: %+v", candidates[1]) + } + if !json.Valid(candidates[1].Metadata) || !strings.Contains(string(candidates[1].Metadata), "maps_to") { + t.Fatalf("expected NAT mapping metadata to survive: %s", candidates[1].Metadata) + } +} + +func TestEnrichPeerEndpointCandidateCertPinsUsesMapsToAlias(t *testing.T) { + candidates := []PeerEndpointCandidate{ + { + EndpointID: "home-1-public-isp1-19199", + NodeID: "home-1", + Transport: "direct_quic", + Address: "quic://94.141.118.222:19199", + Reachability: "public", + ConnectivityMode: "direct", + Metadata: json.RawMessage(`{"provider":"isp1","maps_to":"192.168.200.85:18080"}`), + }, + { + EndpointID: "home-1-advertised", + NodeID: "home-1", + Transport: "direct_quic", + Address: "quic://192.168.200.85:18080", + Reachability: "private", + ConnectivityMode: "private_lan", + Metadata: json.RawMessage(`{"tls_cert_sha256":"98dedb5916486f97fb732b1603c9850f806be6954b96dc24968da7caca4090ef"}`), + }, + } + + enriched := enrichPeerEndpointCandidateCertPins(candidates) + if len(enriched) != 2 { + t.Fatalf("enriched candidates = %+v", enriched) + } + if got := peerEndpointCandidateTLSCertSHA256(enriched[0]); got != "98dedb5916486f97fb732b1603c9850f806be6954b96dc24968da7caca4090ef" { + t.Fatalf("public NAT-mapped candidate tls cert = %q", got) + } + if !strings.Contains(string(enriched[0].Metadata), "maps_to") { + t.Fatalf("maps_to metadata lost after enrichment: %s", enriched[0].Metadata) + } +} + func TestGetNodeSyntheticMeshConfigKeepsOperatorPublicBootstrapPeerBeyondWarmPeerTarget(t *testing.T) { now := time.Date(2026, 5, 1, 9, 30, 0, 0, time.UTC) version := "home-1-external-19199" @@ -3868,14 +4910,15 @@ func TestIssueFabricServiceChannelLeaseSelectsAuthorizedRoute(t *testing.T) { if lease.Token.Token == "" || lease.Token.TTLSeconds != 90 { t.Fatalf("unexpected token contract: %+v", lease.Token) } - if lease.EntryHTTP.PathTemplate == "" || lease.EntryHTTP.WebSocketPathTemplate == "" { - t.Fatalf("entry http contract must include packet endpoints: %+v", lease.EntryHTTP) + if lease.EntryHTTP.Type != "fabric_quic_only" || lease.EntryHTTP.PathTemplate != "" || lease.EntryHTTP.WebSocketPathTemplate != "" || len(lease.EntryHTTP.SupportedMethods) != 0 { + t.Fatalf("entry http contract must be disabled for QUIC-only fabric: %+v", lease.EntryHTTP) } if lease.DataPlane.SchemaVersion != "rap.fabric_service_channel_data_plane.v1" || - lease.DataPlane.Mode != "fabric_primary" || - lease.DataPlane.WorkingDataTransport != "fabric_service_channel" || + lease.DataPlane.Mode != "fabric_quic_only" || + lease.DataPlane.ControlPlaneTransport != "fabric_control_quic" || + lease.DataPlane.WorkingDataTransport != "fabric_quic_route" || lease.DataPlane.SteadyStateTransport != "fabric_route" || - lease.DataPlane.BackendRelayPolicy != "degraded_fallback_only" || + lease.DataPlane.BackendRelayPolicy != "disabled" || !lease.DataPlane.ProductionForwardingRequired || !lease.DataPlane.ServiceNeutral || !lease.DataPlane.ProtocolAgnostic || @@ -3897,8 +4940,8 @@ func TestIssueFabricServiceChannelLeaseSelectsAuthorizedRoute(t *testing.T) { t.Fatalf("signed payload recovery policy provenance = %+v", signedPayload.RecoveryPolicy) } if signedPayload.DataPlane.SchemaVersion != lease.DataPlane.SchemaVersion || - signedPayload.DataPlane.WorkingDataTransport != "fabric_service_channel" || - signedPayload.DataPlane.BackendRelayPolicy != "degraded_fallback_only" { + signedPayload.DataPlane.WorkingDataTransport != "fabric_quic_route" || + signedPayload.DataPlane.BackendRelayPolicy != "disabled" { t.Fatalf("signed payload data-plane contract = %+v", signedPayload.DataPlane) } store := service.store.(*fakeRepository) @@ -3961,9 +5004,9 @@ func TestFabricServiceChannelLeaseIntrospectionAllowsFreshToken(t *testing.T) { t.Fatalf("unexpected introspection result: %+v", result) } if result.DataPlane.SchemaVersion != "rap.fabric_service_channel_data_plane.v1" || - result.DataPlane.WorkingDataTransport != "fabric_service_channel" || + result.DataPlane.WorkingDataTransport != "fabric_quic_route" || result.DataPlane.SteadyStateTransport != "fabric_route" || - result.DataPlane.BackendRelayPolicy != "degraded_fallback_only" { + result.DataPlane.BackendRelayPolicy != "disabled" { t.Fatalf("unexpected introspection data-plane contract: %+v", result.DataPlane) } } @@ -4148,12 +5191,12 @@ func TestFabricServiceChannelAccessTelemetryAggregatesNodeReports(t *testing.T) "signed": 3, "introspection": 4, "legacy_unsigned": 0, - "backend_fallback": 2, + "backend_fallback": 0, "data_plane_contract": 5, - "last_data_plane_mode": "fabric_primary", - "last_working_data_transport": "fabric_service_channel", + "last_data_plane_mode": "fabric_quic_only", + "last_working_data_transport": "fabric_quic_route", "last_steady_state_transport": "fabric_route", - "last_backend_relay_policy": "degraded_fallback_only", + "last_backend_relay_policy": "disabled", "last_logical_flow_mode": "multi_flow_isolated", "last_accepted_at": "2026-05-08T15:19:59Z" } @@ -4211,13 +5254,13 @@ func TestFabricServiceChannelAccessTelemetryAggregatesNodeReports(t *testing.T) if err != nil { t.Fatalf("get access telemetry: %v", err) } - if report.ReportingNodeCount != 1 || report.TotalAccepted != 7 || report.SignedAccepted != 3 || report.IntrospectionAccepted != 4 || report.BackendFallbackCount != 2 { + if report.ReportingNodeCount != 1 || report.TotalAccepted != 7 || report.SignedAccepted != 3 || report.IntrospectionAccepted != 4 || report.BackendFallbackCount != 0 { t.Fatalf("unexpected access telemetry: %+v", report) } - if report.DataPlaneContractCount != 5 || report.LastDataPlaneMode != "fabric_primary" || report.LastWorkingDataTransport != "fabric_service_channel" || report.LastSteadyStateTransport != "fabric_route" || report.LastBackendRelayPolicy != "degraded_fallback_only" || report.LastLogicalFlowMode != "multi_flow_isolated" { + if report.DataPlaneContractCount != 5 || report.LastDataPlaneMode != "fabric_quic_only" || report.LastWorkingDataTransport != "fabric_quic_route" || report.LastSteadyStateTransport != "fabric_route" || report.LastBackendRelayPolicy != "disabled" || report.LastLogicalFlowMode != "multi_flow_isolated" { t.Fatalf("unexpected aggregate data-plane telemetry: %+v", report) } - if report.Nodes[0].DataPlaneContractCount != 5 || report.Nodes[0].LastWorkingDataTransport != "fabric_service_channel" || report.Nodes[0].LastBackendRelayPolicy != "degraded_fallback_only" || report.Nodes[0].LastLogicalFlowMode != "multi_flow_isolated" { + if report.Nodes[0].DataPlaneContractCount != 5 || report.Nodes[0].LastWorkingDataTransport != "fabric_quic_route" || report.Nodes[0].LastBackendRelayPolicy != "disabled" || report.Nodes[0].LastLogicalFlowMode != "multi_flow_isolated" { t.Fatalf("unexpected node data-plane telemetry: %+v", report.Nodes[0]) } if got := report.Nodes[0].TrafficClassCounts["bulk"]; got != 32 { @@ -4226,7 +5269,7 @@ func TestFabricServiceChannelAccessTelemetryAggregatesNodeReports(t *testing.T) if report.TrafficClassCounts["bulk"] != 32 || report.TrafficClassCounts["interactive"] != 12 || report.FlowChannelCount != 44 || report.FlowMaxInFlight != 4 { t.Fatalf("unexpected aggregate flow telemetry: %+v", report) } - if report.FlowHealthStatus != "degraded" || report.FlowHealthReason != "backend_fallback_observed" { + if report.FlowHealthStatus != "degraded" || report.FlowHealthReason != "route_quality_window_slow_samples_reported" { t.Fatalf("unexpected aggregate flow health: %+v", report) } if !report.AdaptiveBackpressureActive || report.AdaptiveBackpressureReason != "bulk_window_reduced_to_protect_interactive" || report.RecommendedParallelWindows["bulk"] != 1 || report.RecommendedParallelWindows["interactive"] != 4 { @@ -4235,7 +5278,7 @@ func TestFabricServiceChannelAccessTelemetryAggregatesNodeReports(t *testing.T) if report.Nodes[0].FlowChannelCount != 44 || report.Nodes[0].FlowHighWatermark != 25 || report.Nodes[0].FlowMaxInFlight != 4 { t.Fatalf("unexpected flow telemetry on node: %+v", report.Nodes[0]) } - if report.Nodes[0].FlowHealthStatus != "degraded" || report.Nodes[0].FlowHealthReason != "backend_fallback_observed" { + if report.Nodes[0].FlowHealthStatus != "watch" || report.Nodes[0].FlowHealthReason != "bulk_pressure_with_interactive_qos_observed" { t.Fatalf("unexpected node flow health: %+v", report.Nodes[0]) } if !report.Nodes[0].AdaptiveBackpressureActive || report.Nodes[0].RecommendedParallelWindows["control"] != 4 || report.Nodes[0].RecommendedParallelWindows["droppable"] != 1 { @@ -4251,13 +5294,13 @@ func TestFabricServiceChannelAccessTelemetryAggregatesNodeReports(t *testing.T) if channel.ChannelID != "channel-1" || channel.EntryNodeTotalAccepted != 7 || channel.RouteFeedbackStatus != "healthy" || channel.RouteQualityWindowSampleCount != 5 || channel.LastSendDurationMs != 42 { t.Fatalf("unexpected active channel correlation: %+v", channel) } - if channel.EntryNodeDataPlaneContractCount != 5 || channel.EntryNodeLastDataPlaneMode != "fabric_primary" || channel.EntryNodeLastWorkingDataTransport != "fabric_service_channel" || channel.EntryNodeLastSteadyStateTransport != "fabric_route" || channel.EntryNodeLastBackendRelayPolicy != "degraded_fallback_only" || channel.EntryNodeLastLogicalFlowMode != "multi_flow_isolated" { + if channel.EntryNodeDataPlaneContractCount != 5 || channel.EntryNodeLastDataPlaneMode != "fabric_quic_only" || channel.EntryNodeLastWorkingDataTransport != "fabric_quic_route" || channel.EntryNodeLastSteadyStateTransport != "fabric_route" || channel.EntryNodeLastBackendRelayPolicy != "disabled" || channel.EntryNodeLastLogicalFlowMode != "multi_flow_isolated" { t.Fatalf("unexpected active channel data-plane telemetry: %+v", channel) } if channel.EntryNodeTrafficClassCounts["interactive"] != 12 || channel.EntryNodeFlowChannelCount != 44 || channel.EntryNodeFlowMaxInFlight != 4 { t.Fatalf("unexpected active channel flow telemetry: %+v", channel) } - if channel.EntryNodeFlowHealthStatus != "degraded" || channel.EntryNodeFlowHealthReason != "backend_fallback_observed" { + if channel.EntryNodeFlowHealthStatus != "degraded" || channel.EntryNodeFlowHealthReason != "route_quality_window_slow_samples_reported" { t.Fatalf("unexpected channel flow health: %+v", channel) } if !channel.EntryNodeAdaptiveBackpressureActive || channel.EntryNodeAdaptiveBackpressureReason != "bulk_window_reduced_to_protect_interactive" || channel.EntryNodeRecommendedParallelWindows["bulk"] != 1 { @@ -4273,13 +5316,8 @@ func TestFabricServiceChannelAccessTelemetryAggregatesNodeReports(t *testing.T) if err != nil { t.Fatalf("list rebuild incidents: %v", err) } - if len(incidents) == 0 || - incidents[0].IncidentSource != "data_plane_contract" || - incidents[0].ChannelID != "channel-1" || - incidents[0].GuardStatus != "data_plane_degraded_backend_relay_observed" || - incidents[0].GuardSeverity != "warn" || - incidents[0].RecommendedOperatorAction != "restore_fabric_route_and_treat_backend_relay_as_degraded_only" { - t.Fatalf("unexpected data-plane incident projection: %+v", incidents) + if len(incidents) != 0 { + t.Fatalf("degraded backend relay incidents must not be projected in QUIC-only fabric: %+v", incidents) } } @@ -5230,10 +6268,8 @@ func TestIssueFabricServiceChannelLeaseMarksBackendRelayAsDegradedFallbackWhenRo if !containsString(lease.AllowedChannels, FabricChannelInteractive) || !containsString(lease.RequiredRoles, "rdp-worker") { t.Fatalf("remote workspace defaults not applied: channels=%v roles=%v", lease.AllowedChannels, lease.RequiredRoles) } - if strings.Contains(lease.EntryHTTP.PathTemplate, "vpn-connections") || - !strings.Contains(lease.EntryHTTP.PathTemplate, "remote-workspaces") || - lease.EntryHTTP.PacketBatchFormat != "application/vnd.rap.remote-workspace-frame-batch.v1" { - t.Fatalf("remote workspace ingress should not be vpn-specific: %+v", lease.EntryHTTP) + if lease.EntryHTTP.Type != "fabric_quic_only" || lease.EntryHTTP.PathTemplate != "" || lease.EntryHTTP.WebSocketPathTemplate != "" { + t.Fatalf("remote workspace ingress must not expose HTTP/WebSocket transport paths: %+v", lease.EntryHTTP) } if lease.DataPlane.StableContractForServiceClass != FabricServiceClassRemoteWorkspace || !lease.DataPlane.ServiceNeutral || @@ -5273,11 +6309,8 @@ func TestIssueFabricServiceChannelLeaseUsesServiceClassAwareIngressDescriptors(t if err != nil { t.Fatalf("issue lease: %v", err) } - if !strings.Contains(lease.EntryHTTP.PathTemplate, tt.pathNeedle) { - t.Fatalf("PathTemplate = %q, want %q", lease.EntryHTTP.PathTemplate, tt.pathNeedle) - } - if lease.EntryHTTP.PacketBatchFormat != tt.packetMedia { - t.Fatalf("PacketBatchFormat = %q, want %q", lease.EntryHTTP.PacketBatchFormat, tt.packetMedia) + if lease.EntryHTTP.Type != "fabric_quic_only" || lease.EntryHTTP.PathTemplate != "" || lease.EntryHTTP.WebSocketPathTemplate != "" || lease.EntryHTTP.PacketBatchFormat != "" { + t.Fatalf("EntryHTTP must be disabled for QUIC-only fabric: %+v", lease.EntryHTTP) } if lease.DataPlane.StableContractForServiceClass != tt.service { t.Fatalf("StableContractForServiceClass = %q, want %q", lease.DataPlane.StableContractForServiceClass, tt.service) @@ -7977,10 +9010,11 @@ type fakeRepository struct { cluster Cluster lastPreferredEntryNodeID string lastPreferredExitNodeID string + platformRoleErr error } func (f *fakeRepository) GetPlatformRole(context.Context, string) (string, error) { - return f.platformRole, nil + return f.platformRole, f.platformRoleErr } func (f *fakeRepository) ListClusters(context.Context) ([]Cluster, error) { diff --git a/backend/internal/modules/nodeagent/module.go b/backend/internal/modules/nodeagent/module.go index 35994a1..630b477 100644 --- a/backend/internal/modules/nodeagent/module.go +++ b/backend/internal/modules/nodeagent/module.go @@ -158,6 +158,7 @@ func (m *Module) bootstrapEnrollment(w http.ResponseWriter, r *http.Request) { func (m *Module) registerAgent(w http.ResponseWriter, r *http.Request) { var payload struct { + ClusterID string `json:"cluster_id"` NodeKey string `json:"node_key"` Name string `json:"name"` OwnershipType string `json:"ownership_type"` @@ -197,6 +198,19 @@ func (m *Module) registerAgent(w http.ResponseWriter, r *http.Request) { httpx.WriteError(w, http.StatusInternalServerError, err.Error()) return } + if payload.ClusterID != "" { + if _, err := m.db.Exec(r.Context(), ` + INSERT INTO cluster_memberships (cluster_id, node_id, membership_status, joined_at, last_seen_at, metadata) + VALUES ($1::uuid, $2::uuid, 'active', $3, $3, $4::jsonb) + ON CONFLICT (cluster_id, node_id) DO UPDATE SET + membership_status = 'active', + last_seen_at = EXCLUDED.last_seen_at, + metadata = cluster_memberships.metadata || EXCLUDED.metadata + `, payload.ClusterID, nodeID, now, []byte(`{"source":"fabric_control_candidate_registration"}`)); err != nil { + httpx.WriteError(w, http.StatusInternalServerError, err.Error()) + return + } + } httpx.WriteJSON(w, http.StatusOK, map[string]any{ "node_id": nodeID, "status": "registered", diff --git a/clients/android/.gradle/9.5.0/executionHistory/executionHistory.bin b/clients/android/.gradle/9.5.0/executionHistory/executionHistory.bin index 32c1308..33907f4 100644 Binary files a/clients/android/.gradle/9.5.0/executionHistory/executionHistory.bin and b/clients/android/.gradle/9.5.0/executionHistory/executionHistory.bin differ diff --git a/clients/android/.gradle/9.5.0/executionHistory/executionHistory.lock b/clients/android/.gradle/9.5.0/executionHistory/executionHistory.lock index 559f1ce..96faba5 100644 Binary files a/clients/android/.gradle/9.5.0/executionHistory/executionHistory.lock and b/clients/android/.gradle/9.5.0/executionHistory/executionHistory.lock differ diff --git a/clients/android/.gradle/9.5.0/fileHashes/fileHashes.bin b/clients/android/.gradle/9.5.0/fileHashes/fileHashes.bin index 71409c8..52a22f3 100644 Binary files a/clients/android/.gradle/9.5.0/fileHashes/fileHashes.bin and b/clients/android/.gradle/9.5.0/fileHashes/fileHashes.bin differ diff --git a/clients/android/.gradle/9.5.0/fileHashes/fileHashes.lock b/clients/android/.gradle/9.5.0/fileHashes/fileHashes.lock index 611b7ed..372daae 100644 Binary files a/clients/android/.gradle/9.5.0/fileHashes/fileHashes.lock and b/clients/android/.gradle/9.5.0/fileHashes/fileHashes.lock differ diff --git a/clients/android/.gradle/9.5.0/fileHashes/resourceHashesCache.bin b/clients/android/.gradle/9.5.0/fileHashes/resourceHashesCache.bin index 97fb248..d5b7bfc 100644 Binary files a/clients/android/.gradle/9.5.0/fileHashes/resourceHashesCache.bin and b/clients/android/.gradle/9.5.0/fileHashes/resourceHashesCache.bin differ diff --git a/clients/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock b/clients/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock index 55e8239..992aa72 100644 Binary files a/clients/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock and b/clients/android/.gradle/buildOutputCleanup/buildOutputCleanup.lock differ diff --git a/clients/android/.gradle/buildOutputCleanup/outputFiles.bin b/clients/android/.gradle/buildOutputCleanup/outputFiles.bin index d29c761..52e5047 100644 Binary files a/clients/android/.gradle/buildOutputCleanup/outputFiles.bin and b/clients/android/.gradle/buildOutputCleanup/outputFiles.bin differ diff --git a/clients/android/README.md b/clients/android/README.md index cbff44a..f3656d9 100644 --- a/clients/android/README.md +++ b/clients/android/README.md @@ -1,18 +1,25 @@ # RAP Android VPN -This is the Android client for the experimental RAP VPN service. +This is the Android mobile node build with the `vpn-client` service enabled. Implemented now: -- login through `/auth/login`; -- trusted-device reconnect through `/auth/refresh` without retyping the password - while the device session is valid; -- load organization-scoped VPN client profile from `/clusters/{clusterID}/vpn/client-profile`; +- installation as a first-class fabric node with an embedded QUIC bootstrap + seed set. The seed set is not a backend selector: it contains every known + public or local entry candidate that may help the node join the fabric from + its current network. +- runtime launch uses a persisted `fabric_bootstrap_config`, not a backend API + URL. The Android node starts by attaching to the fabric through bootstrap + peers and then discovers/uses services through fabric rules. +- login and trusted-device refresh through the QUIC fabric control channel; +- load organization-scoped VPN client profile through the fabric control channel; - request Android VPN permission and create a `VpnService` TUN interface; - run as a normal fabric node with the `vpn-client` service role. The local `VpnService` TUN is the IPv4 ingress for that node, and packet channels are - routed by the farm to an authorized `ipv4-egress` pool. HTTP batch fallback - and old VPN protocols are not part of the supported test path. + routed by the farm to an authorized `ipv4-egress` pool. The supported + dataplane is the QUIC fabric runtime only. HTTP batch forwarding, WebSocket + packet relay, direct backend packet relay, and old VPN protocols are removed + from the runtime path. - user-facing HOME-first screen: connect/disconnect is primary, while backend, cluster, organization, login, and password are kept in the settings dialog; - saved connection settings in app preferences so repeat connects do not require @@ -20,12 +27,23 @@ Implemented now: - encrypted refresh-token storage through Android Keystore. If the trusted device session is revoked or expires, the app asks for the password once and then rotates the device keys/profile again. +- no separate diagnostic foreground service: runtime status is reported by the + node/VPN service itself, so the Android build does not keep a parallel legacy + control process alive. This is still a lab runtime. The required target model is Android as a farm node with the `vpn-client` role. The VPN service must attach to the mesh as that node and route to an authorized IPv4 exit pool; there is no separate VPN entry point. Exit configuration is always pool based, including pools that -currently contain only one node. +currently contain only one node. A phone installed in a closed network may join +through local seed nodes from that network; it does not need direct Internet +access if a nearby fabric node can route onward. + +Current code contract: + +- Android control bootstrap field: `fabric_bootstrap_config` +- Android runtime dataplane: QUIC `Fabricvpn` runtime only +- Android runtime status keys: `fabric_transport_*` Build from this repository on Windows: diff --git a/clients/android/app/build.gradle b/clients/android/app/build.gradle index 29d04f3..da9caba 100644 --- a/clients/android/app/build.gradle +++ b/clients/android/app/build.gradle @@ -22,8 +22,12 @@ android { return (value == null ? "" : value.toString()).replace("\\", "\\\\").replace("\"", "\\\"") } - def defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: "http://192.168.200.61:18080/api/v1" - def defaultFabricBootstrapPeers = project.findProperty("RAP_ANDROID_FABRIC_BOOTSTRAP_PEERS") ?: "quic://192.168.200.85:18080,quic://195.123.240.88:19131" + def defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: "" + // This is a node bootstrap seed set, not an API/backend selector. The + // Android app installs as a fabric node and tries every QUIC endpoint that + // may be reachable from its current network: public nodes, LAN nodes, or a + // closed-site neighbor that can route onward through the fabric. + def defaultFabricBootstrapPeers = project.findProperty("RAP_ANDROID_FABRIC_BOOTSTRAP_PEERS") ?: "quic://94.141.118.222:19199#sha256=49892029a27db9c394a41bc4cb917d9cceb1f86219417c351764d2ed9d6bc683,quic://94.141.118.222:19191#sha256=72e51f1631b32c3a7d1e8732fe3325e0395a897a5aa31db645888c142e4ae401,quic://192.168.200.61:19134#sha256=72e51f1631b32c3a7d1e8732fe3325e0395a897a5aa31db645888c142e4ae401,quic://192.168.200.61:19132#sha256=8d28b75144d25d29e3b8f8022b6165258ce3cb0e227a2d9d97996839abb89c2a,quic://192.168.200.61:19133#sha256=a71b07e55b810f57b01696c485b765b336983e963238163085824bf04022ecaa,quic://192.168.200.85:18080#sha256=49892029a27db9c394a41bc4cb917d9cceb1f86219417c351764d2ed9d6bc683,quic://192.168.200.85:18081#sha256=2a3be67e6345943a36cfa1197a5879c2b112c81adc019fd1ee9d7dffbf188b57,quic://192.168.200.85:18082#sha256=a318c1a756ff43595635961768dfd1677afa7e2cbf945d724c107ff82426378a" def defaultClusterId = project.findProperty("RAP_ANDROID_DEFAULT_CLUSTER_ID") ?: "cfc0743d-d960-49fb-9de8-96e063d5e4aa" def defaultOrganizationId = project.findProperty("RAP_ANDROID_DEFAULT_ORGANIZATION_ID") ?: "125ff8b2-5ac1-4406-9bbb-ebbe18f7c7ed" @@ -31,8 +35,8 @@ android { applicationId "su.cin.rapvpn" minSdk 26 targetSdk 35 - versionCode 227 - versionName "0.2.227" + versionCode 239 + versionName "0.2.239" buildConfigField "String", "DEFAULT_BACKEND_URL", "\"${normalizeGradleString(defaultBackendUrl)}\"" buildConfigField "String", "FABRIC_BOOTSTRAP_PEERS", "\"${normalizeGradleString(defaultFabricBootstrapPeers)}\"" buildConfigField "String", "DEFAULT_CLUSTER_ID", "\"${normalizeGradleString(defaultClusterId)}\"" diff --git a/clients/android/app/libs/rap-fabricvpn-sources.jar b/clients/android/app/libs/rap-fabricvpn-sources.jar index b87527a..b8a5a88 100644 Binary files a/clients/android/app/libs/rap-fabricvpn-sources.jar and b/clients/android/app/libs/rap-fabricvpn-sources.jar differ diff --git a/clients/android/app/libs/rap-fabricvpn.aar b/clients/android/app/libs/rap-fabricvpn.aar index c0cb082..d388b9f 100644 Binary files a/clients/android/app/libs/rap-fabricvpn.aar and b/clients/android/app/libs/rap-fabricvpn.aar differ diff --git a/clients/android/app/src/main/AndroidManifest.xml b/clients/android/app/src/main/AndroidManifest.xml index 5c21020..3298ffc 100644 --- a/clients/android/app/src/main/AndroidManifest.xml +++ b/clients/android/app/src/main/AndroidManifest.xml @@ -42,15 +42,6 @@ android:value="vpn" /> - - - - diff --git a/clients/android/app/src/main/java/su/cin/rapvpn/FabricServiceChannel.java b/clients/android/app/src/main/java/su/cin/rapvpn/FabricServiceChannel.java deleted file mode 100644 index 933be35..0000000 --- a/clients/android/app/src/main/java/su/cin/rapvpn/FabricServiceChannel.java +++ /dev/null @@ -1,140 +0,0 @@ -package su.cin.rapvpn; - -import android.util.Base64; - -import org.json.JSONObject; - -import java.net.URI; -import java.nio.charset.StandardCharsets; - -import okhttp3.Request; - -final class FabricServiceChannel { - final boolean enabled; - final String channelId; - final String token; - final String pathTemplate; - final String webSocketPathTemplate; - final String authorityPayloadHeader; - final String authoritySignatureHeader; - final String serviceClass; - final String channelClass; - - FabricServiceChannel() { - this(false, "", "", "", "", "", "", "", ""); - } - - private FabricServiceChannel( - boolean enabled, - String channelId, - String token, - String pathTemplate, - String webSocketPathTemplate, - String authorityPayloadHeader, - String authoritySignatureHeader, - String serviceClass, - String channelClass) { - this.enabled = enabled; - this.channelId = safe(channelId); - this.token = safe(token); - this.pathTemplate = safe(pathTemplate); - this.webSocketPathTemplate = safe(webSocketPathTemplate); - this.authorityPayloadHeader = safe(authorityPayloadHeader); - this.authoritySignatureHeader = safe(authoritySignatureHeader); - this.serviceClass = safe(serviceClass); - this.channelClass = safe(channelClass); - } - - static FabricServiceChannel fromLease(JSONObject lease) { - if (lease == null) { - return new FabricServiceChannel(); - } - JSONObject tokenObject = lease.optJSONObject("token"); - JSONObject entryHttp = lease.optJSONObject("entry_http"); - String channelId = lease.optString("channel_id", ""); - String token = tokenObject == null ? "" : tokenObject.optString("token", ""); - String pathTemplate = entryHttp == null ? "" : entryHttp.optString("path_template", ""); - String wsTemplate = entryHttp == null ? "" : entryHttp.optString("websocket_path_template", ""); - String serviceClass = lease.optString("service_class", "vpn_packets"); - String channelClass = "vpn_packet"; - JSONObject authoritySignature = lease.optJSONObject("authority_signature"); - JSONObject authorityPayload = lease.optJSONObject("authority_payload"); - String payloadHeader = authorityPayload == null ? "" : encodeHeader(authorityPayload.toString()); - String signatureHeader = authoritySignature == null ? "" : encodeHeader(authoritySignature.toString()); - boolean enabled = !channelId.isEmpty() && token.startsWith("rap_fsc_") && !pathTemplate.isEmpty(); - return new FabricServiceChannel(enabled, channelId, token, pathTemplate, wsTemplate, payloadHeader, signatureHeader, serviceClass, channelClass); - } - - String packetPath(String clusterId, String vpnConnectionId, boolean webSocket) { - return packetPathForBase("", clusterId, vpnConnectionId, webSocket); - } - - String packetPathForBase(String baseUrl, String clusterId, String vpnConnectionId, boolean webSocket) { - String template = webSocket && !webSocketPathTemplate.isEmpty() ? webSocketPathTemplate : pathTemplate; - if (!enabled || template.isEmpty()) { - return ""; - } - String path = template - .replace("{cluster_id}", safe(clusterId)) - .replace("{clusterID}", safe(clusterId)) - .replace("{channel_id}", channelId) - .replace("{channelID}", channelId) - .replace("{resource_id}", safe(vpnConnectionId)) - .replace("{resourceID}", safe(vpnConnectionId)) - .replace("{vpn_connection_id}", safe(vpnConnectionId)) - .replace("{vpnConnectionID}", safe(vpnConnectionId)); - path = path.startsWith("/") ? path : "/" + path; - String basePath = ""; - try { - URI uri = URI.create(baseUrl == null ? "" : baseUrl); - basePath = uri.getRawPath() == null ? "" : trimRight(uri.getRawPath()); - } catch (Exception ignored) { - } - if (basePath.endsWith("/api/v1") && path.startsWith("/api/v1/")) { - path = path.substring("/api/v1".length()); - } - return path; - } - - Request.Builder applyHeaders(Request.Builder builder) { - if (!enabled || builder == null) { - return builder; - } - builder.header("X-RAP-Service-Channel-Token", token); - builder.header("X-RAP-Fabric-Channel-ID", channelId); - if (!serviceClass.isEmpty()) { - builder.header("X-RAP-Service-Class", serviceClass); - } - if (!channelClass.isEmpty()) { - builder.header("X-RAP-Channel-Class", channelClass); - } - if (!authorityPayloadHeader.isEmpty()) { - builder.header("X-RAP-Service-Channel-Authority-Payload", authorityPayloadHeader); - } - if (!authoritySignatureHeader.isEmpty()) { - builder.header("X-RAP-Service-Channel-Authority-Signature", authoritySignatureHeader); - } - return builder; - } - - private static String encodeHeader(String value) { - if (value == null || value.isEmpty()) { - return ""; - } - return Base64.encodeToString(value.getBytes(StandardCharsets.UTF_8), Base64.URL_SAFE | Base64.NO_WRAP | Base64.NO_PADDING); - } - - private static String safe(String value) { - return value == null ? "" : value.trim(); - } - - private static String trimRight(String value) { - if (value == null) { - return ""; - } - while (value.endsWith("/")) { - value = value.substring(0, value.length() - 1); - } - return value; - } -} diff --git a/clients/android/app/src/main/java/su/cin/rapvpn/MainActivity.java b/clients/android/app/src/main/java/su/cin/rapvpn/MainActivity.java index ec25a7c..d7a0cf9 100644 --- a/clients/android/app/src/main/java/su/cin/rapvpn/MainActivity.java +++ b/clients/android/app/src/main/java/su/cin/rapvpn/MainActivity.java @@ -24,14 +24,13 @@ import java.util.Locale; public class MainActivity extends Activity { private static final String APP_VERSION = BuildConfig.VERSION_NAME; - private static final String DEFAULT_BACKEND_URL = BuildConfig.DEFAULT_BACKEND_URL; private static final String FABRIC_BOOTSTRAP_PEERS = BuildConfig.FABRIC_BOOTSTRAP_PEERS; private static final String DEFAULT_CLUSTER_ID = BuildConfig.DEFAULT_CLUSTER_ID; private static final String DEFAULT_ORGANIZATION_ID = BuildConfig.DEFAULT_ORGANIZATION_ID; - private static final String PREF_SELECTED_EXIT_NODE_ID = "selected_exit_node_id"; private static final int VPN_PREPARE_REQUEST = 42; private static final String PREFS = "rap-vpn"; private static final String PREF_DEVICE_FINGERPRINT = "device_fingerprint"; + private static final String PREF_FABRIC_NODE_ID = "fabric_node_id"; private static final String PREF_REFRESH_TOKEN = "refresh_token"; private static final String PREF_REFRESH_EXPIRES_AT = "refresh_expires_at"; private static final String PREF_USER_ID = "user_id"; @@ -39,7 +38,6 @@ public class MainActivity extends Activity { private static final String PREF_PROFILE_JSON = "profile_json"; private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id"; static final String PREF_FORCE_FULL_TUNNEL = "force_full_tunnel"; - private EditText backendUrl; private EditText clusterId; private EditText organizationId; private EditText email; @@ -66,7 +64,6 @@ public class MainActivity extends Activity { int pad = dp(20); root.setPadding(pad, pad, pad, pad); - backendUrl = field("Fabric control bootstrap", preferredBackendUrl()); clusterId = field("Cluster ID", prefs.getString("cluster_id", DEFAULT_CLUSTER_ID)); organizationId = field("Organization ID", prefs.getString("organization_id", DEFAULT_ORGANIZATION_ID)); email = field("Email", prefs.getString("email", "m")); @@ -102,10 +99,6 @@ public class MainActivity extends Activity { runtimeStatus.setPadding(0, 0, 0, dp(10)); runtimeStatus.setText(runtimeStatusText()); - Button load = new Button(this); - load.setText("Войти / обновить пулы"); - load.setOnClickListener(v -> loadProfile(false)); - Button start = new Button(this); start.setText("Подключить"); start.setOnClickListener(v -> prepareVpn()); @@ -148,12 +141,11 @@ public class MainActivity extends Activity { }); Button settings = new Button(this); - settings.setText("Аккаунт"); + settings.setText("Настройка"); settings.setOnClickListener(v -> showSettingsDialog()); root.addView(title); root.addView(profileSummary); - root.addView(load); root.addView(start); root.addView(stop); root.addView(settings); @@ -161,9 +153,7 @@ public class MainActivity extends Activity { root.addView(runtimeStatus); setContentView(root); scheduleRuntimeStatusRefresh(); - if (authContext != null && !authContext.deviceId.isEmpty()) { - startDiagnosticChannel(); - } + registerCandidateNodeAsync(false); } @Override @@ -179,62 +169,38 @@ public class MainActivity extends Activity { return input; } - private void loadProfile() { - loadProfile(false); - } - - private void loadProfile(boolean startAfterLoad) { - status.setText("Загрузка..."); - saveSettings(); + private void prepareVpn() { + if (!hasSelectedPool()) { + status.setText("Сначала выберите выходной пул."); + showSettingsDialog(); + return; + } + status.setText("Проверяю доступ к выбранному пулу..."); new Thread(() -> { try { - RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this); - authContext = authenticate(client); - String activeOrganizationId = resolveOrganizationId(client, authContext.userId); - profileJson = client.vpnClientProfile( - clusterId.getText().toString(), - activeOrganizationId, - authContext.userId, - "" - ); - vpnConnectionId = firstConnectionId(profileJson); - saveProfileState(); + refreshSavedProfileForCurrentUser(); + if (!hasSelectedPool()) { + throw new IllegalStateException("Выбранный пул больше не доступен."); + } runOnUiThread(() -> { profileSummary.setText(summaryText()); - status.setText(startAfterLoad ? "Список пулов обновлен. Подключаю..." : "Список доступных пулов обновлен."); - startDiagnosticChannel(); - if (startAfterLoad) { - requestVpnPermission(); - } + status.setText("Доступ подтвержден. Подключаюсь к выбранному пулу."); + requestVpnPermission(); }); } catch (Exception ex) { runOnUiThread(() -> { String message = friendlyError(ex); - boolean canUseSavedProfile = startAfterLoad && !profileJson.isEmpty() && !vpnConnectionId.isEmpty(); - if (canUseSavedProfile) { - status.setText("Список пулов сейчас не обновился: " + message + ". Подключаюсь с сохраненным рабочим профилем."); - startDiagnosticChannel(); - requestVpnPermission(); - return; - } - status.setText("Ошибка входа: " + message); - if (message.contains("логин") || message.contains("пароль") || message.contains("Сессия устройства")) { - clearSavedAuth(false); - showSettingsDialog(); - } + status.setText("Нужна настройка: " + message); + showSettingsDialog(); }); } }).start(); } - private void prepareVpn() { - loadProfile(true); - status.setText("Обновляю сессию устройства и доступные пулы..."); - } - private void requestVpnPermission() { - if (profileJson.isEmpty()) { - status.setText("VPN-профиль не загружен."); + if (!hasSelectedPool()) { + status.setText("Выходной пул не выбран или больше не доступен."); + showSettingsDialog(); return; } Intent prepare = VpnService.prepare(this); @@ -254,32 +220,37 @@ public class MainActivity extends Activity { } private void startVpn() { - Intent intent = new Intent(this, RapVpnService.class); - intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson); - intent.putExtra(RapVpnService.EXTRA_BACKEND_URL, backendUrl.getText().toString()); - intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId.getText().toString()); - intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId); - startForegroundService(intent); - status.setText("VPN подключается через ферму. Версия " + APP_VERSION + ". Ожидаю рабочий канал."); - runtimeStatus.setText("Запрашиваю статус... " + runtimeStatusText()); - runtimeStatus.postDelayed(() -> { - String state = runtimePrefs.getString("state", ""); - boolean runtimeActive = isVpnRuntimeActive(); - if (!isSystemVpnActive()) { - if (runtimeActive) { - status.setText("VPN runtime активен, рабочий канал поднят. Android еще обновляет системный статус."); - } else if ("stopped".equals(state) || "revoked".equals(state) || "error".equals(state)) { - status.setText("VPN не включился: " + runtimePrefs.getString("message", "Android остановил VPN-сервис") + "."); - } else if ("starting".equals(state) || "tunnel".equals(state) || "relay_selected".equals(state) || "relay".equals(state) || "relay_reset".equals(state)) { - status.setText("VPN запускается. Android еще применяет туннель, ожидаю рабочий канал."); + try { + Intent intent = new Intent(this, RapVpnService.class); + intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson); + intent.putExtra(RapVpnService.EXTRA_FABRIC_BOOTSTRAP_CONFIG, fabricControlConfig()); + intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId.getText().toString()); + intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId); + startForegroundService(intent); + status.setText("VPN подключается через ферму. Версия " + APP_VERSION + ". Ожидаю рабочий канал."); + runtimeStatus.setText("Запрашиваю статус... " + runtimeStatusText()); + runtimeStatus.postDelayed(() -> { + String state = runtimePrefs.getString("state", ""); + boolean runtimeActive = isVpnRuntimeActive(); + if (!isSystemVpnActive()) { + if (runtimeActive) { + status.setText("VPN runtime активен, рабочий канал поднят. Android еще обновляет системный статус."); + } else if ("stopped".equals(state) || "revoked".equals(state) || "error".equals(state)) { + status.setText("VPN не включился: " + runtimePrefs.getString("message", "Android остановил VPN-сервис") + "."); + } else if ("starting".equals(state) || "tunnel".equals(state) || isTransportWarmupState(state)) { + status.setText("VPN запускается. Android еще применяет туннель, ожидаю рабочий канал."); + } else { + status.setText("VPN еще не активен в Android. Проверьте системный запрос разрешения VPN."); + } } else { - status.setText("VPN еще не активен в Android. Проверьте системный запрос разрешения VPN."); + status.setText("VPN включен Android. Версия " + APP_VERSION + "."); } - } else { - status.setText("VPN включен Android. Версия " + APP_VERSION + "."); - } - runtimeStatus.setText(runtimeStatusText()); - }, 2500); + runtimeStatus.setText(runtimeStatusText()); + }, 2500); + } catch (Exception e) { + status.setText("VPN не запущен: bootstrap-конфиг фабрики недоступен."); + runtimeStatus.setText("Ошибка запуска: " + e.getMessage()); + } } private void scheduleRuntimeStatusRefresh() { @@ -335,9 +306,9 @@ public class MainActivity extends Activity { boolean osVpnActive = isSystemVpnActive(); String routes = runtimePrefs.getString("routes", ""); String dnsServers = runtimePrefs.getString("dns_servers", ""); - String profileRelayUrl = runtimePrefs.getString("packet_relay_profile_base_url", ""); - String activeRelayUrl = runtimePrefs.getString("packet_relay_active_base_url", ""); - String relayCandidates = runtimePrefs.getString("packet_relay_candidate_urls", ""); + String profileTransportEndpoint = runtimePrefs.getString("fabric_transport_profile_endpoint", ""); + String activeTransportEndpoint = runtimePrefs.getString("fabric_transport_active_endpoint", ""); + String transportCandidates = runtimePrefs.getString("fabric_transport_candidate_endpoints", ""); boolean forceFullTunnelRuntime = false; boolean fastPathEnabled = false; try { @@ -350,11 +321,14 @@ public class MainActivity extends Activity { } boolean staleState = updatedAt > 0 && (System.currentTimeMillis() - updatedAt) > 12_000; boolean runtimeActive = isVpnRuntimeActive(); - if (!osVpnActive && !runtimeActive && ("running".equals(state) || "tunnel".equals(state) || "relay".equals(state) || "relay_reset".equals(state))) { + if (!osVpnActive && !runtimeActive && ("running".equals(state) || "tunnel".equals(state) || isTransportWarmupState(state))) { state = "stale_no_os_vpn"; message = "Сервис говорит об активном состоянии, но Android VPN-интерфейс не активен. Проверьте разрешения/ручной запуск."; staleState = false; } + String transportEndpoint = activeTransportEndpoint.isEmpty() ? "-" : activeTransportEndpoint; + String transportTargets = transportCandidates.isEmpty() ? "-" : transportCandidates; + String profileTarget = profileTransportEndpoint.isEmpty() ? "-" : profileTransportEndpoint; return "Диагностика: " + state + "\n" + message + "\nOS VPN: " + (osVpnActive ? "активен" : (runtimeActive ? "runtime активен" : "неактивен")) @@ -369,9 +343,9 @@ public class MainActivity extends Activity { + " / down " + String.format(Locale.US, "%.1f", downlinkPps) + "\nDNS выхода: " + (dnsServers.isEmpty() ? "-" : dnsServers) + "\nroutes: " + (routes.isEmpty() ? "-" : routes) - + "\nrelay active: " + (activeRelayUrl.isEmpty() ? "-" : activeRelayUrl) - + "\nrelay profile: " + (profileRelayUrl.isEmpty() ? "-" : profileRelayUrl) - + "\nrelay candidates: " + (relayCandidates.isEmpty() ? "-" : relayCandidates) + + "\ntransport endpoint: " + transportEndpoint + + "\nprofile target: " + profileTarget + + "\ntransport candidates: " + transportTargets + "\nforced_full_tunnel: " + (forceFullTunnelRuntime ? "да" : "нет") + "\nfast_path_mode: " + (fastPathEnabled ? "включен" : "выключен") + "\nbytes read/sent/down: " + readBytes + "/" + sentBytes + "/" + downBytes @@ -389,13 +363,6 @@ public class MainActivity extends Activity { + "\nобновлено: " + age; } - private void startDiagnosticChannel() { - if (authContext == null || authContext.deviceId.isEmpty()) { - return; - } - RapDiagnosticService.start(this); - } - private boolean isSystemVpnActive() { try { ConnectivityManager connectivityManager = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); @@ -426,20 +393,31 @@ public class MainActivity extends Activity { if (updatedAt <= 0 || (System.currentTimeMillis() - updatedAt) > 15_000) { return false; } - String relay = runtimePrefs.getString("packet_relay_active_base_url", ""); + String activeTransportEndpoint = runtimePrefs.getString("fabric_transport_active_endpoint", ""); long read = runtimePrefs.getLong("uplink_read_total", 0); long sent = runtimePrefs.getLong("uplink_sent_total", 0); long down = runtimePrefs.getLong("downlink_received_total", 0); - return !relay.isEmpty() && ("running".equals(state) - || "relay".equals(state) - || "relay_reset".equals(state) + return !activeTransportEndpoint.isEmpty() && ("running".equals(state) + || "fabric_transport".equals(state) + || "fabric_transport_reset".equals(state) || "downlink".equals(state) || "downlink_idle".equals(state) || "uplink_sent".equals(state) || read > 0 || sent > 0 || down > 0); } + private boolean isTransportWarmupState(String state) { + return "fabric_transport_selected".equals(state) + || "fabric_transport".equals(state) + || "fabric_transport_reset".equals(state) + || "fabric_transport_switch".equals(state); + } + private String firstConnectionId(String profile) throws Exception { + String selected = prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, "").trim(); + if (!selected.isEmpty() && profileContainsConnection(profile, selected)) { + return selected; + } JSONObject root = new JSONObject(profile); JSONObject vpnProfile = root.getJSONObject("vpn_client_profile"); JSONArray connections = vpnProfile.getJSONArray("connections"); @@ -489,6 +467,36 @@ public class MainActivity extends Activity { return connections.getJSONObject(0).getString("id"); } + private boolean hasSelectedPool() { + return profileJson != null + && !profileJson.trim().isEmpty() + && vpnConnectionId != null + && !vpnConnectionId.trim().isEmpty() + && profileContainsConnection(profileJson, vpnConnectionId.trim()); + } + + private boolean profileContainsConnection(String profile, String connectionId) { + if (profile == null || profile.trim().isEmpty() || connectionId == null || connectionId.trim().isEmpty()) { + return false; + } + try { + JSONObject root = new JSONObject(profile); + JSONObject vpnProfile = root.optJSONObject("vpn_client_profile"); + JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections"); + if (connections == null) { + return false; + } + for (int i = 0; i < connections.length(); i++) { + JSONObject connection = connections.optJSONObject(i); + if (connection != null && connectionId.trim().equals(connection.optString("id", ""))) { + return true; + } + } + } catch (Exception ignored) { + } + return false; + } + private int dp(int value) { return (int) (value * getResources().getDisplayMetrics().density); } @@ -504,8 +512,8 @@ public class MainActivity extends Activity { return "Версия: " + APP_VERSION + "\nУзел Android: в ферме" + "\nBootstrap фермы: " + bootstrapPeerCount() + " узл." - + "\nДоступные выходы: " + (poolText.isEmpty() ? "войдите для загрузки" : poolText) - + "\nВыбранный выход: " + (selectedPoolText.isEmpty() ? "автоматически" : selectedPoolText) + + "\nДоступные выходы: " + (poolText.isEmpty() ? "не загружены" : poolText) + + "\nВыбранный выход: " + (selectedPoolText.isEmpty() ? "не выбран" : selectedPoolText) + "\nDNS выхода: " + (profileDNS.isEmpty() ? "будет получен из профиля" : profileDNS) + "\nТрафик: " + (prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true) ? "весь через VPN" : "по профилю") + "\nDevice: " + (deviceId.isEmpty() ? "нет" : deviceId) @@ -647,20 +655,7 @@ public class MainActivity extends Activity { return out.toString(); } - private String preferredBackendUrl() { - String saved = prefs.getString("backend_url", DEFAULT_BACKEND_URL); - String normalized = normalizeBackendUrl(saved); - if (!normalized.equals(saved == null ? "" : saved.trim())) { - prefs.edit().putString("backend_url", normalized).apply(); - } - return normalized; - } - private void saveSettings() { - String normalizedBackend = normalizeBackendUrl(backendUrl.getText().toString()); - if (!normalizedBackend.equals(backendUrl.getText().toString().trim())) { - backendUrl.setText(normalizedBackend); - } normalizeAndPersistDefaults(); if (clusterId.getText().toString().trim().isEmpty()) { clusterId.setText(DEFAULT_CLUSTER_ID); @@ -669,7 +664,6 @@ public class MainActivity extends Activity { organizationId.setText(DEFAULT_ORGANIZATION_ID); } prefs.edit() - .putString("backend_url", normalizedBackend) .putString("cluster_id", clusterId.getText().toString()) .putString("organization_id", organizationId.getText().toString()) .putString("email", email.getText().toString()) @@ -677,10 +671,6 @@ public class MainActivity extends Activity { } private void normalizeAndPersistDefaults() { - String normalizedBackend = normalizeBackendUrl(backendUrl.getText().toString()); - if (normalizedBackend.isEmpty()) { - backendUrl.setText(DEFAULT_BACKEND_URL); - } if (clusterId.getText().toString().trim().isEmpty()) { clusterId.setText(DEFAULT_CLUSTER_ID); } @@ -689,38 +679,48 @@ public class MainActivity extends Activity { } } - private String normalizeBackendUrl(String value) { - String candidate = value == null ? "" : value.trim().replaceAll("/+$", ""); - if (candidate.isEmpty()) { - return DEFAULT_BACKEND_URL; + private String fabricControlConfig() throws Exception { + JSONArray endpoints = new JSONArray(); + for (String peer : FABRIC_BOOTSTRAP_PEERS.split(",")) { + String raw = peer == null ? "" : peer.trim(); + String address = raw; + String certSHA256 = ""; + int fragmentIndex = raw.indexOf('#'); + if (fragmentIndex >= 0) { + address = raw.substring(0, fragmentIndex).trim(); + String fragment = raw.substring(fragmentIndex + 1).trim(); + if (fragment.startsWith("sha256=")) { + certSHA256 = fragment.substring("sha256=".length()).trim(); + } + } + if (address.isEmpty()) { + continue; + } + JSONObject endpoint = new JSONObject(); + endpoint.put("endpoint_id", address); + endpoint.put("address", address); + endpoint.put("transport", "direct_quic"); + if (certSHA256.matches("^[0-9a-fA-F]{64}$")) { + endpoint.put("peer_cert_sha256", certSHA256.toLowerCase(Locale.US)); + } + endpoints.put(endpoint); } - String lower = candidate.toLowerCase(Locale.US); - if ("http://vpn.cin.su:19191/api/v1".equals(lower) - || "http://vpn.cin.su/api/v1".equals(lower) - || "https://vpn.cin.su:443/api/v1".equals(lower) - || "http://94.141.118.222:19191/api/v1".equals(lower) - || "http://195.123.240.88:19131/api/v1".equals(lower)) { - return DEFAULT_BACKEND_URL; + if (endpoints.length() == 0) { + throw new IllegalStateException("В клиенте нет bootstrap-узлов фермы."); } - return candidate; - } - - private String selectedExitNodeId() { - return ""; - } - - private String normalizeSelectedExitNodeId(String value) { - String candidate = value == null ? "" : value.trim(); - if (candidate.isEmpty()) { - return ""; - } - if (candidate.matches("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")) { - return candidate; - } - if (candidate.matches("^[A-Za-z0-9][A-Za-z0-9._-]{2,63}$")) { - return candidate; - } - return ""; + JSONObject service = new JSONObject(); + service.put("schema_version", "rap.fabric_service_channel_request.v1"); + service.put("channel_id", "android-control"); + service.put("service_class", "identity_runtime"); + service.put("source_role", "vpn-client"); + JSONObject cfg = new JSONObject(); + cfg.put("cluster_id", DEFAULT_CLUSTER_ID); + cfg.put("local_node_id", fabricNodeId()); + cfg.put("vpn_connection_id", "fabric-control"); + cfg.put("stream_shards", 1); + cfg.put("service_channel_request", service); + cfg.put("endpoints", endpoints); + return cfg.toString(); } private RapApiClient.AuthContext authenticate(RapApiClient client) throws Exception { @@ -743,6 +743,44 @@ public class MainActivity extends Activity { return loggedIn; } + private RapApiClient.AuthContext authenticateWithPassword(RapApiClient client, String emailValue, String passwordValue) throws Exception { + if (passwordValue == null || passwordValue.trim().isEmpty()) { + throw new IllegalStateException("Введите пароль для идентификации устройства и выбора пула."); + } + RapApiClient.AuthContext loggedIn = client.login(emailValue.trim(), passwordValue.trim(), deviceFingerprint()); + saveAuthContext(loggedIn); + return loggedIn; + } + + private void refreshSavedProfileForCurrentUser() throws Exception { + String userId = prefs.getString(PREF_USER_ID, ""); + if (userId == null || userId.trim().isEmpty()) { + throw new IllegalStateException("Устройство еще не привязано к пользователю."); + } + RapApiClient client = new RapApiClient(fabricControlConfig(), this); + String refreshToken = savedRefreshToken(); + if (!refreshToken.isEmpty()) { + authContext = client.refresh(refreshToken); + saveAuthContext(authContext); + userId = authContext.userId; + } + String activeOrganizationId = resolveOrganizationId(client, userId); + String refreshedProfile = client.vpnClientProfile( + clusterId.getText().toString(), + activeOrganizationId, + userId, + "" + ); + if (!profileContainsConnection(refreshedProfile, vpnConnectionId)) { + profileJson = refreshedProfile; + vpnConnectionId = ""; + saveProfileState(); + throw new IllegalStateException("Администратор закрыл доступ к выбранному пулу или пул удален."); + } + profileJson = refreshedProfile; + saveProfileState(); + } + private String resolveOrganizationId(RapApiClient client, String userId) throws Exception { JSONObject payload = client.organizations(userId); JSONArray organizations = payload.optJSONArray("organizations"); @@ -850,6 +888,89 @@ public class MainActivity extends Activity { return generated; } + private String fabricNodeId() { + String existing = prefs.getString(PREF_FABRIC_NODE_ID, ""); + if (existing != null && existing.matches("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$")) { + return existing.toLowerCase(Locale.US); + } + String generated = java.util.UUID.randomUUID().toString(); + prefs.edit().putString(PREF_FABRIC_NODE_ID, generated).apply(); + return generated; + } + + private void registerCandidateNodeAsync(boolean showStatus) { + new Thread(() -> { + try { + RapApiClient client = new RapApiClient(fabricControlConfig(), this); + String nodeId = registerCandidateNode(client); + sendCandidateHeartbeat(client, nodeId); + if (showStatus) { + runOnUiThread(() -> status.setText("Узел телефона виден ферме как кандидат: " + nodeId)); + } + } catch (Exception ex) { + if (showStatus) { + runOnUiThread(() -> status.setText("Узел телефона пока не зарегистрирован в ферме: " + friendlyError(ex))); + } + } + }, "rap-fabric-candidate-register").start(); + } + + private String registerCandidateNode(RapApiClient client) throws Exception { + String nodeId = fabricNodeId(); + JSONObject metadata = new JSONObject(); + metadata.put("source", "android_vpn_client"); + metadata.put("candidate_access", true); + metadata.put("fabric_transport", "quic"); + metadata.put("connectivity_mode", "outbound_only"); + metadata.put("app_version", APP_VERSION); + metadata.put("device_fingerprint", deviceFingerprint()); + JSONObject payload = new JSONObject(); + payload.put("cluster_id", clusterId.getText().toString().trim().isEmpty() ? DEFAULT_CLUSTER_ID : clusterId.getText().toString().trim()); + payload.put("node_key", "android-vpn:" + deviceFingerprint()); + payload.put("name", "android-vpn-" + deviceFingerprint().replace("android-", "").substring(0, Math.min(8, deviceFingerprint().replace("android-", "").length()))); + payload.put("ownership_type", "customer_managed"); + payload.put("owner_organization_id", organizationId.getText().toString().trim().isEmpty() ? DEFAULT_ORGANIZATION_ID : organizationId.getText().toString().trim()); + payload.put("reported_version", APP_VERSION); + payload.put("metadata", metadata); + JSONObject response = client.registerFabricNode(payload); + String registeredNodeId = response.optString("node_id", nodeId).trim(); + if (!registeredNodeId.isEmpty()) { + prefs.edit().putString(PREF_FABRIC_NODE_ID, registeredNodeId).apply(); + return registeredNodeId; + } + return nodeId; + } + + private void sendCandidateHeartbeat(RapApiClient client, String nodeId) throws Exception { + JSONObject capabilities = new JSONObject(); + capabilities.put("fabric_quic_node", true); + capabilities.put("android_vpn_client", true); + capabilities.put("candidate_access", true); + capabilities.put("vpn_client", true); + JSONObject serviceStates = new JSONObject(); + serviceStates.put("vpn-client", new JSONObject() + .put("state", isSystemVpnActive() ? "running" : "candidate") + .put("runtime", "android_vpnservice") + .put("transport", "fabric_quic_route")); + JSONObject metadata = new JSONObject(); + metadata.put("source", "android_vpn_client"); + metadata.put("candidate", true); + metadata.put("passive", true); + metadata.put("app_version", APP_VERSION); + metadata.put("mesh_endpoint_report", new JSONObject() + .put("schema_version", "rap.mesh_endpoint_report.v1") + .put("transport", "quic") + .put("connectivity_mode", "outbound_only") + .put("endpoint_candidates", new JSONArray())); + JSONObject payload = new JSONObject(); + payload.put("health_status", "healthy"); + payload.put("reported_version", APP_VERSION); + payload.put("capabilities", capabilities); + payload.put("service_states", serviceStates); + payload.put("metadata", metadata); + client.sendFabricNodeHeartbeat(clusterId.getText().toString().trim().isEmpty() ? DEFAULT_CLUSTER_ID : clusterId.getText().toString().trim(), nodeId, payload); + } + private void showSettingsDialog() { LinearLayout form = new LinearLayout(this); form.setOrientation(LinearLayout.VERTICAL); @@ -877,17 +998,15 @@ public class MainActivity extends Activity { form.addView(showPassword); form.addView(forceFullTunnel); new AlertDialog.Builder(this) - .setTitle("Аккаунт VPN") + .setTitle("Настройка VPN") .setView(form) - .setPositiveButton("Сохранить", (dialog, which) -> { + .setPositiveButton("Войти и выбрать выход", (dialog, which) -> { email.setText(emailDraft.getText().toString()); - password.setText(passwordDraft.getText().toString()); - prefs.edit() - .remove(PREF_SELECTED_EXIT_NODE_ID) - .apply(); + String passwordValue = passwordDraft.getText().toString(); + password.setText(""); prefs.edit().putBoolean(PREF_FORCE_FULL_TUNNEL, forceFullTunnel.isChecked()).apply(); saveSettings(); - profileSummary.setText(summaryText()); + loginAndChoosePool(emailDraft.getText().toString(), passwordValue); }) .setNeutralButton("Забыть устройство", (dialog, which) -> { clearSavedAuth(true); @@ -897,6 +1016,72 @@ public class MainActivity extends Activity { .show(); } + private void loginAndChoosePool(String emailValue, String passwordValue) { + status.setText("Идентифицирую устройство и загружаю доступные выходы..."); + new Thread(() -> { + try { + RapApiClient client = new RapApiClient(fabricControlConfig(), this); + authContext = authenticateWithPassword(client, emailValue, passwordValue); + String activeOrganizationId = resolveOrganizationId(client, authContext.userId); + String loadedProfile = client.vpnClientProfile( + clusterId.getText().toString(), + activeOrganizationId, + authContext.userId, + "" + ); + runOnUiThread(() -> showPoolChoiceDialog(loadedProfile)); + } catch (Exception ex) { + runOnUiThread(() -> { + status.setText("Ошибка настройки: " + friendlyError(ex)); + if (friendlyError(ex).contains("пароль")) { + clearSavedAuth(false); + } + }); + } + }).start(); + } + + private void showPoolChoiceDialog(String loadedProfile) { + try { + JSONObject root = new JSONObject(loadedProfile); + JSONObject vpnProfile = root.optJSONObject("vpn_client_profile"); + JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections"); + if (connections == null || connections.length() == 0) { + throw new IllegalStateException("Для пользователя нет доступных выходных пулов."); + } + String[] labels = new String[connections.length()]; + String[] ids = new String[connections.length()]; + int selectedIndex = 0; + for (int i = 0; i < connections.length(); i++) { + JSONObject connection = connections.getJSONObject(i); + ids[i] = connection.optString("id", ""); + String name = connection.optString("exit_pool_name", "").trim(); + if (name.isEmpty()) { + name = connection.optString("name", "").trim(); + } + labels[i] = name.isEmpty() ? "Выход " + (i + 1) : name; + if (!vpnConnectionId.isEmpty() && vpnConnectionId.equals(ids[i])) { + selectedIndex = i; + } + } + int initialSelection = selectedIndex; + new AlertDialog.Builder(this) + .setTitle("Выходной пул") + .setSingleChoiceItems(labels, initialSelection, (dialog, which) -> { + profileJson = loadedProfile; + vpnConnectionId = ids[which]; + saveProfileState(); + profileSummary.setText(summaryText()); + status.setText("Выбран выходной пул: " + labels[which]); + dialog.dismiss(); + }) + .setNegativeButton("Отмена", null) + .show(); + } catch (Exception ex) { + status.setText("Ошибка выбора пула: " + friendlyError(ex)); + } + } + private String friendlyError(Exception ex) { String message = ex.getMessage(); if (message == null || message.trim().isEmpty()) { diff --git a/clients/android/app/src/main/java/su/cin/rapvpn/RapApiClient.java b/clients/android/app/src/main/java/su/cin/rapvpn/RapApiClient.java index 98f1926..053952a 100644 --- a/clients/android/app/src/main/java/su/cin/rapvpn/RapApiClient.java +++ b/clients/android/app/src/main/java/su/cin/rapvpn/RapApiClient.java @@ -4,7 +4,6 @@ import android.content.Context; import android.net.ConnectivityManager; import android.net.Network; import android.net.NetworkCapabilities; -import android.net.VpnService; import okhttp3.MediaType; import okhttp3.OkHttpClient; @@ -16,35 +15,28 @@ import okhttp3.RequestBody; import okhttp3.Response; import okhttp3.ResponseBody; +import org.json.JSONArray; import org.json.JSONObject; -import java.io.ByteArrayOutputStream; -import java.io.IOException; -import java.io.InterruptedIOException; +import su.cin.rapvpn.fabric.fabricvpn.Fabricvpn; +import su.cin.rapvpn.fabric.fabricvpn.Manager; + import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.Socket; import java.net.URI; import java.net.UnknownHostException; import java.nio.charset.StandardCharsets; import java.util.ArrayList; import java.util.List; -import java.util.Collections; import java.util.concurrent.TimeUnit; import javax.net.SocketFactory; final class RapApiClient { private static final MediaType JSON = MediaType.get("application/json; charset=utf-8"); - private static final MediaType OCTET_STREAM = MediaType.get("application/octet-stream"); - private static final int MAX_PACKET_BATCH_PACKETS = 512; - private static final int MAX_PACKET_BATCH_BYTES = 512 * 1024; - private static final int MAX_SINGLE_PACKET_BYTES = 65535; - private static final int MAX_BATCH_HEADER_BYTES = 4; private final String baseUrl; private final OkHttpClient httpClient; private final String networkMode; - private final FabricServiceChannel fabricServiceChannel; + private final Manager fabricControlManager; RapApiClient(String baseUrl) { this(baseUrl, (Context) null); @@ -52,7 +44,7 @@ final class RapApiClient { RapApiClient(String baseUrl, Context context) { this.baseUrl = trimRight(baseUrl); - this.fabricServiceChannel = new FabricServiceChannel(); + this.fabricControlManager = startFabricControlManager(baseUrl); OkHttpClient.Builder builder = new OkHttpClient.Builder(); // Regular app and diagnostic requests should use Android's default // routing. Some devices reject binding app sockets to a specific @@ -74,7 +66,7 @@ final class RapApiClient { RapApiClient(String baseUrl, Context context, boolean preferUnderlyingNetwork) { this.baseUrl = trimRight(baseUrl); - this.fabricServiceChannel = new FabricServiceChannel(); + this.fabricControlManager = startFabricControlManager(baseUrl); OkHttpClient.Builder builder = new OkHttpClient.Builder(); String mode = context == null ? "default_network" : "default_network_context"; if (preferUnderlyingNetwork && context != null) { @@ -99,74 +91,27 @@ final class RapApiClient { this.httpClient = builder.build(); } - RapApiClient(String baseUrl, VpnService vpnService) { - this(baseUrl, vpnService, new FabricServiceChannel()); - } - - RapApiClient(String baseUrl, VpnService vpnService, FabricServiceChannel fabricServiceChannel) { - this.baseUrl = trimRight(baseUrl); - this.fabricServiceChannel = fabricServiceChannel == null ? new FabricServiceChannel() : fabricServiceChannel; - OkHttpClient.Builder builder = new OkHttpClient.Builder(); - if (vpnService != null) { - builder.socketFactory(new ProtectedSocketFactory(vpnService)); - builder.dns(new BackendPinnedDns(baseUrl)); - this.networkMode = "protected_socket"; - } else { - this.networkMode = "default_network"; - } - builder.connectTimeout(3, TimeUnit.SECONDS); - builder.writeTimeout(8, TimeUnit.SECONDS); - builder.readTimeout(8, TimeUnit.SECONDS); - builder.callTimeout(10, TimeUnit.SECONDS); - builder.retryOnConnectionFailure(false); - Dispatcher dispatcher = new Dispatcher(); - dispatcher.setMaxRequests(64); - dispatcher.setMaxRequestsPerHost(32); - builder.dispatcher(dispatcher); - builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES)); - this.httpClient = builder.build(); - } - - RapApiClient(String baseUrl, Network network) { - this.baseUrl = trimRight(baseUrl); - this.fabricServiceChannel = new FabricServiceChannel(); - OkHttpClient.Builder builder = new OkHttpClient.Builder(); - if (network != null) { - builder.socketFactory(network.getSocketFactory()); - builder.dns(hostname -> { - InetAddress[] addresses = network.getAllByName(hostname); - if (addresses == null || addresses.length == 0) { - throw new UnknownHostException(hostname); - } - List out = new ArrayList<>(); - Collections.addAll(out, addresses); - return out; - }); - this.networkMode = "vpn_network"; - } else { - builder.dns(new BackendPinnedDns(baseUrl)); - this.networkMode = "default_network"; - } - builder.connectTimeout(5, TimeUnit.SECONDS); - builder.writeTimeout(12, TimeUnit.SECONDS); - builder.readTimeout(12, TimeUnit.SECONDS); - builder.callTimeout(15, TimeUnit.SECONDS); - builder.retryOnConnectionFailure(true); - Dispatcher dispatcher = new Dispatcher(); - dispatcher.setMaxRequests(64); - dispatcher.setMaxRequestsPerHost(32); - builder.dispatcher(dispatcher); - builder.connectionPool(new ConnectionPool(16, 5, TimeUnit.MINUTES)); - this.httpClient = builder.build(); - } - String networkMode() { return networkMode; } + private Manager startFabricControlManager(String config) { + String value = config == null ? "" : config.trim(); + if (!value.startsWith("{")) { + return null; + } + try { + Fabricvpn.touch(); + Manager manager = Fabricvpn.newManager(); + manager.start(value); + return manager; + } catch (Exception e) { + String detail = e.getMessage() == null ? e.getClass().getSimpleName() : e.getMessage(); + throw new IllegalStateException("Не удалось подключиться к ферме через QUIC bootstrap. Последняя ошибка: " + detail, e); + } + } + static final class BackendPinnedDns implements Dns { - private static final String VPN_PUBLIC_HOST = "vpn.cin.su"; - private static final String VPN_PUBLIC_IPV4 = "94.141.118.222"; private final String backendHost; BackendPinnedDns(String baseUrl) { @@ -180,10 +125,6 @@ final class RapApiClient { @Override public List lookup(String hostname) throws UnknownHostException { - String host = hostname == null ? "" : hostname.trim().toLowerCase(); - if (!backendHost.isEmpty() && host.equals(backendHost) && VPN_PUBLIC_HOST.equals(host)) { - return Collections.singletonList(InetAddress.getByName(VPN_PUBLIC_IPV4)); - } return Dns.SYSTEM.lookup(hostname); } } @@ -243,103 +184,26 @@ final class RapApiClient { return get(path); } - JSONObject startSession(String resourceId, String userId, String deviceId) throws Exception { - JSONObject body = new JSONObject(); - body.put("resource_id", resourceId); - body.put("user_id", userId); - body.put("device_id", deviceId); - return post("/sessions/", body); + JSONObject registerFabricNode(JSONObject payload) throws Exception { + return post("/node-agents/register", payload); } - JSONObject reportVPNDiagnosticStatus(String clusterId, String deviceId, JSONObject payload) throws Exception { - return post("/clusters/" + clusterId + "/vpn/client-diagnostics/" + deviceId + "/status", payload); - } - - JSONObject nextVPNDiagnosticCommand(String clusterId, String deviceId, int timeoutMs) throws Exception { - byte[] payload = getBytes("/clusters/" + clusterId + "/vpn/client-diagnostics/" + deviceId + "/commands?timeout_ms=" + timeoutMs); - if (payload.length == 0) { - return null; - } - return new JSONObject(new String(payload, StandardCharsets.UTF_8)); - } - - JSONObject vpnPacketStats(String clusterId, String vpnConnectionId) throws Exception { - return get("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/stats"); - } - - JSONObject resetVPNPacketQueues(String clusterId, String vpnConnectionId) throws Exception { - return post("/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/reset", new JSONObject()); - } - - void sendClientPacket(String clusterId, String vpnConnectionId, byte[] packet, int length) throws Exception { - postBytes(clientPacketPath(clusterId, vpnConnectionId, ""), packet, length); - } - - void sendClientPacketBatch(String clusterId, String vpnConnectionId, List packets) throws Exception { - if (packets == null || packets.isEmpty()) { - return; - } - List> chunks = chunkPacketsForBatch(packets); - if (chunks.isEmpty()) { - return; - } - for (List chunk : chunks) { - postBytes(clientPacketPath(clusterId, vpnConnectionId, "?batch=true"), encodePacketBatch(chunk)); - } - } - - byte[] receiveClientPacket(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception { - try { - return getBytes(clientPacketPath(clusterId, vpnConnectionId, "?timeout_ms=" + timeoutMs)); - } catch (InterruptedIOException e) { - return new byte[0]; - } catch (IOException e) { - if (e.getMessage() != null && e.getMessage().toLowerCase().contains("timeout")) { - return new byte[0]; - } - throw e; - } catch (IllegalStateException e) { - String message = e.getMessage(); - if (message != null && (message.contains("HTTP 502") || message.contains("HTTP 503") || message.contains("HTTP 504"))) { - return new byte[0]; - } - throw e; - } - } - - List receiveClientPacketBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception { - byte[] payload; - try { - payload = getBytes(clientPacketPath(clusterId, vpnConnectionId, "?batch=true&timeout_ms=" + timeoutMs)); - if (payload == null || payload.length == 0) { - return new ArrayList<>(); - } - if (!isLikelyPacketBatch(payload)) { - return receiveSinglePacketAsBatch(clusterId, vpnConnectionId, timeoutMs); - } - return decodePacketBatch(payload); - } catch (InterruptedIOException e) { - return new ArrayList<>(); - } catch (IOException e) { - if (e.getMessage() != null && e.getMessage().toLowerCase().contains("timeout")) { - return new ArrayList<>(); - } - throw e; - } catch (IllegalStateException e) { - String message = e.getMessage(); - if (message != null && (message.contains("HTTP 502") || message.contains("HTTP 503") || message.contains("HTTP 504"))) { - return new ArrayList<>(); - } - throw e; - } + JSONObject sendFabricNodeHeartbeat(String clusterId, String nodeId, JSONObject payload) throws Exception { + return post("/clusters/" + clusterId + "/nodes/" + nodeId + "/heartbeats", payload); } private JSONObject get(String path) throws Exception { + if (fabricControlManager != null) { + return fabricControlJSON("GET", path, null); + } Request request = new Request.Builder().url(baseUrl + path).get().build(); return read(request); } private JSONObject post(String path, JSONObject body) throws Exception { + if (fabricControlManager != null) { + return fabricControlJSON("POST", path, body); + } Request request = new Request.Builder() .url(baseUrl + path) .post(RequestBody.create(body.toString().getBytes(StandardCharsets.UTF_8), JSON)) @@ -347,39 +211,60 @@ final class RapApiClient { return read(request); } - private byte[] getBytes(String path) throws Exception { - Request.Builder builder = new Request.Builder().url(baseUrl + path).get(); - applyFabricHeadersIfNeeded(builder, path); - Request request = builder.build(); - try (Response response = httpClient.newCall(request).execute()) { - if (response.code() == 204) { - return new byte[0]; - } - if (!response.isSuccessful()) { - throw new IllegalStateException(describeHttpFailure(response)); - } - ResponseBody body = response.body(); - return body == null ? new byte[0] : body.bytes(); + private JSONObject fabricControlJSON(String method, String path, JSONObject body) throws Exception { + byte[] payload = fabricControlBodyBytes(method, path, body); + if (payload.length == 0) { + return new JSONObject(); } + return new JSONObject(new String(payload, StandardCharsets.UTF_8)); } - private void postBytes(String path, byte[] packet, int length) throws Exception { - byte[] bodyBytes = new byte[length]; - System.arraycopy(packet, 0, bodyBytes, 0, length); - postBytes(path, bodyBytes); + private byte[] fabricControlBodyBytes(String method, String path, JSONObject body) throws Exception { + JSONObject request = new JSONObject(); + request.put("method", method); + request.put("path", path); + if (body != null) { + request.put("body", body); + } + String raw; + try { + raw = fabricControlManager.controlRequest(request.toString()); + } catch (Exception e) { + throw new IllegalStateException("Ферма сейчас не смогла выполнить контрольный запрос. Попробуйте еще раз.", e); + } + JSONObject wrapper = raw == null || raw.trim().isEmpty() ? new JSONObject() : new JSONObject(raw); + int statusCode = wrapper.optInt("status_code", 200); + Object bodyValue = wrapper.opt("body"); + String bodyText = jsonBodyText(bodyValue); + if (statusCode < 200 || statusCode >= 300) { + if (statusCode == 401 && bodyText.contains("auth.invalid_credentials")) { + throw new IllegalStateException("Неверный логин или пароль."); + } + if (statusCode == 401 && bodyText.contains("auth.invalid_refresh_token")) { + throw new IllegalStateException("Сессия устройства истекла. Введите пароль один раз."); + } + throw new IllegalStateException("fabric control HTTP " + statusCode + ": " + compactText(bodyText, 240)); + } + return bodyText.getBytes(java.nio.charset.StandardCharsets.UTF_8); } - private void postBytes(String path, byte[] bodyBytes) throws Exception { - Request.Builder builder = new Request.Builder() - .url(baseUrl + path) - .post(RequestBody.create(bodyBytes, OCTET_STREAM)); - applyFabricHeadersIfNeeded(builder, path); - Request request = builder.build(); - try (Response response = httpClient.newCall(request).execute()) { - if (!response.isSuccessful()) { - throw new IllegalStateException(describeHttpFailure(response)); - } + private String jsonBodyText(Object bodyValue) { + if (bodyValue == null || JSONObject.NULL.equals(bodyValue)) { + return ""; } + if (bodyValue instanceof JSONObject || bodyValue instanceof JSONArray) { + return bodyValue.toString(); + } + String text = String.valueOf(bodyValue); + return text == null ? "" : text; + } + + private String compactText(String text, int limit) { + String value = text == null ? "" : text.replace('\n', ' ').replace('\r', ' ').trim(); + if (value.length() > limit) { + return value.substring(0, limit); + } + return value; } private String describeHttpFailure(Response response) { @@ -401,45 +286,6 @@ final class RapApiClient { return message.toString(); } - private String clientPacketPath(String clusterId, String vpnConnectionId, String suffix) throws IOException { - String path = fabricServiceChannel.packetPathForBase(baseUrl, clusterId, vpnConnectionId, false); - if (path.isEmpty()) { - throw new IOException("fabric service channel lease required for VPN packet dataplane"); - } - return path + (suffix == null ? "" : suffix); - } - - private void applyFabricHeadersIfNeeded(Request.Builder builder, String path) { - if (path != null && path.contains("/fabric/service-channels/")) { - fabricServiceChannel.applyHeaders(builder); - } - } - - private byte[] encodePacketBatch(List packets) { - int total = 0; - for (byte[] packet : packets) { - if (packet != null && packet.length > 0) { - total += 4 + packet.length; - } - } - byte[] out = new byte[total]; - int offset = 0; - for (byte[] packet : packets) { - if (packet == null || packet.length == 0) { - continue; - } - int length = packet.length; - out[offset] = (byte) ((length >> 24) & 0xff); - out[offset + 1] = (byte) ((length >> 16) & 0xff); - out[offset + 2] = (byte) ((length >> 8) & 0xff); - out[offset + 3] = (byte) (length & 0xff); - offset += 4; - System.arraycopy(packet, 0, out, offset, length); - offset += length; - } - return out; - } - private JSONObject read(Request request) throws Exception { try (Response response = httpClient.newCall(request).execute()) { ResponseBody body = response.body(); @@ -457,93 +303,6 @@ final class RapApiClient { } } - private List decodePacketBatch(byte[] payload) { - List packets = new ArrayList<>(); - int offset = 0; - while (payload != null && offset + 4 <= payload.length) { - int length = ((payload[offset] & 0xff) << 24) - | ((payload[offset + 1] & 0xff) << 16) - | ((payload[offset + 2] & 0xff) << 8) - | (payload[offset + 3] & 0xff); - offset += 4; - if (length <= 0 || offset + length > payload.length) { - break; - } - byte[] packet = new byte[length]; - System.arraycopy(payload, offset, packet, 0, length); - packets.add(packet); - offset += length; - } - return packets; - } - - private List> chunkPacketsForBatch(List packets) { - List> chunks = new ArrayList<>(); - List current = new ArrayList<>(); - int currentBytes = 0; - boolean hasData = false; - for (byte[] packet : packets) { - if (packet == null || packet.length == 0) { - continue; - } - if (packet.length > MAX_SINGLE_PACKET_BYTES) { - continue; - } - hasData = true; - - int projected = currentBytes + MAX_BATCH_HEADER_BYTES + packet.length; - if (!current.isEmpty() && (current.size() >= MAX_PACKET_BATCH_PACKETS || projected > MAX_PACKET_BATCH_BYTES)) { - chunks.add(current); - current = new ArrayList<>(); - currentBytes = 0; - } - current.add(packet); - currentBytes = projected; - } - if (!hasData) { - return chunks; - } - if (!current.isEmpty()) { - chunks.add(current); - } - return chunks; - } - - private boolean isLikelyPacketBatch(byte[] payload) { - if (payload == null || payload.length < MAX_BATCH_HEADER_BYTES) { - return false; - } - int offset = 0; - int consumed = 0; - while (offset + MAX_BATCH_HEADER_BYTES <= payload.length) { - int length = ((payload[offset] & 0xff) << 24) - | ((payload[offset + 1] & 0xff) << 16) - | ((payload[offset + 2] & 0xff) << 8) - | (payload[offset + 3] & 0xff); - offset += MAX_BATCH_HEADER_BYTES; - if (length <= 0 || length > MAX_SINGLE_PACKET_BYTES) { - return false; - } - if (offset + length > payload.length) { - return false; - } - offset += length; - consumed++; - if (consumed > MAX_PACKET_BATCH_PACKETS) { - return false; - } - } - return offset == payload.length && consumed > 0; - } - - private List receiveSinglePacketAsBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws Exception { - byte[] payload = receiveClientPacket(clusterId, vpnConnectionId, timeoutMs); - if (payload == null || payload.length == 0) { - return new ArrayList<>(); - } - return new ArrayList<>(Collections.singletonList(payload)); - } - private AuthContext parseAuthContext(JSONObject response) throws Exception { JSONObject user = response.getJSONObject("user"); String userId = user.optString("id", ""); @@ -570,65 +329,6 @@ final class RapApiClient { return value; } - static final class ProtectedSocketFactory extends SocketFactory { - private final SocketFactory delegate = SocketFactory.getDefault(); - private final VpnService vpnService; - - ProtectedSocketFactory(VpnService vpnService) { - this.vpnService = vpnService; - } - - @Override - public Socket createSocket() throws IOException { - Socket socket = delegate.createSocket(); - socket.bind(null); - return protect(socket); - } - - @Override - public Socket createSocket(String host, int port) throws IOException { - Socket socket = createSocket(); - socket.connect(new InetSocketAddress(host, port)); - return socket; - } - - @Override - public Socket createSocket(String host, int port, InetAddress localHost, int localPort) throws IOException { - Socket socket = delegate.createSocket(); - socket.bind(new InetSocketAddress(localHost, localPort)); - protect(socket); - socket.connect(new InetSocketAddress(host, port)); - return socket; - } - - @Override - public Socket createSocket(InetAddress host, int port) throws IOException { - Socket socket = createSocket(); - socket.connect(new InetSocketAddress(host, port)); - return socket; - } - - @Override - public Socket createSocket(InetAddress address, int port, InetAddress localAddress, int localPort) throws IOException { - Socket socket = delegate.createSocket(); - socket.bind(new InetSocketAddress(localAddress, localPort)); - protect(socket); - socket.connect(new InetSocketAddress(address, port)); - return socket; - } - - private Socket protect(Socket socket) throws IOException { - if (!vpnService.protect(socket)) { - try { - socket.close(); - } catch (IOException ignored) { - } - throw new IOException("protect control-plane socket failed"); - } - return socket; - } - } - static final class AuthContext { final String userId; final String deviceId; diff --git a/clients/android/app/src/main/java/su/cin/rapvpn/RapAutostartReceiver.java b/clients/android/app/src/main/java/su/cin/rapvpn/RapAutostartReceiver.java index e9f505d..aa3634c 100644 --- a/clients/android/app/src/main/java/su/cin/rapvpn/RapAutostartReceiver.java +++ b/clients/android/app/src/main/java/su/cin/rapvpn/RapAutostartReceiver.java @@ -10,7 +10,6 @@ import android.os.Build; public final class RapAutostartReceiver extends BroadcastReceiver { private static final String PREFS = "rap-vpn"; private static final String PREF_PROFILE_JSON = "profile_json"; - private static final String PREF_BACKEND_URL = "backend_url"; private static final String PREF_CLUSTER_ID = "cluster_id"; private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id"; private static final String PREF_MANUAL_STOPPED = "manual_stopped"; @@ -25,21 +24,18 @@ public final class RapAutostartReceiver extends BroadcastReceiver { && !Intent.ACTION_BOOT_COMPLETED.equals(action)) { return; } - RapDiagnosticService.start(context); SharedPreferences prefs = context.getSharedPreferences(PREFS, Context.MODE_PRIVATE); if (prefs.getBoolean(PREF_MANUAL_STOPPED, false)) { return; } if (Intent.ACTION_MY_PACKAGE_REPLACED.equals(action)) { - // Diagnostic service owns post-upgrade VPN restart. Starting both services from - // MY_PACKAGE_REPLACED can race foreground-service startup and leave diagnostics stale. + // After package replacement we wait for an explicit user action or runtime resume. return; } String profile = prefs.getString(PREF_PROFILE_JSON, ""); - String backendUrl = prefs.getString(PREF_BACKEND_URL, ""); String clusterId = prefs.getString(PREF_CLUSTER_ID, ""); String vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, ""); - if (profile.isEmpty() || backendUrl.isEmpty() || clusterId.isEmpty() || vpnConnectionId.isEmpty()) { + if (profile.isEmpty() || clusterId.isEmpty() || vpnConnectionId.isEmpty()) { return; } if (VpnService.prepare(context) != null) { @@ -47,7 +43,6 @@ public final class RapAutostartReceiver extends BroadcastReceiver { } Intent service = new Intent(context, RapVpnService.class); service.putExtra("profile_json", profile); - service.putExtra("backend_url", backendUrl); service.putExtra("cluster_id", clusterId); service.putExtra("vpn_connection_id", vpnConnectionId); if (Build.VERSION.SDK_INT >= 26) { diff --git a/clients/android/app/src/main/java/su/cin/rapvpn/RapDiagnosticService.java b/clients/android/app/src/main/java/su/cin/rapvpn/RapDiagnosticService.java deleted file mode 100644 index 0e6c25b..0000000 --- a/clients/android/app/src/main/java/su/cin/rapvpn/RapDiagnosticService.java +++ /dev/null @@ -1,2195 +0,0 @@ -package su.cin.rapvpn; - -import android.app.Notification; -import android.app.NotificationChannel; -import android.app.NotificationManager; -import android.app.Service; -import android.content.Intent; -import android.content.SharedPreferences; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.net.ConnectivityManager; -import android.net.LinkProperties; -import android.net.Network; -import android.net.NetworkCapabilities; -import android.net.Uri; -import android.net.VpnService; -import android.os.Build; -import android.os.Handler; -import android.os.IBinder; -import android.os.Looper; -import android.provider.Settings; -import android.widget.Toast; - -import org.json.JSONArray; -import org.json.JSONObject; - -import java.net.DatagramPacket; -import java.net.DatagramSocket; -import java.net.HttpURLConnection; -import java.net.InetAddress; -import java.net.InetSocketAddress; -import java.net.SocketTimeoutException; -import java.net.Socket; -import java.net.URI; -import java.net.URL; -import java.net.UnknownHostException; -import java.util.ArrayList; -import java.util.Arrays; -import java.util.LinkedHashSet; -import java.util.List; -import java.util.Random; -import java.util.Set; -import java.util.UUID; -import java.text.SimpleDateFormat; -import java.util.Date; -import java.util.concurrent.atomic.AtomicBoolean; -import java.util.regex.Matcher; -import java.util.regex.Pattern; - -public class RapDiagnosticService extends Service { - static final String ACTION_START = "su.cin.rapvpn.DIAGNOSTIC_START"; - static final String ACTION_STOP = "su.cin.rapvpn.DIAGNOSTIC_STOP"; - static final String ACTION_RESTART = "su.cin.rapvpn.DIAGNOSTIC_RESTART"; - private static final String CHANNEL_ID = "rap-vpn-diagnostics"; - private static final String APP_VERSION = BuildConfig.VERSION_NAME; - private static final String DEFAULT_BACKEND_URL = BuildConfig.DEFAULT_BACKEND_URL; - private static final String INTERNAL_BACKEND_URL = "http://192.168.200.61:18080/api/v1"; - private static final String DEFAULT_CLUSTER_ID = BuildConfig.DEFAULT_CLUSTER_ID; - private static final String DEFAULT_ORGANIZATION_ID = BuildConfig.DEFAULT_ORGANIZATION_ID; - private static final String PREF_SELECTED_EXIT_NODE_ID = "selected_exit_node_id"; - private static final String PREFS = "rap-vpn"; - private static final String RUNTIME_PREFS = "rap-vpn-runtime"; - private static final String PREF_REFRESH_TOKEN = "refresh_token"; - private static final String PREF_USER_ID = "user_id"; - private static final String PREF_DEVICE_ID = "device_id"; - private static final String PREF_DIAGNOSTIC_DEVICE_ID = "diagnostic_device_id"; - private static final String PREF_PROFILE_JSON = "profile_json"; - private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id"; - private static final long COMMAND_STALE_MS = 45000; - private static final long COMMAND_ORPHAN_MS = 60000; - private static final long POLL_FORCE_MS = 45000; - private static final int RECOVERY_TCP_TIMEOUT_MS = 8000; - private static final int RECOVERY_PAGE_TIMEOUT_MS = 25000; - private static final int RECOVERY_DOWNLOAD_CONNECT_TIMEOUT_MS = 8000; - private static final int RECOVERY_DOWNLOAD_READ_TIMEOUT_MS = 12000; - private static final int RECOVERY_RUNTIME_READY_TIMEOUT_MS = 9000; - private volatile boolean running; - private Thread worker; - private Thread supervisor; - private String serviceState = ""; - private String lastCommandType = ""; - private String lastCommandResult = ""; - private String lastCommandPollResult = ""; - private String lastReceivedCommandID = ""; - private String lastReceivedCommandType = ""; - private long lastReceivedCommandAt = 0; - private long lastCommandAt = 0; - private long lastHeartbeatAt = 0; - private long lastCommandPollAt = 0; - private volatile long lastWorkerProgressAt = 0; - private volatile long heartbeatStartedAt = 0; - private volatile long commandPollStartedAt = 0; - private volatile long commandStartedAt = 0; - private volatile long lastFabricLeaseRefreshAttemptAt = 0; - private String controlNetworkMode = ""; - private final AtomicBoolean heartbeatInProgress = new AtomicBoolean(false); - private final AtomicBoolean commandPollInProgress = new AtomicBoolean(false); - private final AtomicBoolean commandInProgress = new AtomicBoolean(false); - - @Override - public int onStartCommand(Intent intent, int flags, int startId) { - if (intent != null && ACTION_STOP.equals(intent.getAction())) { - running = false; - if (worker != null) { - worker.interrupt(); - } - if (supervisor != null) { - supervisor.interrupt(); - } - stopForeground(true); - stopSelfResult(startId); - return START_NOT_STICKY; - } - if (intent != null && ACTION_RESTART.equals(intent.getAction())) { - restartWorker(); - } - startForeground(1002, notification()); - startWorker(); - return START_STICKY; - } - - @Override - public void onDestroy() { - running = false; - if (worker != null) { - worker.interrupt(); - } - if (supervisor != null) { - supervisor.interrupt(); - } - super.onDestroy(); - } - - @Override - public IBinder onBind(Intent intent) { - return null; - } - - static void start(android.content.Context context) { - Intent intent = new Intent(context, RapDiagnosticService.class); - intent.setAction(ACTION_START); - if (Build.VERSION.SDK_INT >= 26) { - context.startForegroundService(intent); - } else { - context.startService(intent); - } - } - - static void restart(android.content.Context context) { - Intent intent = new Intent(context, RapDiagnosticService.class); - intent.setAction(ACTION_RESTART); - if (Build.VERSION.SDK_INT >= 26) { - context.startForegroundService(intent); - } else { - context.startService(intent); - } - } - - private void startWorker() { - if (worker != null && worker.isAlive()) { - long age = System.currentTimeMillis() - lastWorkerProgressAt; - if (age > 45000) { - restartWorker(); - } else { - startSupervisor(); - return; - } - } - running = true; - lastWorkerProgressAt = System.currentTimeMillis(); - worker = new Thread(this::runLoop, "rap-vpn-diagnostic-service"); - worker.start(); - startSupervisor(); - } - - private void restartWorker() { - running = false; - Thread oldWorker = worker; - Thread oldSupervisor = supervisor; - worker = null; - supervisor = null; - if (oldWorker != null) { - oldWorker.interrupt(); - } - if (oldSupervisor != null) { - oldSupervisor.interrupt(); - } - heartbeatInProgress.set(false); - commandPollInProgress.set(false); - commandInProgress.set(false); - heartbeatStartedAt = 0; - commandPollStartedAt = 0; - commandStartedAt = 0; - serviceState = "diagnostic worker restarting"; - lastWorkerProgressAt = System.currentTimeMillis(); - running = true; - } - - private void startSupervisor() { - if (supervisor != null && supervisor.isAlive()) { - return; - } - supervisor = new Thread(() -> { - while (running) { - try { - Thread.sleep(10000); - long age = System.currentTimeMillis() - lastWorkerProgressAt; - releaseStaleBackgroundOperations(System.currentTimeMillis()); - if (age < 60000) { - continue; - } - Thread stale = worker; - if (stale != null) { - stale.interrupt(); - } - serviceState = "restarting stale diagnostic worker age_ms=" + age; - worker = null; - startWorker(); - } catch (InterruptedException e) { - return; - } catch (Exception ignored) { - } - } - }, "rap-vpn-diagnostic-supervisor"); - supervisor.start(); - } - - private void runLoop() { - while (running) { - try { - lastWorkerProgressAt = System.currentTimeMillis(); - SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); - String backendUrl = normalizeBackendUrl(prefs.getString("backend_url", DEFAULT_BACKEND_URL)); - if (!backendUrl.equals(prefs.getString("backend_url", ""))) { - prefs.edit().putString("backend_url", backendUrl).apply(); - } - String clusterId = prefs.getString("cluster_id", DEFAULT_CLUSTER_ID); - if (clusterId == null || clusterId.trim().isEmpty()) { - clusterId = DEFAULT_CLUSTER_ID; - } - String deviceId = diagnosticDeviceId(prefs); - if (backendUrl.isEmpty() || clusterId.isEmpty() || deviceId.isEmpty()) { - serviceState = "waiting for config backend=" + !backendUrl.isEmpty() - + " cluster=" + !clusterId.isEmpty() - + " device=" + !deviceId.isEmpty(); - writeLocalDiagnosticHeartbeat(); - Thread.sleep(3000); - continue; - } - recoverSavedUserId(prefs); - releaseStaleBackgroundOperations(System.currentTimeMillis()); - lastHeartbeatAt = System.currentTimeMillis(); - serviceState = "online " + new SimpleDateFormat("HH:mm:ss").format(new Date()); - writeLocalDiagnosticHeartbeat(); - startHeartbeatWorker(backendUrl, clusterId, deviceId, prefs); - if (!commandInProgress.get()) { - startCommandPollWorker(backendUrl, clusterId, deviceId); - } - Thread.sleep(1000); - } catch (InterruptedException ignored) { - return; - } catch (Exception e) { - lastWorkerProgressAt = System.currentTimeMillis(); - serviceState = "error: " + e.getMessage(); - try { - Thread.sleep(3000); - } catch (InterruptedException interrupted) { - return; - } - } - } - } - - private void writeLocalDiagnosticHeartbeat() { - getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE) - .edit() - .putLong("diagnostic_local_heartbeat_at", System.currentTimeMillis()) - .putString("diagnostic_local_state", serviceState) - .putString("diagnostic_local_app_version", APP_VERSION) - .apply(); - } - - private void releaseStaleBackgroundOperations(long now) { - if (heartbeatInProgress.get() && heartbeatStartedAt > 0 && now - heartbeatStartedAt > 30000) { - heartbeatInProgress.set(false); - heartbeatStartedAt = 0; - serviceState = "diagnostic heartbeat watchdog released stale heartbeat"; - } - if (commandPollInProgress.get() && commandPollStartedAt > 0 && now - commandPollStartedAt > 30000) { - commandPollInProgress.set(false); - commandPollStartedAt = 0; - serviceState = "diagnostic poll watchdog released stale poll"; - } - if (commandPollInProgress.get() && commandPollStartedAt == 0 && lastCommandPollAt > 0 && now - lastCommandPollAt > 45000) { - commandPollInProgress.set(false); - serviceState = "diagnostic poll watchdog released orphan poll flag age_ms=" + (now - lastCommandPollAt); - } - if (!commandPollInProgress.get() && !commandInProgress.get() && lastCommandPollAt > 0 && now - lastCommandPollAt > POLL_FORCE_MS) { - serviceState = "diagnostic poll watchdog forcing command poll age_ms=" + (now - lastCommandPollAt); - lastWorkerProgressAt = now; - } - long commandAge = commandInProgress.get() && commandStartedAt > 0 ? now - commandStartedAt : 0; - if (commandAge > COMMAND_STALE_MS) { - commandInProgress.set(false); - commandStartedAt = 0; - lastCommandType = lastReceivedCommandType.isEmpty() ? "command_timeout" : lastReceivedCommandType; - lastCommandResult = "command watchdog timed out age_ms=" + commandAge - + " id=" + lastReceivedCommandID - + " type=" + lastReceivedCommandType; - lastCommandAt = now; - serviceState = "diagnostic command watchdog released stale command age_ms=" + commandAge; - } - if (commandInProgress.get() && commandStartedAt == 0 && lastCommandAt > 0 && now - lastCommandAt > COMMAND_ORPHAN_MS) { - commandInProgress.set(false); - serviceState = "diagnostic command watchdog released orphan command flag age_ms=" + (now - lastCommandAt); - } - if (commandInProgress.get() && commandStartedAt == 0 && lastCommandPollAt > 0 && now - lastCommandPollAt > COMMAND_ORPHAN_MS) { - commandInProgress.set(false); - serviceState = "diagnostic command watchdog released poll-stalled command flag age_ms=" + (now - lastCommandPollAt); - } - } - - private void startHeartbeatWorker(String backendUrl, String clusterId, String deviceId, SharedPreferences prefs) { - if (!heartbeatInProgress.compareAndSet(false, true)) { - return; - } - Thread heartbeatWorker = new Thread(() -> { - try { - heartbeatStartedAt = System.currentTimeMillis(); - RapApiClient client = controlClient(backendUrl); - controlNetworkMode = client.networkMode(); - try { - maybeRestartVPNAfterAppUpgrade(client, clusterId, prefs); - } catch (Exception e) { - serviceState = "upgrade restart check warning: " + e.getMessage(); - } - try { - maybeRecoverExpiredFabricLease(); - } catch (Exception e) { - serviceState = "fabric lease recovery warning: " + e.getMessage(); - } - lastWorkerProgressAt = System.currentTimeMillis(); - reportStatusWithFallback(backendUrl, clusterId, deviceId, statusPayload("heartbeat")); - lastWorkerProgressAt = System.currentTimeMillis(); - } catch (Exception e) { - serviceState = "heartbeat error: " + e.getMessage(); - lastWorkerProgressAt = System.currentTimeMillis(); - } finally { - heartbeatInProgress.set(false); - heartbeatStartedAt = 0; - } - }, "rap-vpn-diagnostic-heartbeat"); - heartbeatWorker.start(); - } - - private void startCommandPollWorker(String backendUrl, String clusterId, String deviceId) { - if (!commandPollInProgress.compareAndSet(false, true)) { - return; - } - Thread pollWorker = new Thread(() -> { - try { - commandPollStartedAt = System.currentTimeMillis(); - RapApiClient client = controlClient(backendUrl); - controlNetworkMode = client.networkMode(); - lastCommandPollAt = System.currentTimeMillis(); - JSONObject commandEnvelope = nextCommandWithFallback(backendUrl, clusterId, deviceId); - lastWorkerProgressAt = System.currentTimeMillis(); - if (commandEnvelope != null) { - lastCommandPollResult = describeCommandEnvelope(commandEnvelope); - rememberReceivedCommand(commandEnvelope); - startCommandWorker(backendUrl, clusterId, deviceId, commandEnvelope); - } else { - lastCommandPollResult = "no_content"; - } - } catch (Exception e) { - lastCommandPollResult = "error: " + e.getClass().getSimpleName(); - serviceState = "command poll error: " + e.getMessage(); - lastWorkerProgressAt = System.currentTimeMillis(); - } finally { - commandPollInProgress.set(false); - commandPollStartedAt = 0; - } - }, "rap-vpn-diagnostic-poll"); - pollWorker.start(); - } - - private void startCommandWorker(String backendUrl, String clusterId, String deviceId, JSONObject commandEnvelope) { - if (!commandInProgress.compareAndSet(false, true)) { - lastCommandPollResult = "worker_busy " + describeCommandEnvelope(commandEnvelope); - return; - } - Thread commandWorker = new Thread(() -> { - try { - commandStartedAt = System.currentTimeMillis(); - lastCommandType = lastReceivedCommandType.isEmpty() ? "command_running" : lastReceivedCommandType; - lastCommandResult = "running id=" + lastReceivedCommandID + " type=" + lastReceivedCommandType; - lastCommandAt = commandStartedAt; - lastWorkerProgressAt = System.currentTimeMillis(); - RapApiClient commandClient = controlClient(backendUrl); - controlNetworkMode = commandClient.networkMode(); - handleCommand(backendUrl, commandClient, clusterId, deviceId, commandEnvelope); - lastWorkerProgressAt = System.currentTimeMillis(); - } catch (Exception e) { - lastWorkerProgressAt = System.currentTimeMillis(); - lastCommandType = "command_worker_error"; - lastCommandResult = e.getClass().getSimpleName() + ": " + e.getMessage(); - lastCommandAt = System.currentTimeMillis(); - serviceState = "command error: " + e.getMessage(); - try { - reportStatusWithFallback(backendUrl, clusterId, deviceId, statusPayload("command_worker_error")); - } catch (Exception ignored) { - } - } finally { - commandInProgress.set(false); - commandStartedAt = 0; - } - }, "rap-vpn-diagnostic-command"); - commandWorker.start(); - } - - private void handleCommand(String backendUrl, RapApiClient client, String clusterId, String deviceId, JSONObject envelope) throws Exception { - JSONObject command = envelope.optJSONObject("vpn_client_diagnostic_command"); - JSONObject payload = command == null ? envelope.optJSONObject("payload") : command.optJSONObject("payload"); - if (payload == null) { - return; - } - String type = payload.optString("type", ""); - JSONObject params = payload.optJSONObject("payload"); - if (params == null) { - params = payload.optJSONObject("params"); - } - if (params == null) { - params = payload; - } - String result; - lastCommandType = type; - lastCommandResult = "running id=" + (command == null ? "" : command.optString("id", "")) + " type=" + type; - lastCommandAt = System.currentTimeMillis(); - if ("start_vpn".equals(type)) { - result = startVPNFromSavedProfile(); - } else if ("stop_vpn".equals(type)) { - Intent stopIntent = new Intent(this, RapVpnService.class); - stopIntent.setAction(RapVpnService.ACTION_STOP); - startService(stopIntent); - result = "stop_vpn accepted"; - } else if ("http_get".equals(type)) { - result = runHttpGet(params.optString("url", "http://192.168.200.61:18080/")); - } else if ("vpn_http_get".equals(type)) { - result = runVPNHttpGet(params.optString("url", "http://192.168.200.61:18080/"), params.optInt("timeout_ms", 15000)); - } else if ("vpn_page_probe".equals(type)) { - result = runVPNPageProbe(params); - } else if ("vpn_tcp_connect".equals(type)) { - result = runVPNTCPConnect(params.optString("host", "192.168.200.95"), params.optInt("port", 3389), params.optInt("timeout_ms", 7000)); - } else if ("vpn_tcp_connect_default".equals(type)) { - result = runDefaultTCPConnect(params.optString("host", "192.168.200.95"), params.optInt("port", 3389), params.optInt("timeout_ms", 7000)); - } else if ("vpn_dns_lookup".equals(type)) { - result = runVPNDNSLookup(params.optString("host", "2ip.ru")); - } else if ("open_url".equals(type)) { - String url = params.optString("url", "http://2ip.ru/"); - Intent open = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(open); - result = "open_url accepted " + url; - } else if ("open_chrome_test".equals(type) || "open_external_browser_test".equals(type)) { - result = openExternalBrowserTest( - params.optString("url", "https://speedtest.rt.ru/"), - params.optString("package", "")); - } else if ("open_webview_test".equals(type) || "browser_page_test".equals(type)) { - result = openWebViewTest(params.optString("url", "https://speedtest.rt.ru/")); - } else if ("vpn_stats".equals(type)) { - result = collectVPNStats(client, clusterId); - } else if ("device_network_snapshot".equals(type)) { - result = deviceNetworkSnapshot(); - } else if ("vpn_deep_test".equals(type)) { - result = runVPNDeepTest(client, clusterId, params); - } else if ("vpn_download_test".equals(type)) { - result = runVPNDownloadTest(params.optString("url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json")); - } else if ("vpn_mixed_load_test".equals(type) || "vpn_parallel_http_get".equals(type)) { - result = runVPNMixedLoadTest(params); - } else if ("launch_telegram".equals(type)) { - result = openExternalURL(params.optString("url", "tg://resolve?domain=telegram")); - } else if ("remote_assist_start".equals(type)) { - showVisibleMessage("Необходим полный доступ к VPN-диагностике. Сеанс удаленной диагностики начат."); - result = "remote_assist_start accepted: scoped vpn diagnostics only"; - } else if ("remote_assist_end".equals(type)) { - String message = params.optString("message", "Сеанс удаленной диагностики завершен."); - showVisibleMessage(message); - result = "remote_assist_end accepted"; - } else if ("full_vpn_test".equals(type)) { - result = runFullVPNTest(client, clusterId, params); - } else if ("install_profile".equals(type) || "apply_profile".equals(type)) { - result = installProfileFromCommand(params); - } else if ("refresh_profile".equals(type)) { - result = refreshProfile(); - } else { - result = "unknown command " + type; - } - if (isRecoverableVPNProbe(type) && params.optBoolean("allow_recovery", false) && looksLikeVPNStall(result)) { - String firstResult = result; - String recovery = controlledRestartVPNRuntime(client, clusterId); - String ready = waitForVPNRuntimeReady(RECOVERY_RUNTIME_READY_TIMEOUT_MS); - String recoveryRetry = runVPNProbeCommand(type, recoveryProbePayload(type, params), true); - if (!looksLikeVPNStall(recoveryRetry)) { - result = firstResult + " | recovery=" + recovery + " | ready=" + ready + " | recovery_retry=" + recoveryRetry; - } else { - Thread.sleep(1500); - result = firstResult + " | recovery=" + recovery + " | ready=" + ready + " | recovery_retry=" + recoveryRetry + " | final_retry=" + runVPNProbeCommand(type, recoveryProbePayload(type, params), true); - } - } - lastCommandType = type; - lastCommandResult = result; - lastCommandAt = System.currentTimeMillis(); - JSONObject report = statusPayload("command_result"); - report.put("command_type", type); - report.put("command_result", result); - try { - reportStatusWithFallback(backendUrl, clusterId, deviceId, report); - } catch (Exception e) { - serviceState = "command result report failed: " + e.getMessage(); - } - } - - private RapApiClient controlClient(String backendUrl) { - return new RapApiClient(backendUrl, this, true); - } - - private JSONObject nextCommandWithFallback(String backendUrl, String clusterId, String deviceId) throws Exception { - Exception last = null; - for (ControlEndpoint endpoint : controlEndpoints(backendUrl)) { - try { - RapApiClient client = endpoint.client(this); - controlNetworkMode = client.networkMode() + " " + endpoint.url; - return client.nextVPNDiagnosticCommand(clusterId, deviceId, 0); - } catch (Exception e) { - last = e; - } - } - if (last != null) { - throw last; - } - return null; - } - - private void reportStatusWithFallback(String backendUrl, String clusterId, String deviceId, JSONObject payload) throws Exception { - Exception last = null; - for (ControlEndpoint endpoint : controlEndpoints(backendUrl)) { - try { - RapApiClient client = endpoint.client(this); - controlNetworkMode = client.networkMode() + " " + endpoint.url; - client.reportVPNDiagnosticStatus(clusterId, deviceId, payload); - return; - } catch (Exception e) { - last = e; - } - } - if (last != null) { - throw last; - } - } - - private List controlEndpoints(String primary) { - List endpoints = new ArrayList<>(); - LinkedHashSet seen = new LinkedHashSet<>(); - addControlEndpoint(endpoints, seen, DEFAULT_BACKEND_URL, false); - addControlEndpoint(endpoints, seen, primary, false); - boolean vpnRuntimeActive = isVpnRuntimeLikelyActive() || vpnNetwork() != null; - if (vpnRuntimeActive) { - addControlEndpoint(endpoints, seen, INTERNAL_BACKEND_URL, true); - } - SharedPreferences runtime = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE); - addControlEndpoint(endpoints, seen, runtime.getString("packet_relay_active_base_url", ""), false); - String candidates = runtime.getString("packet_relay_candidate_urls", ""); - for (String candidate : candidates.split(",")) { - addControlEndpoint(endpoints, seen, candidate, false); - } - return endpoints; - } - - private void addControlEndpoint(List endpoints, LinkedHashSet seen, String value, boolean viaVPN) { - String normalized = normalizeBackendUrl(value == null ? "" : value.trim()); - String key = (viaVPN ? "vpn|" : "direct|") + normalized; - if (!normalized.isEmpty() && seen.add(key)) { - endpoints.add(new ControlEndpoint(normalized, viaVPN)); - } - } - - private boolean isVpnRuntimeLikelyActive() { - SharedPreferences runtime = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE); - long updatedAt = runtime.getLong("updated_at", 0); - if (updatedAt <= 0 || System.currentTimeMillis() - updatedAt > 30000) { - return false; - } - String relay = runtime.getString("packet_relay_active_base_url", ""); - String state = runtime.getString("state", ""); - boolean fabricMode = runtime.getBoolean("mesh_node_route_mode", false) - || "fabric_mesh_node_route_v1".equals(runtime.getString("dataplane_selected_transport", "")); - if (fabricMode && ("fabric".equals(state) - || "fabric_downlink".equals(state) - || "uplink_sent".equals(state) - || "uplink_read".equals(state) - || "downlink".equals(state) - || "downlink_idle".equals(state))) { - return true; - } - if (!relay.isEmpty() && ("running".equals(state) - || "ready".equals(state) - || "warming".equals(state) - || "tunnel".equals(state) - || "relay".equals(state) - || "downlink".equals(state) - || "downlink_idle".equals(state) - || "runtime_recovery".equals(state))) { - return true; - } - long sent = runtime.getLong("uplink_sent_total", 0); - long down = runtime.getLong("downlink_received_total", 0); - return (fabricMode || !relay.isEmpty()) && (sent > 0 || down > 0); - } - - private static final class ControlEndpoint { - final String url; - final boolean viaVPN; - - ControlEndpoint(String url, boolean viaVPN) { - this.url = url; - this.viaVPN = viaVPN; - } - - RapApiClient client(RapDiagnosticService service) { - if (!viaVPN) { - return new RapApiClient(url, service, true); - } - Network vpn = service.vpnNetwork(); - if (vpn != null) { - return new RapApiClient(url, vpn); - } - return new RapApiClient(url); - } - } - - private String startVPNFromSavedProfile() { - SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); - String profileJson = prefs.getString(PREF_PROFILE_JSON, ""); - String backendUrl = prefs.getString("backend_url", ""); - String clusterId = prefs.getString("cluster_id", ""); - String vpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, ""); - if (profileJson.isEmpty() || backendUrl.isEmpty() || clusterId.isEmpty() || vpnConnectionId.isEmpty()) { - return "start_vpn skipped: profile/backend/cluster/connection missing"; - } - if (VpnService.prepare(this) != null) { - Intent launcher = new Intent(this, TestVpnActivity.class); - launcher.putExtra(TestVpnActivity.EXTRA_PROFILE_JSON, profileJson); - launcher.putExtra(TestVpnActivity.EXTRA_BACKEND_URL, backendUrl); - launcher.putExtra(TestVpnActivity.EXTRA_CLUSTER_ID, clusterId); - launcher.putExtra(TestVpnActivity.EXTRA_VPN_CONNECTION_ID, vpnConnectionId); - launcher.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(launcher); - return "start_vpn permission required: opened vpn launcher " + vpnConnectionId; - } - Intent intent = new Intent(this, RapVpnService.class); - intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson); - intent.putExtra(RapVpnService.EXTRA_BACKEND_URL, backendUrl); - intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId); - intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId); - if (Build.VERSION.SDK_INT >= 26) { - startForegroundService(intent); - } else { - startService(intent); - } - return "start_vpn accepted " + vpnConnectionId; - } - - private boolean isRecoverableVPNProbe(String type) { - return "vpn_http_get".equals(type) - || "vpn_page_probe".equals(type) - || "vpn_tcp_connect".equals(type) - || "vpn_download_test".equals(type); - } - - private boolean looksLikeVPNStall(String result) { - if (result == null) { - return false; - } - return result.contains("SocketTimeoutException") - || result.contains("failed to connect") - || result.contains("Read timed out") - || result.contains("Connection reset") - || result.contains("No route to host"); - } - - private String runVPNProbeCommand(String type, JSONObject payload) { - return runVPNProbeCommand(type, payload, false); - } - - private String runVPNProbeCommand(String type, JSONObject payload, boolean recoveryAttempt) { - if ("vpn_http_get".equals(type)) { - return runVPNHttpGet(payload.optString("url", "http://192.168.200.61:18080/"), payload.optInt("timeout_ms", 15000)); - } - if ("vpn_page_probe".equals(type)) { - return runVPNPageProbe(payload); - } - if ("vpn_tcp_connect".equals(type)) { - return runVPNTCPConnect(payload.optString("host", "192.168.200.95"), payload.optInt("port", 3389), payload.optInt("timeout_ms", 7000)); - } - if ("vpn_download_test".equals(type)) { - if (recoveryAttempt) { - return runVPNDownloadTest( - payload.optString("url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json"), - RECOVERY_DOWNLOAD_CONNECT_TIMEOUT_MS, - RECOVERY_DOWNLOAD_READ_TIMEOUT_MS); - } - return runVPNDownloadTest(payload.optString("url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json")); - } - return "retry skipped: unsupported probe " + type; - } - - private JSONObject recoveryProbePayload(String type, JSONObject payload) { - JSONObject copy; - try { - copy = payload == null ? new JSONObject() : new JSONObject(payload.toString()); - } catch (Exception e) { - copy = new JSONObject(); - } - try { - if ("vpn_tcp_connect".equals(type)) { - int requested = copy.optInt("timeout_ms", RECOVERY_TCP_TIMEOUT_MS); - copy.put("timeout_ms", Math.max(1000, Math.min(requested, RECOVERY_TCP_TIMEOUT_MS))); - } else if ("vpn_page_probe".equals(type)) { - int requested = copy.optInt("timeout_ms", RECOVERY_PAGE_TIMEOUT_MS); - copy.put("timeout_ms", Math.max(10000, Math.min(requested, RECOVERY_PAGE_TIMEOUT_MS))); - } - } catch (Exception ignored) { - } - return copy; - } - - private String controlledRestartVPNRuntime(RapApiClient client, String clusterId) { - SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); - String connectionId = prefs.getString(PREF_VPN_CONNECTION_ID, ""); - if (connectionId.isEmpty()) { - return "restart skipped: connection missing"; - } - try { - try { - client.resetVPNPacketQueues(clusterId, connectionId); - } catch (Exception e) { - return "restart failed: queue reset failed: " + e.getMessage(); - } - Thread.sleep(300); - String refresh = refreshProfile(); - String start = startVPNFromSavedProfile(); - return start + " profile_refresh=" + refresh; - } catch (Exception e) { - return "restart failed: " + e.getClass().getSimpleName() + ": " + e.getMessage(); - } - } - - private String waitForVPNRuntimeReady(int timeoutMs) { - SharedPreferences runtime = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE); - long deadline = System.currentTimeMillis() + Math.max(1000, timeoutMs); - String last = "unknown"; - while (System.currentTimeMillis() < deadline) { - String state = runtime.getString("state", ""); - String senderState = runtime.getString("uplink_sender_state", ""); - boolean senderAlive = runtime.getBoolean("uplink_sender_thread_alive", false); - boolean downlinkAlive = runtime.getBoolean("downlink_thread_alive", false); - long updatedAt = runtime.getLong("updated_at", 0); - long age = updatedAt <= 0 ? Long.MAX_VALUE : System.currentTimeMillis() - updatedAt; - last = state + "/sender=" + senderState + "/age_ms=" + age; - boolean readyState = "downlink".equals(state) - || "downlink_idle".equals(state) - || "fabric".equals(state) - || "fabric_downlink".equals(state) - || "uplink_sent".equals(state) - || "uplink_read".equals(state) - || "runtime_recovery".equals(state); - if (readyState && senderAlive && downlinkAlive && age < 3000) { - return "runtime_ready " + last; - } - try { - Thread.sleep(250); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return "runtime_ready interrupted " + last; - } - } - return "runtime_ready timeout " + last; - } - - private void maybeRestartVPNAfterAppUpgrade(RapApiClient client, String clusterId, SharedPreferences prefs) { - String lastVersion = prefs.getString("vpn_runtime_app_version", ""); - if (APP_VERSION.equals(lastVersion)) { - return; - } - recoverSavedUserId(prefs); - String connectionId = prefs.getString(PREF_VPN_CONNECTION_ID, ""); - String profileJson = prefs.getString(PREF_PROFILE_JSON, ""); - if (connectionId.isEmpty() || profileJson.isEmpty()) { - prefs.edit().putString("vpn_runtime_app_version", APP_VERSION).apply(); - return; - } - serviceState = "upgrade restart " + lastVersion + " -> " + APP_VERSION; - try { - String refresh = refreshProfile(); - if (refresh.startsWith("refresh_profile failed")) { - lastCommandResult = "vpn runtime profile refresh before upgrade restart failed: " + refresh; - } - try { - client.resetVPNPacketQueues(clusterId, connectionId); - } catch (Exception ignored) { - } - Thread.sleep(300); - startVPNFromSavedProfile(); - prefs.edit().putString("vpn_runtime_app_version", APP_VERSION).apply(); - lastCommandType = "auto_upgrade_restart"; - lastCommandResult = "vpn runtime reinitialized after app upgrade " + lastVersion + " -> " + APP_VERSION + " profile_refresh=" + refresh; - lastCommandAt = System.currentTimeMillis(); - } catch (Exception e) { - lastCommandType = "auto_upgrade_restart"; - lastCommandResult = "vpn runtime upgrade restart failed: " + e.getClass().getSimpleName() + ": " + e.getMessage(); - lastCommandAt = System.currentTimeMillis(); - } - } - - private void recoverSavedUserId(SharedPreferences prefs) { - if (prefs == null || !prefs.getString(PREF_USER_ID, "").trim().isEmpty()) { - return; - } - String savedUserId = savedUserIdForProfileRefresh(prefs); - if (savedUserId == null || savedUserId.trim().isEmpty()) { - return; - } - prefs.edit().putString(PREF_USER_ID, savedUserId.trim()).apply(); - } - - private String refreshProfile() { - SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); - try { - String refreshToken = new SecureTokenStore(this).get(PREF_REFRESH_TOKEN); - if (refreshToken.isEmpty()) { - String savedUserId = savedUserIdForProfileRefresh(prefs); - if (savedUserId == null || savedUserId.trim().isEmpty()) { - return "refresh_profile skipped: refresh token and saved user missing"; - } - return refreshProfileForUser(prefs, savedUserId.trim(), null); - } - RapApiClient client = new RapApiClient(normalizeBackendUrl(prefs.getString("backend_url", "")), this, true); - RapApiClient.AuthContext auth = client.refresh(refreshToken); - new SecureTokenStore(this).put(PREF_REFRESH_TOKEN, auth.refreshToken); - return refreshProfileForUser(prefs, auth.userId, auth.deviceId); - } catch (Exception e) { - return "refresh_profile failed: " + e.getMessage(); - } - } - - private String savedUserIdForProfileRefresh(SharedPreferences prefs) { - String savedUserId = prefs.getString(PREF_USER_ID, ""); - if (savedUserId != null && !savedUserId.trim().isEmpty()) { - return savedUserId.trim(); - } - try { - String profileJson = prefs.getString(PREF_PROFILE_JSON, ""); - if (profileJson == null || profileJson.trim().isEmpty()) { - return ""; - } - JSONObject root = new JSONObject(profileJson); - JSONObject profile = root.optJSONObject("vpn_client_profile"); - if (profile == null) { - profile = root; - } - String profileUserId = profile.optString("user_id", "").trim(); - if (!profileUserId.isEmpty()) { - prefs.edit().putString(PREF_USER_ID, profileUserId).apply(); - return profileUserId; - } - } catch (Exception ignored) { - } - return ""; - } - - private void maybeRecoverExpiredFabricLease() { - SharedPreferences runtime = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE); - String downlinkError = runtime.getString("downlink_error_type", "") + " " + runtime.getString("downlink_message", ""); - String uplinkError = runtime.getString("uplink_sender_error_type", "") + " " + runtime.getString("uplink_sender_message", ""); - String combined = (downlinkError + " " + uplinkError).toLowerCase(); - if (!combined.contains("403 forbidden") && !combined.contains("service channel lease expired")) { - return; - } - long now = System.currentTimeMillis(); - if (now - lastFabricLeaseRefreshAttemptAt < 60000) { - return; - } - lastFabricLeaseRefreshAttemptAt = now; - String refresh = refreshProfile(); - if (!refresh.startsWith("refresh_profile ok")) { - serviceState = "fabric lease recovery refresh skipped: " + refresh; - return; - } - String start = startVPNFromSavedProfile(); - serviceState = "fabric lease recovery restarted vpn: " + start; - lastCommandType = "auto_fabric_lease_recovery"; - lastCommandResult = start + " profile_refresh=" + refresh; - lastCommandAt = now; - } - - private String refreshProfileForUser(SharedPreferences prefs, String userId, String trustedDeviceId) throws Exception { - String backendUrl = normalizeBackendUrl(prefs.getString("backend_url", DEFAULT_BACKEND_URL)); - String organizationId = prefs.getString("organization_id", DEFAULT_ORGANIZATION_ID); - String clusterId = prefs.getString("cluster_id", DEFAULT_CLUSTER_ID); - if (clusterId == null || clusterId.trim().isEmpty()) { - clusterId = DEFAULT_CLUSTER_ID; - } - if (organizationId == null || organizationId.trim().isEmpty()) { - organizationId = DEFAULT_ORGANIZATION_ID; - } - RapApiClient client = new RapApiClient(backendUrl, this, true); - String profileJson = client.vpnClientProfile(clusterId, organizationId, userId, ""); - JSONObject root = new JSONObject(profileJson); - JSONObject profile = root.getJSONObject("vpn_client_profile"); - String connectionId = profile.getJSONArray("connections").getJSONObject(0).getString("id"); - SharedPreferences.Editor editor = prefs.edit() - .putString("backend_url", backendUrl) - .putString("cluster_id", clusterId) - .putString("organization_id", organizationId) - .putString(PREF_USER_ID, userId) - .putString(PREF_PROFILE_JSON, profileJson) - .putString(PREF_VPN_CONNECTION_ID, connectionId); - if (trustedDeviceId != null && !trustedDeviceId.trim().isEmpty()) { - editor.putString(PREF_DEVICE_ID, trustedDeviceId.trim()); - } - editor.apply(); - return "refresh_profile ok " + connectionId; - } - - private String installProfileFromCommand(JSONObject params) { - try { - String backendUrl = normalizeBackendUrl(params.optString("backend_url", DEFAULT_BACKEND_URL)); - String clusterId = params.optString("cluster_id", DEFAULT_CLUSTER_ID).trim(); - String organizationId = params.optString("organization_id", DEFAULT_ORGANIZATION_ID).trim(); - String userId = params.optString("user_id", "").trim(); - String trustedDeviceId = params.optString("trusted_device_id", "").trim(); - String profileJson = params.optString("profile_json", "").trim(); - JSONObject root; - if (profileJson.isEmpty()) { - JSONObject profile = params.optJSONObject("vpn_client_profile"); - if (profile == null) { - profile = params.optJSONObject("profile"); - } - if (profile == null) { - return "install_profile skipped: profile missing"; - } - root = new JSONObject(); - root.put("vpn_client_profile", profile); - profileJson = root.toString(); - } else { - root = new JSONObject(profileJson); - if (!root.has("vpn_client_profile")) { - JSONObject wrapped = new JSONObject(); - wrapped.put("vpn_client_profile", root); - root = wrapped; - profileJson = wrapped.toString(); - } - } - JSONObject profile = root.getJSONObject("vpn_client_profile"); - if (clusterId.isEmpty()) { - clusterId = profile.optString("cluster_id", DEFAULT_CLUSTER_ID); - } - if (organizationId.isEmpty()) { - organizationId = profile.optString("organization_id", DEFAULT_ORGANIZATION_ID); - } - if (userId.isEmpty()) { - userId = profile.optString("user_id", ""); - } - JSONObject connection = profile.getJSONArray("connections").getJSONObject(0); - String connectionId = params.optString("vpn_connection_id", connection.optString("id", "")).trim(); - SharedPreferences.Editor editor = getSharedPreferences(PREFS, MODE_PRIVATE).edit() - .putString("backend_url", backendUrl) - .putString("cluster_id", clusterId) - .putString("organization_id", organizationId) - .putString(PREF_USER_ID, userId) - .putString(PREF_PROFILE_JSON, profileJson) - .putString(PREF_VPN_CONNECTION_ID, connectionId); - if (!trustedDeviceId.isEmpty()) { - editor.putString(PREF_DEVICE_ID, trustedDeviceId); - } - editor.remove(PREF_SELECTED_EXIT_NODE_ID); - editor.apply(); - Intent stopIntent = new Intent(this, RapVpnService.class); - stopIntent.setAction(RapVpnService.ACTION_STOP); - startService(stopIntent); - Thread.sleep(300); - return "install_profile ok " + connectionId + " | " + startVPNFromSavedProfile(); - } catch (Exception e) { - return "install_profile failed: " + e.getClass().getSimpleName() + ": " + e.getMessage(); - } - } - - private JSONObject statusPayload(String event) throws Exception { - SharedPreferences prefs = getSharedPreferences(PREFS, MODE_PRIVATE); - String deviceId = diagnosticDeviceId(prefs); - JSONObject payload = new JSONObject(); - payload.put("event", event); - payload.put("app_version", APP_VERSION); - payload.put("service", "diagnostic"); - payload.put("user_id", prefs.getString(PREF_USER_ID, "")); - payload.put("device_id", deviceId); - payload.put("trusted_device_id", prefs.getString(PREF_DEVICE_ID, "")); - payload.put("diagnostic_device_id", prefs.getString(PREF_DIAGNOSTIC_DEVICE_ID, "")); - payload.put("organization_id", prefs.getString("organization_id", "")); - payload.put("vpn_connection_id", prefs.getString(PREF_VPN_CONNECTION_ID, "")); - payload.put("backend_url", prefs.getString("backend_url", "")); - payload.put("control_network_mode", controlNetworkMode); - payload.put("profile_loaded", !prefs.getString(PREF_PROFILE_JSON, "").isEmpty()); - payload.put("runtime", runtimeSnapshot()); - payload.put("vpn_config", vpnConfigSnapshot()); - payload.put("service_state", serviceState); - payload.put("last_result", lastCommandResult); - payload.put("last_command_type", lastCommandType); - payload.put("last_command_result", lastCommandResult); - payload.put("last_command_at", lastCommandAt); - payload.put("last_heartbeat_at", lastHeartbeatAt); - payload.put("last_command_poll_at", lastCommandPollAt); - payload.put("last_command_poll_result", lastCommandPollResult); - payload.put("last_received_command_id", lastReceivedCommandID); - payload.put("last_received_command_type", lastReceivedCommandType); - payload.put("last_received_command_at", lastReceivedCommandAt); - payload.put("heartbeat_in_progress", heartbeatInProgress.get()); - payload.put("heartbeat_started_at", heartbeatStartedAt); - payload.put("command_poll_in_progress", commandPollInProgress.get()); - payload.put("command_poll_started_at", commandPollStartedAt); - payload.put("command_in_progress", commandInProgress.get()); - payload.put("command_started_at", commandStartedAt); - payload.put("browser_test", browserTestSnapshot()); - return payload; - } - - private String describeCommandEnvelope(JSONObject envelope) { - if (envelope == null) { - return "no_content"; - } - JSONObject command = envelope.optJSONObject("vpn_client_diagnostic_command"); - JSONObject payload = command == null ? envelope.optJSONObject("payload") : command.optJSONObject("payload"); - String id = command == null ? "" : command.optString("id", ""); - String type = payload == null ? "" : payload.optString("type", ""); - String value = "received"; - if (!type.isEmpty()) { - value += " " + type; - } - if (!id.isEmpty()) { - value += " " + id; - } - return value; - } - - private void rememberReceivedCommand(JSONObject envelope) { - JSONObject command = envelope == null ? null : envelope.optJSONObject("vpn_client_diagnostic_command"); - JSONObject payload = command == null ? (envelope == null ? null : envelope.optJSONObject("payload")) : command.optJSONObject("payload"); - lastReceivedCommandID = command == null ? "" : command.optString("id", ""); - lastReceivedCommandType = payload == null ? "" : payload.optString("type", ""); - lastReceivedCommandAt = System.currentTimeMillis(); - } - - private String diagnosticDeviceId(SharedPreferences prefs) { - String cached = prefs.getString(PREF_DIAGNOSTIC_DEVICE_ID, ""); - if (cached != null && !cached.trim().isEmpty()) { - return cached.trim(); - } - String trusted = prefs.getString(PREF_DEVICE_ID, ""); - if (trusted != null && trusted.trim().startsWith("diag-")) { - String stable = trusted.trim(); - prefs.edit().putString(PREF_DIAGNOSTIC_DEVICE_ID, stable).apply(); - return stable; - } - String androidId = ""; - try { - androidId = Settings.Secure.getString(getContentResolver(), Settings.Secure.ANDROID_ID); - } catch (Exception ignored) { - } - String seed = androidId == null || androidId.trim().isEmpty() - ? UUID.randomUUID().toString() - : androidId.trim(); - String generated = "diag-" + seed.replaceAll("[^A-Za-z0-9_-]", "").toLowerCase(); - if (generated.length() > 80) { - generated = generated.substring(0, 80); - } - prefs.edit().putString(PREF_DIAGNOSTIC_DEVICE_ID, generated).apply(); - return generated; - } - - private String normalizeBackendUrl(String value) { - String candidate = value == null ? "" : value.trim().replaceAll("/+$", ""); - if (candidate.isEmpty()) { - return DEFAULT_BACKEND_URL; - } - String lower = candidate.toLowerCase(); - if ("http://vpn.cin.su:19191/api/v1".equals(lower) - || "http://vpn.cin.su/api/v1".equals(lower) - || "https://vpn.cin.su:443/api/v1".equals(lower) - || "http://94.141.118.222:19191/api/v1".equals(lower) - || "http://195.123.240.88:19131/api/v1".equals(lower)) { - return DEFAULT_BACKEND_URL; - } - return candidate; - } - - private String collectVPNStats(RapApiClient client, String clusterId) { - String connectionId = getSharedPreferences(PREFS, MODE_PRIVATE).getString(PREF_VPN_CONNECTION_ID, ""); - if (connectionId.isEmpty()) { - return "vpn_stats skipped: connection missing"; - } - try { - JSONObject stats = client.vpnPacketStats(clusterId, connectionId); - return "vpn_stats " + compact(stats.toString(), 900); - } catch (Exception e) { - return "vpn_stats failed: " + e.getMessage(); - } - } - - private JSONObject browserTestSnapshot() throws Exception { - SharedPreferences prefs = getSharedPreferences(TestTrafficActivity.PREFS, MODE_PRIVATE); - JSONObject payload = new JSONObject(); - payload.put("state", prefs.getString("state", "")); - payload.put("message", prefs.getString("message", "")); - payload.put("progress", prefs.getInt("progress", 0)); - payload.put("url", prefs.getString("url", "")); - payload.put("target_url", prefs.getString("target_url", "")); - payload.put("error_type", prefs.getString("error_type", "")); - payload.put("asset_error_count", prefs.getInt("asset_error_count", 0)); - payload.put("main_error_count", prefs.getInt("main_error_count", 0)); - payload.put("http_error_count", prefs.getInt("http_error_count", 0)); - payload.put("updated_at", prefs.getLong("updated_at", 0)); - payload.put("http_probe", prefs.getString("http_probe", "")); - payload.put("http_probe_at", prefs.getLong("http_probe_at", 0)); - payload.put("dom_probe", prefs.getString("dom_probe", "")); - payload.put("dom_probe_at", prefs.getLong("dom_probe_at", 0)); - return payload; - } - - private String runFullVPNTest(RapApiClient client, String clusterId, JSONObject payload) { - String url = payload.optString("url", "http://2ip.ru/"); - int watchSeconds = payload.optInt("watch_seconds", 30); - if (watchSeconds < 5) { - watchSeconds = 5; - } - if (watchSeconds > 120) { - watchSeconds = 120; - } - String connectionId = getSharedPreferences(PREFS, MODE_PRIVATE).getString(PREF_VPN_CONNECTION_ID, ""); - StringBuilder result = new StringBuilder(); - try { - result.append(refreshProfile()).append(" | "); - if (!connectionId.isEmpty()) { - result.append("reset=").append(compact(client.resetVPNPacketQueues(clusterId, connectionId).toString(), 240)).append(" | "); - } - result.append(startVPNFromSavedProfile()).append(" | "); - Thread.sleep(3000); - Intent open = new Intent(Intent.ACTION_VIEW, Uri.parse(url)); - open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(open); - result.append("open_url=").append(url); - long deadline = System.currentTimeMillis() + watchSeconds * 1000L; - while (running && System.currentTimeMillis() < deadline) { - Thread.sleep(5000); - JSONObject report = statusPayload("full_vpn_test_watch"); - report.put("test_url", url); - if (!connectionId.isEmpty()) { - report.put("packet_stats", client.vpnPacketStats(clusterId, connectionId)); - } - client.reportVPNDiagnosticStatus(clusterId, diagnosticDeviceId(getSharedPreferences(PREFS, MODE_PRIVATE)), report); - } - if (!connectionId.isEmpty()) { - result.append(" | stats=").append(compact(client.vpnPacketStats(clusterId, connectionId).toString(), 900)); - } - } catch (Exception e) { - result.append(" | full_vpn_test failed: ").append(e.getClass().getSimpleName()).append(": ").append(e.getMessage()); - } - return compact(result.toString(), 1200); - } - - private String runVPNDeepTest(RapApiClient client, String clusterId, JSONObject payload) { - String url = payload.optString("url", "http://2ip.ru/"); - String host = payload.optString("host", "2ip.ru"); - String localUrl = payload.optString("local_url", "http://192.168.200.61:18080/"); - StringBuilder result = new StringBuilder(); - result.append("network={").append(deviceNetworkSnapshot()).append("}"); - result.append(" | stats=").append(collectVPNStats(client, clusterId)); - result.append(" | dns=").append(runVPNDNSLookup(host)); - result.append(" | vpn_http=").append(runVPNHttpGet(url, 15000)); - result.append(" | vpn_local_http=").append(runVPNHttpGet(localUrl, 15000)); - result.append(" | download=").append(runVPNDownloadTest(payload.optString("download_url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json"))); - return compact(result.toString(), 2500); - } - - private String runVPNDownloadTest(String target) { - return runVPNDownloadTest(target, 15000, 20000); - } - - private String runVPNDownloadTest(String target, int connectTimeoutMs, int readTimeoutMs) { - try { - Network vpn = waitForVPNNetwork(5000); - if (vpn == null) { - return "vpn_download_test " + target + " -> vpn network not found"; - } - URL url = new URL(target); - HttpURLConnection connection = (HttpURLConnection) vpn.openConnection(url); - connection.setConnectTimeout(Math.max(1000, connectTimeoutMs)); - connection.setReadTimeout(Math.max(1000, readTimeoutMs)); - connection.setInstanceFollowRedirects(false); - int code = connection.getResponseCode(); - int bytes = 0; - byte[] buffer = new byte[8192]; - try (java.io.InputStream input = connection.getInputStream()) { - while (bytes < 1024 * 1024) { - int n = input.read(buffer, 0, Math.min(buffer.length, 1024 * 1024 - bytes)); - if (n < 0) { - break; - } - bytes += n; - } - } - connection.disconnect(); - return "vpn_download_test " + target + " -> HTTP " + code + " bytes=" + bytes; - } catch (Exception e) { - return "vpn_download_test " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage(); - } - } - - private String runVPNMixedLoadTest(JSONObject payload) { - int parallel = payload.optInt("parallel", 12); - if (parallel < 1) { - parallel = 1; - } - if (parallel > 20) { - parallel = 20; - } - int timeoutMs = payload.optInt("timeout_ms", 20000); - if (timeoutMs < 3000) { - timeoutMs = 3000; - } - if (timeoutMs > 45000) { - timeoutMs = 45000; - } - String tcpHost = payload.optString("tcp_host", "192.168.200.95"); - int tcpPort = payload.optInt("tcp_port", 443); - String[] defaults = new String[] { - "http://2ip.ru/", - "http://example.com/", - "http://neverssl.com/", - "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json", - "http://192.168.200.61:18080/" - }; - List urls = new ArrayList<>(); - JSONArray payloadUrls = payload.optJSONArray("urls"); - if (payloadUrls != null) { - for (int i = 0; i < payloadUrls.length(); i++) { - String url = payloadUrls.optString(i, ""); - if (url != null && !url.trim().isEmpty()) { - urls.add(url.trim()); - } - } - } - if (urls.isEmpty()) { - for (String url : defaults) { - urls.add(url); - } - } - String tcpBefore = runVPNTCPConnect(tcpHost, tcpPort, Math.min(timeoutMs, 10000)); - String[] results = new String[parallel]; - Thread[] threads = new Thread[parallel]; - final int requestTimeoutMs = timeoutMs; - long started = System.currentTimeMillis(); - for (int i = 0; i < parallel; i++) { - final int index = i; - final String target = urls.get(i % urls.size()); - threads[i] = new Thread(() -> results[index] = runVPNHttpGet(target, requestTimeoutMs), "rap-vpn-load-test-" + index); - threads[i].start(); - } - for (Thread thread : threads) { - try { - thread.join(timeoutMs + 5000L); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - break; - } - } - long elapsed = System.currentTimeMillis() - started; - int ok = 0; - int failed = 0; - StringBuilder sample = new StringBuilder(); - for (int i = 0; i < results.length; i++) { - String item = results[i]; - if (item != null && item.contains("-> HTTP ")) { - ok++; - } else { - failed++; - } - if (i < 5) { - if (sample.length() > 0) { - sample.append("; "); - } - sample.append(item == null ? "timeout/no_result" : item); - } - } - String tcpAfter = runVPNTCPConnect(tcpHost, tcpPort, Math.min(timeoutMs, 10000)); - return compact("vpn_mixed_load_test parallel=" + parallel - + " ok=" + ok - + " failed=" + failed - + " elapsed_ms=" + elapsed - + " tcp_before={" + tcpBefore + "}" - + " tcp_after={" + tcpAfter + "}" - + " sample={" + sample + "}", 2500); - } - - private String openExternalURL(String target) { - try { - Intent open = new Intent(Intent.ACTION_VIEW, Uri.parse(target)); - open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK); - startActivity(open); - return "open_external_url accepted " + target; - } catch (Exception e) { - return "open_external_url " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage(); - } - } - - private String openWebViewTest(String target) { - try { - if (target == null || target.trim().isEmpty()) { - target = "https://speedtest.rt.ru/"; - } - Intent open = new Intent(this, TestTrafficActivity.class); - open.putExtra(TestTrafficActivity.EXTRA_URL, target); - open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); - startActivity(open); - return "open_webview_test accepted " + target; - } catch (Exception e) { - return "open_webview_test " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage(); - } - } - - private String openExternalBrowserTest(String target, String requestedPackage) { - try { - if (target == null || target.trim().isEmpty()) { - target = "https://speedtest.rt.ru/"; - } - Uri uri = Uri.parse(target); - Intent open = new Intent(Intent.ACTION_VIEW, uri); - open.addCategory(Intent.CATEGORY_BROWSABLE); - open.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); - open.putExtra("com.android.browser.application_id", getPackageName()); - open.putExtra("create_new_tab", true); - - String packageName = selectBrowserPackage(open, requestedPackage); - if (!packageName.isEmpty()) { - open.setPackage(packageName); - } - startActivity(open); - return "open_external_browser_test accepted " + target + " package=" + (packageName.isEmpty() ? "default" : packageName); - } catch (Exception e) { - return "open_external_browser_test " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage(); - } - } - - private String selectBrowserPackage(Intent probe, String requestedPackage) { - PackageManager packageManager = getPackageManager(); - if (packageManager == null) { - return ""; - } - if (requestedPackage != null && !requestedPackage.trim().isEmpty() && isPackageAvailable(packageManager, requestedPackage.trim())) { - return requestedPackage.trim(); - } - List preferred = Arrays.asList( - "com.android.chrome", - "com.chrome.beta", - "com.chrome.dev", - "com.google.android.apps.chrome", - "org.mozilla.firefox", - "com.sec.android.app.sbrowser", - "com.android.browser"); - for (String candidate : preferred) { - if (isPackageAvailable(packageManager, candidate)) { - return candidate; - } - } - try { - List infos = packageManager.queryIntentActivities(probe, 0); - if (infos != null && !infos.isEmpty() && infos.get(0).activityInfo != null) { - return infos.get(0).activityInfo.packageName == null ? "" : infos.get(0).activityInfo.packageName; - } - } catch (Exception ignored) { - } - return ""; - } - - private boolean isPackageAvailable(PackageManager packageManager, String packageName) { - try { - packageManager.getPackageInfo(packageName, 0); - return true; - } catch (Exception ignored) { - return false; - } - } - - private void showVisibleMessage(String message) { - new Handler(Looper.getMainLooper()).post(() -> Toast.makeText(this, message, Toast.LENGTH_LONG).show()); - } - - private String deviceNetworkSnapshot() { - try { - ConnectivityManager connectivity = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); - if (connectivity == null) { - return "connectivity unavailable"; - } - Network active = connectivity.getActiveNetwork(); - StringBuilder out = new StringBuilder(); - out.append("active=").append(active == null ? "none" : active.toString()); - Network[] networks = connectivity.getAllNetworks(); - out.append(" networks=").append(networks == null ? 0 : networks.length); - if (networks != null) { - for (Network network : networks) { - NetworkCapabilities capabilities = connectivity.getNetworkCapabilities(network); - LinkProperties link = connectivity.getLinkProperties(network); - out.append(" [").append(network); - if (capabilities != null) { - out.append(" transports="); - if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { - out.append("VPN,"); - } - if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI)) { - out.append("WIFI,"); - } - if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR)) { - out.append("CELL,"); - } - out.append("internet=").append(capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)); - } - if (link != null) { - out.append(" dns="); - List dns = link.getDnsServers(); - for (int i = 0; i < dns.size(); i++) { - if (i > 0) { - out.append(","); - } - out.append(dns.get(i).getHostAddress()); - } - out.append(" routes=").append(link.getRoutes().size()); - } - out.append("]"); - } - } - return compact(out.toString(), 1800); - } catch (Exception e) { - return "device_network_snapshot failed: " + e.getClass().getSimpleName() + ": " + e.getMessage(); - } - } - - private String compact(String value, int maxLength) { - if (value == null) { - return ""; - } - String compacted = value.replace('\n', ' ').replace('\r', ' '); - if (compacted.length() <= maxLength) { - return compacted; - } - return compacted.substring(0, Math.max(0, maxLength - 3)) + "..."; - } - - private JSONObject runtimeSnapshot() throws Exception { - SharedPreferences runtime = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE); - JSONObject payload = new JSONObject(); - payload.put("state", runtime.getString("state", "")); - payload.put("message", runtime.getString("message", "")); - payload.put("updated_at", runtime.getLong("updated_at", 0)); - payload.put("runtime_started_at", runtime.getLong("runtime_started_at", 0)); - payload.put("diagnostic_local_heartbeat_at", runtime.getLong("diagnostic_local_heartbeat_at", 0)); - payload.put("diagnostic_local_state", runtime.getString("diagnostic_local_state", "")); - payload.put("diagnostic_local_app_version", runtime.getString("diagnostic_local_app_version", "")); - payload.put("diagnostic_watchdog_started_at", runtime.getLong("diagnostic_watchdog_started_at", 0)); - payload.put("diagnostic_watchdog_last_ensure_at", runtime.getLong("diagnostic_watchdog_last_ensure_at", 0)); - payload.put("diagnostic_watchdog_last_heartbeat_age_ms", runtime.getLong("diagnostic_watchdog_last_heartbeat_age_ms", 0)); - payload.put("diagnostic_watchdog_last_action", runtime.getString("diagnostic_watchdog_last_action", "")); - payload.put("diagnostic_watchdog_last_error", runtime.getString("diagnostic_watchdog_last_error", "")); - payload.put("diagnostic_watchdog_ensure_requests", runtime.getLong("diagnostic_watchdog_ensure_requests", 0)); - payload.put("diagnostic_watchdog_restart_requests", runtime.getLong("diagnostic_watchdog_restart_requests", 0)); - payload.put("uplink_read", runtime.getLong("uplink_read", 0)); - payload.put("uplink_sent", runtime.getLong("uplink_sent", 0)); - payload.put("downlink_received", runtime.getLong("downlink_received", 0)); - payload.put("uplink_read_total", runtime.getLong("uplink_read_total", 0)); - payload.put("uplink_read_bytes", runtime.getLong("uplink_read_bytes", 0)); - payload.put("uplink_sent_total", runtime.getLong("uplink_sent_total", 0)); - payload.put("uplink_sent_bytes", runtime.getLong("uplink_sent_bytes", 0)); - payload.put("downlink_received_total", runtime.getLong("downlink_received_total", 0)); - payload.put("downlink_received_bytes", runtime.getLong("downlink_received_bytes", 0)); - payload.put("uplink_read_mbps", runtime.getFloat("uplink_read_mbps", 0f)); - payload.put("uplink_sent_mbps", runtime.getFloat("uplink_sent_mbps", 0f)); - payload.put("downlink_received_mbps", runtime.getFloat("downlink_received_mbps", 0f)); - payload.put("uplink_read_pps", runtime.getFloat("uplink_read_pps", 0f)); - payload.put("uplink_sent_pps", runtime.getFloat("uplink_sent_pps", 0f)); - payload.put("downlink_received_pps", runtime.getFloat("downlink_received_pps", 0f)); - payload.put("uplink_dropped_packets", runtime.getLong("uplink_dropped_packets", 0)); - payload.put("uplink_dropped_bytes", runtime.getLong("uplink_dropped_bytes", 0)); - payload.put("uplink_filtered_packets", runtime.getLong("uplink_filtered_packets", 0)); - payload.put("uplink_filtered_bytes", runtime.getLong("uplink_filtered_bytes", 0)); - payload.put("uplink_bypassed_control_packets", runtime.getLong("uplink_bypassed_control_packets", 0)); - payload.put("uplink_bypassed_control_bytes", runtime.getLong("uplink_bypassed_control_bytes", 0)); - payload.put("downlink_dropped_packets", runtime.getLong("downlink_dropped_packets", 0)); - payload.put("downlink_dropped_bytes", runtime.getLong("downlink_dropped_bytes", 0)); - payload.put("downlink_transport_checksum_repairs", runtime.getLong("downlink_transport_checksum_repairs", 0)); - payload.put("local_dns_queries", runtime.getLong("local_dns_queries", 0)); - payload.put("local_dns_replies", runtime.getLong("local_dns_replies", 0)); - payload.put("local_dns_errors", runtime.getLong("local_dns_errors", 0)); - payload.put("runtime_watchdog_recoveries", runtime.getLong("runtime_watchdog_recoveries", 0)); - payload.put("tcp_handshake_stalls", runtime.getLong("tcp_handshake_stalls", 0)); - payload.put("runtime_watchdog_hard_restarts", runtime.getLong("runtime_watchdog_hard_restarts", 0)); - payload.put("uplink_source_mismatch_packets", runtime.getLong("uplink_source_mismatch_packets", 0)); - payload.put("downlink_destination_mismatch_packets", runtime.getLong("downlink_destination_mismatch_packets", 0)); - payload.put("errors", runtime.getLong("errors", 0)); - payload.put("uplink", runtimePrefix(runtime, "uplink")); - payload.put("uplink_sender", runtimePrefix(runtime, "uplink_sender")); - payload.put("uplink_tcp", runtimePrefix(runtime, "uplink_tcp")); - payload.put("downlink", runtimePrefix(runtime, "downlink")); - payload.put("downlink_writer", runtimePrefix(runtime, "downlink_writer")); - payload.put("downlink_tcp", runtimePrefix(runtime, "downlink_tcp")); - payload.put("relay", runtimePrefix(runtime, "relay")); - payload.put("uplink_worker_count", runtime.getInt("uplink_worker_count", 0)); - payload.put("uplink_queue_depth_total", runtime.getInt("uplink_queue_depth_total", 0)); - payload.put("uplink_queue_depth_max", runtime.getInt("uplink_queue_depth_max", 0)); - payload.put("uplink_queue_depths", runtime.getString("uplink_queue_depths", "")); - payload.put("uplink_queue_0_offers", runtime.getLong("uplink_queue_0_offers", 0)); - payload.put("uplink_queue_1_offers", runtime.getLong("uplink_queue_1_offers", 0)); - payload.put("uplink_queue_2_offers", runtime.getLong("uplink_queue_2_offers", 0)); - payload.put("uplink_queue_3_offers", runtime.getLong("uplink_queue_3_offers", 0)); - payload.put("uplink_queue_0_drops", runtime.getLong("uplink_queue_0_drops", 0)); - payload.put("uplink_queue_1_drops", runtime.getLong("uplink_queue_1_drops", 0)); - payload.put("uplink_queue_2_drops", runtime.getLong("uplink_queue_2_drops", 0)); - payload.put("uplink_queue_3_drops", runtime.getLong("uplink_queue_3_drops", 0)); - payload.put("uplink_sender_worker_packets_0", runtime.getLong("uplink_sender_worker_packets_0", 0)); - payload.put("uplink_sender_worker_packets_1", runtime.getLong("uplink_sender_worker_packets_1", 0)); - payload.put("uplink_sender_worker_packets_2", runtime.getLong("uplink_sender_worker_packets_2", 0)); - payload.put("uplink_sender_worker_packets_3", runtime.getLong("uplink_sender_worker_packets_3", 0)); - payload.put("uplink_sender_worker_errors_0", runtime.getLong("uplink_sender_worker_errors_0", 0)); - payload.put("uplink_sender_worker_errors_1", runtime.getLong("uplink_sender_worker_errors_1", 0)); - payload.put("uplink_sender_worker_errors_2", runtime.getLong("uplink_sender_worker_errors_2", 0)); - payload.put("uplink_sender_worker_errors_3", runtime.getLong("uplink_sender_worker_errors_3", 0)); - payload.put("uplink_queue_depth", runtime.getInt("uplink_queue_depth", 0)); - payload.put("downlink_queue_depth", runtime.getInt("downlink_queue_depth", 0)); - payload.put("downlink_flow_queue_count", runtime.getInt("downlink_flow_queue_count", 0)); - payload.put("downlink_queue_depths", runtime.getString("downlink_queue_depths", "")); - payload.put("downlink_queue_depth_total", runtime.getInt("downlink_queue_depth_total", 0)); - payload.put("downlink_queue_depth_max", runtime.getInt("downlink_queue_depth_max", 0)); - payload.put("downlink_queued_packets", runtime.getLong("downlink_queued_packets", 0)); - payload.put("downlink_queue_waits", runtime.getLong("downlink_queue_waits", 0)); - payload.put("downlink_restarts", runtime.getLong("downlink_restarts", 0)); - payload.put("flow_isolation_mode", runtime.getString("flow_isolation_mode", "")); - return payload; - } - - private JSONObject vpnConfigSnapshot() throws Exception { - SharedPreferences runtime = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE); - JSONObject payload = new JSONObject(); - payload.put("vpn_address", runtime.getString("vpn_address", "")); - payload.put("dns_servers", runtime.getString("dns_servers", "")); - payload.put("routes", runtime.getString("routes", "")); - payload.put("full_tunnel", runtime.getBoolean("full_tunnel", false)); - payload.put("dataplane_session_status", runtime.getString("dataplane_session_status", "")); - payload.put("dataplane_preferred_transport", runtime.getString("dataplane_preferred_transport", "")); - payload.put("dataplane_fallback_transport", runtime.getString("dataplane_fallback_transport", "")); - payload.put("dataplane_entry_node_id", runtime.getString("dataplane_entry_node_id", "")); - payload.put("dataplane_exit_node_id", runtime.getString("dataplane_exit_node_id", "")); - payload.put("dataplane_selected_transport", runtime.getString("dataplane_selected_transport", "")); - payload.put("packet_relay_profile_base_url", runtime.getString("packet_relay_profile_base_url", "")); - payload.put("packet_relay_active_base_url", runtime.getString("packet_relay_active_base_url", "")); - payload.put("packet_relay_base_url", runtime.getString("packet_relay_base_url", "")); - payload.put("packet_relay_candidate_urls", runtime.getString("packet_relay_candidate_urls", "")); - payload.put("dataplane_transport_candidate_count", runtime.getInt("dataplane_transport_candidate_count", 0)); - payload.put("dataplane_entry_candidate_count", runtime.getInt("dataplane_entry_candidate_count", 0)); - payload.put("dataplane_exit_candidate_count", runtime.getInt("dataplane_exit_candidate_count", 0)); - payload.put("fabric_mesh_exit_endpoints", runtime.getString("fabric_mesh_exit_endpoints", "")); - return payload; - } - - private JSONObject runtimePrefix(SharedPreferences runtime, String prefix) throws Exception { - JSONObject payload = new JSONObject(); - payload.put("state", runtime.getString(prefix + "_state", "")); - payload.put("message", runtime.getString(prefix + "_message", "")); - payload.put("updated_at", runtime.getLong(prefix + "_updated_at", 0)); - payload.put("packets", runtime.getLong(prefix + "_packets", 0)); - payload.put("bytes", runtime.getLong(prefix + "_bytes", 0)); - payload.put("errors", runtime.getLong(prefix + "_errors", 0)); - payload.put("error_type", runtime.getString(prefix + "_error_type", "")); - payload.put("thread_alive", runtime.getBoolean(prefix + "_thread_alive", false)); - payload.put("rate_mbps", runtime.getFloat(prefix + "_rate_mbps", 0f)); - payload.put("rate_pps", runtime.getFloat(prefix + "_rate_pps", 0f)); - return payload; - } - - private String runHttpGet(String target) { - try { - HttpURLConnection connection = (HttpURLConnection) new URL(target).openConnection(); - connection.setConnectTimeout(15000); - connection.setReadTimeout(15000); - connection.setInstanceFollowRedirects(false); - int code = connection.getResponseCode(); - connection.disconnect(); - return "http_get " + target + " -> HTTP " + code; - } catch (Exception e) { - return "http_get " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage(); - } - } - - private String runVPNHttpGet(String target, int timeoutMs) { - int effectiveTimeoutMs = Math.max(1000, Math.min(timeoutMs, 30000)); - int connectTimeoutMs = Math.max(1000, Math.min(effectiveTimeoutMs, 10000)); - int readTimeoutMs = Math.max(1000, effectiveTimeoutMs - connectTimeoutMs); - try { - Network vpn = vpnNetwork(); - if (vpn == null) { - return "vpn_http_get " + target + " -> vpn network not found"; - } - URL url = new URL(target); - HttpURLConnection connection; - String resolved = ""; - if ("http".equalsIgnoreCase(url.getProtocol()) && !isIPv4Literal(url.getHost())) { - resolved = firstManualVPNAddress(vpn, url.getHost()); - } - if (!resolved.isEmpty()) { - URL resolvedURL = new URL(url.getProtocol(), resolved, url.getPort(), url.getFile()); - connection = (HttpURLConnection) vpn.openConnection(resolvedURL); - connection.setRequestProperty("Host", hostHeader(url)); - } else { - connection = (HttpURLConnection) vpn.openConnection(url); - } - try { - connection.setConnectTimeout(connectTimeoutMs); - connection.setReadTimeout(readTimeoutMs); - connection.setInstanceFollowRedirects(false); - int code = connection.getResponseCode(); - connection.disconnect(); - return "vpn_http_get " + target + " -> HTTP " + code; - } catch (UnknownHostException e) { - String fallbackResolved = firstManualVPNAddress(vpn, url.getHost()); - if (fallbackResolved.isEmpty() || !"http".equalsIgnoreCase(url.getProtocol())) { - throw e; - } - URL resolvedURL = new URL(url.getProtocol(), fallbackResolved, url.getPort(), url.getFile()); - connection = (HttpURLConnection) vpn.openConnection(resolvedURL); - connection.setRequestProperty("Host", hostHeader(url)); - connection.setConnectTimeout(connectTimeoutMs); - connection.setReadTimeout(readTimeoutMs); - connection.setInstanceFollowRedirects(false); - int code = connection.getResponseCode(); - connection.disconnect(); - return "vpn_http_get " + target + " -> HTTP " + code; - } - } catch (Exception e) { - return "vpn_http_get " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage(); - } - } - - private String runVPNPageProbe(JSONObject payload) { - String target = payload.optString("url", "https://speedtest.rt.ru/"); - int maxAssets = payload.optInt("max_assets", 16); - int totalTimeoutMs = payload.optInt("timeout_ms", 70000); - if (maxAssets < 1) { - maxAssets = 1; - } - if (maxAssets > 40) { - maxAssets = 40; - } - if (totalTimeoutMs < 10000) { - totalTimeoutMs = 10000; - } - if (totalTimeoutMs > 120000) { - totalTimeoutMs = 120000; - } - long deadline = System.currentTimeMillis() + totalTimeoutMs; - try { - Network vpn = vpnNetwork(); - if (vpn == null) { - return "vpn_page_probe " + target + " -> vpn network not found"; - } - FetchResult main = fetchVPNURL(vpn, new URL(target), Math.min(15000, totalTimeoutMs / 2), Math.min(20000, totalTimeoutMs / 2), 512 * 1024); - StringBuilder result = new StringBuilder(); - result.append("vpn_page_probe ").append(target) - .append(" -> main HTTP ").append(main.code) - .append(" bytes=").append(main.bytes) - .append(" ms=").append(main.elapsedMs) - .append(" type=").append(main.contentType); - Set assets = extractAssetURLs(new URL(target), main.body, maxAssets); - int ok = 0; - int failed = 0; - int skipped = 0; - long totalBytes = 0; - long maxMs = 0; - StringBuilder failures = new StringBuilder(); - int index = 0; - for (String asset : assets) { - index++; - long remaining = deadline - System.currentTimeMillis(); - if (remaining < 3000) { - skipped = assets.size() - index + 1; - appendFailure(failures, "#" + index + " deadline_reached skipped=" + skipped); - break; - } - try { - int connectTimeout = (int) Math.max(2000, Math.min(8000, remaining / 3)); - int readTimeout = (int) Math.max(3000, Math.min(10000, remaining / 2)); - FetchResult assetResult = fetchVPNURL(vpn, new URL(asset), connectTimeout, readTimeout, 256 * 1024); - totalBytes += assetResult.bytes; - maxMs = Math.max(maxMs, assetResult.elapsedMs); - if (assetResult.code >= 200 && assetResult.code < 400) { - ok++; - } else { - failed++; - appendFailure(failures, "#" + index + " HTTP " + assetResult.code + " " + compact(asset, 120)); - } - } catch (Exception e) { - failed++; - appendFailure(failures, "#" + index + " " + e.getClass().getSimpleName() + ":" + e.getMessage() + " " + compact(asset, 120)); - } - } - result.append(" | assets=").append(assets.size()) - .append(" ok=").append(ok) - .append(" failed=").append(failed) - .append(" skipped=").append(skipped) - .append(" asset_bytes=").append(totalBytes) - .append(" max_asset_ms=").append(maxMs); - if (failures.length() > 0) { - result.append(" | failures=").append(failures); - } - return compact(result.toString(), 2500); - } catch (Exception e) { - return "vpn_page_probe " + target + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage(); - } - } - - private String runVPNTCPConnect(String host, int port, int timeoutMs) { - if (port <= 0 || port > 65535) { - return "vpn_tcp_connect " + host + ":" + port + " -> invalid port"; - } - if (timeoutMs < 1000) { - timeoutMs = 1000; - } - if (timeoutMs > 30000) { - timeoutMs = 30000; - } - long started = System.currentTimeMillis(); - try { - Network vpn = waitForVPNNetwork(5000); - if (vpn == null) { - return "vpn_tcp_connect " + host + ":" + port + " -> vpn network not found"; - } - InetAddress address = resolveForVPN(vpn, host); - try (Socket socket = new Socket()) { - vpn.bindSocket(socket); - socket.connect(new InetSocketAddress(address, port), timeoutMs); - long elapsed = System.currentTimeMillis() - started; - return "vpn_tcp_connect " + host + ":" + port + " -> connected address=" + address.getHostAddress() + " ms=" + elapsed; - } - } catch (Exception e) { - long elapsed = System.currentTimeMillis() - started; - return "vpn_tcp_connect " + host + ":" + port + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage() + " ms=" + elapsed; - } - } - - private String runDefaultTCPConnect(String host, int port, int timeoutMs) { - if (port <= 0 || port > 65535) { - return "vpn_tcp_connect_default " + host + ":" + port + " -> invalid port"; - } - if (timeoutMs < 1000) { - timeoutMs = 1000; - } - if (timeoutMs > 30000) { - timeoutMs = 30000; - } - long started = System.currentTimeMillis(); - try { - InetAddress address = InetAddress.getByName(host); - try (Socket socket = new Socket()) { - socket.connect(new InetSocketAddress(address, port), timeoutMs); - long elapsed = System.currentTimeMillis() - started; - return "vpn_tcp_connect_default " + host + ":" + port + " -> connected address=" + address.getHostAddress() + " local=" + socket.getLocalAddress().getHostAddress() + " ms=" + elapsed; - } - } catch (Exception e) { - long elapsed = System.currentTimeMillis() - started; - return "vpn_tcp_connect_default " + host + ":" + port + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage() + " ms=" + elapsed; - } - } - - private Network waitForVPNNetwork(int timeoutMs) { - long deadline = System.currentTimeMillis() + Math.max(0, timeoutMs); - Network vpn; - do { - vpn = vpnNetwork(); - if (vpn != null) { - return vpn; - } - try { - Thread.sleep(150); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return null; - } - } while (System.currentTimeMillis() < deadline); - return null; - } - - private FetchResult fetchVPNURL(Network vpn, URL url, int connectTimeoutMs, int readTimeoutMs, int maxBytes) throws Exception { - long started = System.currentTimeMillis(); - HttpURLConnection connection = (HttpURLConnection) vpn.openConnection(url); - connection.setConnectTimeout(connectTimeoutMs); - connection.setReadTimeout(readTimeoutMs); - connection.setInstanceFollowRedirects(true); - connection.setRequestProperty("User-Agent", "RAP-VPN-Diagnostic/" + APP_VERSION); - int code = connection.getResponseCode(); - String contentType = connection.getContentType(); - int bytes = 0; - StringBuilder body = new StringBuilder(Math.min(maxBytes, 65536)); - try (java.io.InputStream input = code >= 400 ? connection.getErrorStream() : connection.getInputStream()) { - if (input != null) { - byte[] buffer = new byte[8192]; - while (bytes < maxBytes) { - int want = Math.min(buffer.length, maxBytes - bytes); - int read = input.read(buffer, 0, want); - if (read < 0) { - break; - } - if (body.length() < 131072) { - body.append(new String(buffer, 0, read, java.nio.charset.StandardCharsets.UTF_8)); - } - bytes += read; - } - } - } finally { - connection.disconnect(); - } - return new FetchResult(code, bytes, System.currentTimeMillis() - started, contentType == null ? "" : contentType, body.toString()); - } - - private Set extractAssetURLs(URL base, String html, int maxAssets) { - Set out = new LinkedHashSet<>(); - if (html == null || html.isEmpty()) { - return out; - } - Pattern pattern = Pattern.compile("(?i)(?:src|href)\\s*=\\s*[\"']([^\"'#]+)[\"']"); - Matcher matcher = pattern.matcher(html); - while (matcher.find() && out.size() < maxAssets) { - String raw = matcher.group(1).trim(); - if (raw.isEmpty() || raw.startsWith("data:") || raw.startsWith("mailto:") || raw.startsWith("tel:")) { - continue; - } - try { - URL resolved = new URL(base, raw); - String protocol = resolved.getProtocol(); - if ("http".equalsIgnoreCase(protocol) || "https".equalsIgnoreCase(protocol)) { - out.add(resolved.toString()); - } - } catch (Exception ignored) { - } - } - return out; - } - - private void appendFailure(StringBuilder failures, String item) { - if (failures.length() > 0) { - failures.append("; "); - } - failures.append(item); - } - - private InetAddress resolveForVPN(Network vpn, String host) throws Exception { - if (isIPv4Literal(host)) { - return InetAddress.getByName(host); - } - String preferredExitAddress = firstManualVPNAddress(vpn, host); - if (!preferredExitAddress.isEmpty()) { - return InetAddress.getByName(preferredExitAddress); - } - Exception resolverError = null; - try { - InetAddress[] addresses = vpn.getAllByName(host); - if (addresses != null && addresses.length > 0) { - return addresses[0]; - } - } catch (Exception e) { - resolverError = e; - } - if (resolverError != null) { - String fallbackAddress = firstUnderlyingDNSAddress(host); - if (!fallbackAddress.isEmpty()) { - return InetAddress.getByName(fallbackAddress); - } - throw resolverError; - } - throw new UnknownHostException(host); - } - - private static class FetchResult { - final int code; - final int bytes; - final long elapsedMs; - final String contentType; - final String body; - - FetchResult(int code, int bytes, long elapsedMs, String contentType, String body) { - this.code = code; - this.bytes = bytes; - this.elapsedMs = elapsedMs; - this.contentType = contentType; - this.body = body; - } - } - - private boolean isIPv4Literal(String host) { - if (host == null) { - return false; - } - String[] parts = host.split("\\."); - if (parts.length != 4) { - return false; - } - try { - for (String part : parts) { - int value = Integer.parseInt(part); - if (value < 0 || value > 255) { - return false; - } - } - return true; - } catch (NumberFormatException e) { - return false; - } - } - - private String runVPNDNSLookup(String host) { - try { - Network vpn = vpnNetwork(); - if (vpn == null) { - return "vpn_dns_lookup " + host + " -> vpn network not found"; - } - StringBuilder result = new StringBuilder(); - try { - InetAddress[] system = vpn.getAllByName(host); - result.append("system="); - appendAddresses(result, system); - } catch (Exception e) { - result.append("system=").append(e.getClass().getSimpleName()).append(":").append(e.getMessage()); - } - String manual = manualVPNDNSLookup(vpn, host); - result.append(" manual=").append(manual); - return "vpn_dns_lookup " + host + " -> " + result; - } catch (Exception e) { - return "vpn_dns_lookup " + host + " -> " + e.getClass().getSimpleName() + ": " + e.getMessage(); - } - } - - private String firstManualVPNAddress(Network vpn, String host) { - String result = manualVPNDNSLookup(vpn, host); - if (result.startsWith("ok:")) { - String addresses = result.substring(3); - int comma = addresses.indexOf(','); - return comma >= 0 ? addresses.substring(0, comma) : addresses; - } - return ""; - } - - private String firstUnderlyingDNSAddress(String host) { - for (String server : Arrays.asList("1.1.1.1", "8.8.8.8", "9.9.9.9")) { - String result = manualDNSLookupOnUnderlyingNetwork(server, host); - if (result.startsWith("ok:")) { - String addresses = result.substring(3); - int comma = addresses.indexOf(','); - return comma >= 0 ? addresses.substring(0, comma) : addresses; - } - } - return ""; - } - - private String manualDNSLookupOnUnderlyingNetwork(String dnsServer, String host) { - try (DatagramSocket socket = new DatagramSocket()) { - Network network = underlyingNetwork(); - if (network != null) { - network.bindSocket(socket); - } - socket.setSoTimeout(2500); - byte[] query = buildDNSQuery(host); - DatagramPacket packet = new DatagramPacket(query, query.length, InetAddress.getByName(dnsServer), 53); - socket.send(packet); - byte[] response = new byte[512]; - DatagramPacket answer = new DatagramPacket(response, response.length); - socket.receive(answer); - List addresses = parseDNSAResponse(response, answer.getLength()); - if (addresses.isEmpty()) { - return "empty:" + dnsServer; - } - return "ok:" + String.join(",", addresses); - } catch (Exception e) { - return e.getClass().getSimpleName() + ":" + e.getMessage(); - } - } - - private Network underlyingNetwork() { - ConnectivityManager connectivity = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); - if (connectivity == null) { - return null; - } - for (Network network : connectivity.getAllNetworks()) { - NetworkCapabilities capabilities = connectivity.getNetworkCapabilities(network); - if (capabilities == null) { - continue; - } - if (capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { - continue; - } - if (!capabilities.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET)) { - continue; - } - return network; - } - return null; - } - - private String manualVPNDNSLookup(Network vpn, String host) { - String dnsServers = getSharedPreferences(RUNTIME_PREFS, MODE_PRIVATE).getString("dns_servers", ""); - if (dnsServers.isEmpty()) { - return "skipped:no_dns_servers"; - } - String dnsServer = dnsServers.split(",", 2)[0].trim(); - if (dnsServer.isEmpty()) { - return "skipped:no_dns_servers"; - } - try (DatagramSocket socket = new DatagramSocket()) { - vpn.bindSocket(socket); - socket.setSoTimeout(5000); - byte[] query = buildDNSQuery(host); - DatagramPacket packet = new DatagramPacket(query, query.length, InetAddress.getByName(dnsServer), 53); - socket.send(packet); - byte[] response = new byte[512]; - DatagramPacket answer = new DatagramPacket(response, response.length); - socket.receive(answer); - List addresses = parseDNSAResponse(response, answer.getLength()); - if (addresses.isEmpty()) { - return "empty:" + dnsServer; - } - return "ok:" + String.join(",", addresses); - } catch (SocketTimeoutException e) { - return "timeout:" + dnsServer; - } catch (Exception e) { - return e.getClass().getSimpleName() + ":" + e.getMessage(); - } - } - - private byte[] buildDNSQuery(String host) throws Exception { - byte[] out = new byte[512]; - int id = new Random().nextInt(0xffff); - out[0] = (byte) ((id >> 8) & 0xff); - out[1] = (byte) (id & 0xff); - out[2] = 0x01; - out[5] = 0x01; - int offset = 12; - for (String label : host.split("\\.")) { - byte[] bytes = label.getBytes("UTF-8"); - out[offset++] = (byte) bytes.length; - System.arraycopy(bytes, 0, out, offset, bytes.length); - offset += bytes.length; - } - out[offset++] = 0; - out[offset++] = 0; - out[offset++] = 1; - out[offset++] = 0; - out[offset++] = 1; - byte[] query = new byte[offset]; - System.arraycopy(out, 0, query, 0, offset); - return query; - } - - private List parseDNSAResponse(byte[] packet, int length) { - List addresses = new ArrayList<>(); - if (length < 12) { - return addresses; - } - int qd = u16(packet, 4); - int an = u16(packet, 6); - int offset = 12; - for (int i = 0; i < qd; i++) { - offset = skipDNSName(packet, length, offset); - offset += 4; - if (offset > length) { - return addresses; - } - } - for (int i = 0; i < an && offset < length; i++) { - offset = skipDNSName(packet, length, offset); - if (offset + 10 > length) { - return addresses; - } - int type = u16(packet, offset); - int cls = u16(packet, offset + 2); - int rdLen = u16(packet, offset + 8); - offset += 10; - if (type == 1 && cls == 1 && rdLen == 4 && offset + 4 <= length) { - addresses.add((packet[offset] & 0xff) + "." + (packet[offset + 1] & 0xff) + "." + (packet[offset + 2] & 0xff) + "." + (packet[offset + 3] & 0xff)); - } - offset += rdLen; - } - return addresses; - } - - private int skipDNSName(byte[] packet, int length, int offset) { - while (offset < length) { - int value = packet[offset] & 0xff; - offset++; - if (value == 0) { - break; - } - if ((value & 0xc0) == 0xc0) { - offset++; - break; - } - offset += value; - } - return offset; - } - - private int u16(byte[] packet, int offset) { - if (packet == null || offset + 1 >= packet.length) { - return 0; - } - return ((packet[offset] & 0xff) << 8) | (packet[offset + 1] & 0xff); - } - - private void appendAddresses(StringBuilder result, InetAddress[] addresses) { - if (addresses == null || addresses.length == 0) { - result.append("empty"); - return; - } - for (int i = 0; i < addresses.length; i++) { - if (i > 0) { - result.append(","); - } - result.append(addresses[i].getHostAddress()); - } - } - - private String hostHeader(URL url) { - if (url.getPort() > 0) { - return url.getHost() + ":" + url.getPort(); - } - return url.getHost(); - } - - private Network vpnNetwork() { - ConnectivityManager connectivity = (ConnectivityManager) getSystemService(CONNECTIVITY_SERVICE); - if (connectivity == null) { - return null; - } - long deadline = System.currentTimeMillis() + 3000; - do { - for (Network network : connectivity.getAllNetworks()) { - NetworkCapabilities capabilities = connectivity.getNetworkCapabilities(network); - if (capabilities != null && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { - return network; - } - } - try { - Thread.sleep(200); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - return null; - } - } while (System.currentTimeMillis() < deadline); - for (Network network : connectivity.getAllNetworks()) { - NetworkCapabilities capabilities = connectivity.getNetworkCapabilities(network); - if (capabilities != null && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_VPN)) { - return network; - } - } - return null; - } - - private Notification notification() { - if (Build.VERSION.SDK_INT >= 26) { - NotificationChannel channel = new NotificationChannel(CHANNEL_ID, "RAP VPN diagnostics", NotificationManager.IMPORTANCE_LOW); - NotificationManager manager = getSystemService(NotificationManager.class); - if (manager != null) { - manager.createNotificationChannel(channel); - } - } - Notification.Builder builder = Build.VERSION.SDK_INT >= 26 ? new Notification.Builder(this, CHANNEL_ID) : new Notification.Builder(this); - return builder - .setContentTitle("RAP VPN diagnostics") - .setContentText("Diagnostic channel is active") - .setSmallIcon(android.R.drawable.stat_sys_upload_done) - .build(); - } -} diff --git a/clients/android/app/src/main/java/su/cin/rapvpn/RapVpnService.java b/clients/android/app/src/main/java/su/cin/rapvpn/RapVpnService.java index 4193550..95cda43 100644 --- a/clients/android/app/src/main/java/su/cin/rapvpn/RapVpnService.java +++ b/clients/android/app/src/main/java/su/cin/rapvpn/RapVpnService.java @@ -28,10 +28,8 @@ import java.io.FileDescriptor; import java.io.FileInputStream; import java.net.DatagramPacket; import java.net.DatagramSocket; -import java.net.Inet4Address; import java.net.InetAddress; import java.net.InetSocketAddress; -import java.net.URI; import java.net.Socket; import java.util.ArrayList; import java.util.LinkedHashMap; @@ -48,7 +46,7 @@ import java.util.concurrent.atomic.AtomicLong; public class RapVpnService extends VpnService { static final String EXTRA_PROFILE_JSON = "profile_json"; - static final String EXTRA_BACKEND_URL = "backend_url"; + static final String EXTRA_FABRIC_BOOTSTRAP_CONFIG = "fabric_bootstrap_config"; static final String EXTRA_CLUSTER_ID = "cluster_id"; static final String EXTRA_VPN_CONNECTION_ID = "vpn_connection_id"; static final String ACTION_STOP = "su.cin.rapvpn.STOP"; @@ -57,7 +55,6 @@ public class RapVpnService extends VpnService { private static final String PREFS = "rap-vpn-runtime"; private static final int DEFAULT_VPN_MTU = 1000; private static final int VPN_TCP_MSS_CLAMP = 900; - private static final boolean PACKET_WEBSOCKET_DATAPLANE_ENABLED = true; private static final int VPN_BATCH_MAX_PACKETS = 512; private static final int VPN_BATCH_MAX_BYTES = 1024 * 1024; private static final int UPLINK_WORKER_MIN_COUNT = 4; @@ -128,7 +125,7 @@ public class RapVpnService extends VpnService { }; private static final String PREF_NAME = "rap-vpn"; private static final String PREF_PROFILE_JSON = "profile_json"; - private static final String PREF_BACKEND_URL = "backend_url"; + private static final String PREF_FABRIC_BOOTSTRAP_CONFIG = "fabric_bootstrap_config"; private static final String PREF_CLUSTER_ID = "cluster_id"; private static final String PREF_VPN_CONNECTION_ID = "vpn_connection_id"; private static final String PREF_MANUAL_STOPPED = "manual_stopped"; @@ -158,8 +155,6 @@ public class RapVpnService extends VpnService { private volatile boolean running; private volatile String vpnAddressIPv4 = "10.77.0.2"; private volatile byte[] vpnAddressIPv4Bytes = new byte[]{10, 77, 0, 2}; - private volatile byte[][] backendBypassIPv4s = new byte[0][]; - private volatile int backendBypassPort; private volatile boolean fastPathModeEnabled; private volatile long downlinkRestarts; private volatile long lastRuntimeDetailAt; @@ -208,16 +203,10 @@ public class RapVpnService extends VpnService { private volatile boolean relaxedUplinkSourceValidation; private volatile boolean relaxedDownlinkDestinationValidation; private volatile String activeConnectionIdByProfile; - private volatile String activePacketRelayUrlByProfile; - private volatile List activePacketRelayUrlsByProfile = new ArrayList<>(); - private volatile int activePacketRelayIndex; - private volatile VpnPacketWebSocketRelay packetWebSocketRelay; - private volatile FabricServiceChannel activeFabricServiceChannel = new FabricServiceChannel(); private volatile boolean activeMeshNodeRouteMode; private volatile String activeFabricRuntimeConfigJson = ""; private volatile Manager fabricVpnManager; private volatile String lastUplinkSendErrorMessage = ""; - private final Object packetRelaySwitchLock = new Object(); private final Map clientSourceNat = new LinkedHashMap(4096, 0.75f, true) { @Override protected boolean removeEldestEntry(Map.Entry eldest) { @@ -252,12 +241,11 @@ public class RapVpnService extends VpnService { stopSelfResult(startId); return START_NOT_STICKY; } - ensureDiagnosticServiceRunning(); startForeground(1001, notification()); writeRuntimeStatus("starting", "starting vpn service", 0, 0, 0, 0); SharedPreferences prefs = getSharedPreferences(PREF_NAME, MODE_PRIVATE); boolean explicitStart = intent != null && (intent.hasExtra(EXTRA_PROFILE_JSON) - || intent.hasExtra(EXTRA_BACKEND_URL) + || intent.hasExtra(EXTRA_FABRIC_BOOTSTRAP_CONFIG) || intent.hasExtra(EXTRA_CLUSTER_ID) || intent.hasExtra(EXTRA_VPN_CONNECTION_ID)); if (!explicitStart && prefs.getBoolean(PREF_MANUAL_STOPPED, false)) { @@ -269,8 +257,8 @@ public class RapVpnService extends VpnService { } markManualStopped(false); String profile = extraOrSaved(intent, EXTRA_PROFILE_JSON, PREF_PROFILE_JSON, prefs); - String backendUrl = extraOrSaved(intent, EXTRA_BACKEND_URL, PREF_BACKEND_URL, prefs); - startSafeInterface(profile == null ? "" : profile, backendUrl); + String fabricBootstrapConfig = extraOrSaved(intent, EXTRA_FABRIC_BOOTSTRAP_CONFIG, PREF_FABRIC_BOOTSTRAP_CONFIG, prefs); + startSafeInterface(profile == null ? "" : profile, fabricBootstrapConfig); if (tunnel == null) { shutdownReason = "vpn start failed: tunnel is null"; writeRuntimeStatus("error", "vpn start failed: tunnel is null", 0, 0, 0, 0); @@ -294,7 +282,7 @@ public class RapVpnService extends VpnService { stopSelf(); return START_NOT_STICKY; } - persistStartConfig(profile, backendUrl, clusterId, vpnConnectionId); + persistStartConfig(profile, fabricBootstrapConfig, clusterId, vpnConnectionId); if (!activeMeshNodeRouteMode) { shutdownReason = "fabric mesh node route profile required"; writeRuntimeStatus("error", "vpn not started: fabric_mesh_node_route_v1 profile required; legacy relay dataplanes are disabled", 0, 0, 0, 0); @@ -310,74 +298,21 @@ public class RapVpnService extends VpnService { } private void ensureDiagnosticServiceRunning() { - try { - RapDiagnosticService.start(this); - diagnosticEnsureRequests.incrementAndGet(); - writeRuntimeDetail("diagnostic_start", "diagnostic service start requested by vpn runtime", "control", 0, 0, "", -1); - } catch (Exception e) { - Log.w(TAG, "diagnostic service start failed", e); - writeRuntimeDetail("diagnostic_start_failed", e.getMessage(), "control", 0, 1, e.getClass().getSimpleName(), -1); - } + writeRuntimeDetail("diagnostic_disabled", "legacy diagnostic background service is disabled; Android node works through fabric runtime only", "control", 0, 0, "", -1); } private void ensureDiagnosticServiceHealthy() { - try { - SharedPreferences runtime = getSharedPreferences(PREFS, MODE_PRIVATE); - long lastLocalHeartbeat = runtime.getLong("diagnostic_local_heartbeat_at", 0); - long now = System.currentTimeMillis(); - long age = lastLocalHeartbeat <= 0 ? Long.MAX_VALUE : now - lastLocalHeartbeat; - boolean restart = age > DIAGNOSTIC_STALE_RESTART_MS - && now - lastDiagnosticRestartAt >= DIAGNOSTIC_RESTART_COOLDOWN_MS; - diagnosticEnsureRequests.incrementAndGet(); - if (restart) { - diagnosticRestartRequests.incrementAndGet(); - lastDiagnosticRestartAt = now; - } - Intent intent = new Intent(this, RapDiagnosticService.class); - intent.setAction(restart ? RapDiagnosticService.ACTION_RESTART : RapDiagnosticService.ACTION_START); - if (Build.VERSION.SDK_INT >= 26) { - startForegroundService(intent); - } else { - startService(intent); - } - runtime.edit() - .putLong("diagnostic_watchdog_last_ensure_at", now) - .putLong("diagnostic_watchdog_last_heartbeat_age_ms", age) - .putString("diagnostic_watchdog_last_action", restart ? "restart" : "start") - .putLong("diagnostic_watchdog_ensure_requests", diagnosticEnsureRequests.get()) - .putLong("diagnostic_watchdog_restart_requests", diagnosticRestartRequests.get()) - .apply(); - writeRuntimeDetail( - restart ? "diagnostic_restart" : "diagnostic_start", - (restart ? "diagnostic service restart requested age_ms=" : "diagnostic service start requested age_ms=") + age, - "control", - 0, - 0, - "", - -1); - } catch (Exception e) { - Log.w(TAG, "diagnostic service health ensure failed", e); - getSharedPreferences(PREFS, MODE_PRIVATE).edit() - .putLong("diagnostic_watchdog_last_ensure_at", System.currentTimeMillis()) - .putString("diagnostic_watchdog_last_action", "failed") - .putString("diagnostic_watchdog_last_error", e.getClass().getSimpleName() + ": " + e.getMessage()) - .apply(); - writeRuntimeDetail("diagnostic_start_failed", e.getMessage(), "control", 0, 1, e.getClass().getSimpleName(), -1); - } + getSharedPreferences(PREFS, MODE_PRIVATE).edit() + .putLong("diagnostic_watchdog_last_ensure_at", System.currentTimeMillis()) + .putString("diagnostic_watchdog_last_action", "disabled") + .putLong("diagnostic_watchdog_ensure_requests", diagnosticEnsureRequests.incrementAndGet()) + .apply(); } private void ensureDiagnosticFromRuntimeStatus(long now) { - if (now - lastDiagnosticStatusEnsureAt < 10000) { - return; - } - lastDiagnosticStatusEnsureAt = now; - try { - long lastLocalHeartbeat = getSharedPreferences(PREFS, MODE_PRIVATE).getLong("diagnostic_local_heartbeat_at", 0); - long age = lastLocalHeartbeat <= 0 ? Long.MAX_VALUE : now - lastLocalHeartbeat; - if (age > 45000) { - ensureDiagnosticServiceHealthy(); - } - } catch (Exception ignored) { + if (now - lastDiagnosticStatusEnsureAt >= 10000) { + lastDiagnosticStatusEnsureAt = now; + ensureDiagnosticServiceHealthy(); } } @@ -405,11 +340,11 @@ public class RapVpnService extends VpnService { return prefs.getString(prefName, ""); } - private void persistStartConfig(String profile, String backendUrl, String clusterId, String vpnConnectionId) { + private void persistStartConfig(String profile, String fabricBootstrapConfig, String clusterId, String vpnConnectionId) { try { getSharedPreferences(PREF_NAME, MODE_PRIVATE).edit() .putString(PREF_PROFILE_JSON, profile == null ? "" : profile) - .putString(PREF_BACKEND_URL, backendUrl == null ? "" : backendUrl) + .putString(PREF_FABRIC_BOOTSTRAP_CONFIG, fabricBootstrapConfig == null ? "" : fabricBootstrapConfig) .putString(PREF_CLUSTER_ID, clusterId == null ? "" : clusterId) .putString(PREF_VPN_CONNECTION_ID, vpnConnectionId == null ? "" : vpnConnectionId) .apply(); @@ -422,7 +357,7 @@ public class RapVpnService extends VpnService { running = false; closeTunHandles(); closeTunnelQuietly(); - stopPacketRelay(); + stopFabricTransport(); } catch (Exception ignored) { } writeRuntimeStatus("stopped", shutdownReason == null || shutdownReason.isEmpty() ? "vpn stopped" : shutdownReason, 0, 0, 0, 0); @@ -442,19 +377,16 @@ public class RapVpnService extends VpnService { } } - private void startSafeInterface(String profileJson, String backendUrl) { + private void startSafeInterface(String profileJson, String fabricBootstrapConfig) { try { running = false; closeTunHandles(); closeTunnelQuietly(); - stopPacketRelay(); + stopFabricTransport(); resetRuntimeMetrics(); - activePacketRelayUrlByProfile = ""; - activePacketRelayUrlsByProfile = new ArrayList<>(); - activeFabricServiceChannel = new FabricServiceChannel(); activeMeshNodeRouteMode = false; activeFabricRuntimeConfigJson = ""; - VpnClientConfig config = parseClientConfig(profileJson, backendUrl); + VpnClientConfig config = parseClientConfig(profileJson, fabricBootstrapConfig); SharedPreferences prefs = getSharedPreferences(PREF_NAME, MODE_PRIVATE); boolean forceFullTunnel = prefs.getBoolean(MainActivity.PREF_FORCE_FULL_TUNNEL, true); fastPathModeEnabled = forceFullTunnel || config.fullTunnel; @@ -509,9 +441,6 @@ public class RapVpnService extends VpnService { if (activeConnectionIdByProfile != null && !activeConnectionIdByProfile.isEmpty()) { persistVpnConnectionId(activeConnectionIdByProfile); } - activePacketRelayUrlByProfile = config.packetRelayBaseUrl; - activePacketRelayUrlsByProfile = new ArrayList<>(config.packetRelayBaseUrls); - activeFabricServiceChannel = config.fabricServiceChannel; activeMeshNodeRouteMode = config.meshNodeRouteMode; activeFabricRuntimeConfigJson = config.fabricRuntimeConfigJson; } catch (Exception e) { @@ -521,7 +450,7 @@ public class RapVpnService extends VpnService { } } - private VpnClientConfig parseClientConfig(String profileJson, String backendUrl) { + private VpnClientConfig parseClientConfig(String profileJson, String fabricBootstrapConfig) { VpnClientConfig config = new VpnClientConfig(); config.vpnAddress = "10.77.0.2/32"; try { @@ -608,21 +537,6 @@ public class RapVpnService extends VpnService { config.dataplaneSelectedTransport = "fabric_mesh_node_route_v1"; config.configNotes.add("Fabric mesh node route: Android node connects to exit pool directly through mesh"); } - config.packetRelayBaseUrls.addAll(selectDataplanePacketRelayBaseUrls(dataplaneSession, backendUrl)); - if (!config.packetRelayBaseUrls.isEmpty()) { - config.packetRelayBaseUrl = config.packetRelayBaseUrls.get(0); - } - } - JSONObject serviceChannelLease = clientConfig.optJSONObject("fabric_service_channel_lease"); - if (serviceChannelLease != null) { - FabricServiceChannel channel = FabricServiceChannel.fromLease(serviceChannelLease); - if (channel.enabled) { - config.fabricServiceChannel = channel; - if (!channel.webSocketPathTemplate.isEmpty()) { - config.dataplaneSelectedTransport = "fabric_packet_websocket_v1"; - } - config.configNotes.add("Fabric service channel enabled: " + channel.channelId); - } } } JSONObject routePolicy = connection.optJSONObject("route_policy"); @@ -787,8 +701,7 @@ public class RapVpnService extends VpnService { if (isMeshNodeRouteDataplane(dataplaneSession)) { return "fabric_mesh_node_route_v1"; } - JSONObject candidate = selectSafeEntryDirectHTTPCandidate(dataplaneSession); - return candidate == null ? "" : "entry_direct_http_v1"; + return ""; } private boolean isMeshNodeRouteDataplane(JSONObject dataplaneSession) { @@ -811,98 +724,6 @@ public class RapVpnService extends VpnService { return false; } - private List selectDataplanePacketRelayBaseUrls(JSONObject dataplaneSession, String backendUrl) { - List urls = new ArrayList<>(); - JSONObject candidate = selectSafeEntryDirectHTTPCandidate(dataplaneSession); - if (candidate == null) { - return urls; - } - JSONArray entryCandidates = candidate.optJSONArray("entry_candidates"); - if (entryCandidates == null) { - return urls; - } - List ordered = orderEntryCandidatesForNetwork(entryCandidates, backendUrl); - for (int i = 0; i < ordered.size(); i++) { - JSONObject entry = ordered.get(i); - if (entry == null) { - continue; - } - String apiBaseUrl = normalizeHTTPBaseUrl(entry.optString("api_base_url", "")); - if (!apiBaseUrl.isEmpty()) { - addUniqueUrl(urls, apiBaseUrl); - continue; - } - String address = normalizeHTTPBaseUrl(entry.optString("address", "")); - if (!address.isEmpty()) { - addUniqueUrl(urls, address + "/api/v1"); - } - } - return urls; - } - - private List orderEntryCandidatesForNetwork(JSONArray entryCandidates, String backendUrl) { - List publicEntries = new ArrayList<>(); - List privateEntries = new ArrayList<>(); - List otherEntries = new ArrayList<>(); - for (int i = 0; i < entryCandidates.length(); i++) { - JSONObject entry = entryCandidates.optJSONObject(i); - if (entry == null) { - continue; - } - String reachability = entry.optString("reachability", ""); - String url = entry.optString("api_base_url", ""); - if (url.isEmpty()) { - url = entry.optString("address", ""); - } - boolean privateAddress = isPrivateURLHost(url); - if ("public".equalsIgnoreCase(reachability) && !privateAddress) { - publicEntries.add(entry); - } else if ("private".equalsIgnoreCase(reachability) || privateAddress) { - privateEntries.add(entry); - } else { - otherEntries.add(entry); - } - } - List ordered = new ArrayList<>(); - if (isPrivateURLHost(backendUrl)) { - ordered.addAll(privateEntries); - ordered.addAll(publicEntries); - } else { - ordered.addAll(publicEntries); - ordered.addAll(privateEntries); - } - ordered.addAll(otherEntries); - return ordered; - } - - private JSONObject selectSafeEntryDirectHTTPCandidate(JSONObject dataplaneSession) { - if (dataplaneSession == null) { - return null; - } - JSONArray transportCandidates = dataplaneSession.optJSONArray("transport_candidates"); - if (transportCandidates == null) { - return null; - } - for (int i = 0; i < transportCandidates.length(); i++) { - JSONObject candidate = transportCandidates.optJSONObject(i); - if (candidate == null) { - continue; - } - if (!"entry_direct_http_v1".equals(candidate.optString("type", ""))) { - continue; - } - if (!candidate.optBoolean("safe_client_switch", false)) { - continue; - } - String status = candidate.optString("status", "").trim().toLowerCase(); - if (!"available".equals(status) && !status.startsWith("available_")) { - continue; - } - return candidate; - } - return null; - } - private String summarizeFabricMeshEndpoints(JSONArray candidates) { if (candidates == null || candidates.length() == 0) { return ""; @@ -934,72 +755,6 @@ public class RapVpnService extends VpnService { return joinList(endpoints); } - private String normalizeHTTPBaseUrl(String value) { - if (value == null) { - return ""; - } - value = value.trim(); - while (value.endsWith("/")) { - value = value.substring(0, value.length() - 1); - } - if (value.isEmpty()) { - return ""; - } - try { - URI uri = URI.create(value); - String scheme = uri.getScheme(); - String host = uri.getHost(); - if (host == null || host.isEmpty()) { - return ""; - } - if (!"http".equalsIgnoreCase(scheme) && !"https".equalsIgnoreCase(scheme)) { - return ""; - } - return value; - } catch (Exception ignored) { - return ""; - } - } - - private boolean isPrivateURLHost(String value) { - if (value == null || value.trim().isEmpty()) { - return false; - } - try { - URI uri = URI.create(value.trim()); - String host = uri.getHost(); - if (host == null || host.isEmpty()) { - return false; - } - byte[] bytes = ipv4Bytes(host); - if (bytes == null || bytes.length != 4) { - return "localhost".equalsIgnoreCase(host); - } - int first = bytes[0] & 0xff; - int second = bytes[1] & 0xff; - return first == 10 - || first == 127 - || (first == 172 && second >= 16 && second <= 31) - || (first == 192 && second == 168) - || (first == 169 && second == 254); - } catch (Exception ignored) { - return false; - } - } - - private boolean isIPv6URLHost(String value) { - if (value == null || value.trim().isEmpty()) { - return false; - } - try { - URI uri = URI.create(value.trim()); - String host = uri.getHost(); - return host != null && host.contains(":"); - } catch (Exception ignored) { - return false; - } - } - private void writeRuntimeConfig(VpnClientConfig config, boolean forceFullTunnel, boolean fastPathMode) { try { getSharedPreferences(PREFS, MODE_PRIVATE).edit() @@ -1028,10 +783,9 @@ public class RapVpnService extends VpnService { .putString("dataplane_selected_transport", config.dataplaneSelectedTransport) .putBoolean("mesh_node_route_mode", config.meshNodeRouteMode) .putString("fabric_runtime_config_json", config.fabricRuntimeConfigJson) - .putString("packet_relay_profile_base_url", config.packetRelayBaseUrl) - .putString("packet_relay_active_base_url", "") - .putString("packet_relay_base_url", config.packetRelayBaseUrl) - .putString("packet_relay_candidate_urls", joinList(config.packetRelayBaseUrls)) + .putString("fabric_transport_profile_endpoint", firstNonEmpty(config.dataplaneExitNodeId, config.dataplaneSelectedTransport)) + .putString("fabric_transport_active_endpoint", "") + .putString("fabric_transport_candidate_endpoints", firstNonEmpty(config.fabricMeshExitEndpoints, config.dataplaneExitNodeId)) .putInt("dataplane_transport_candidate_count", config.dataplaneTransportCandidateCount) .putInt("dataplane_entry_candidate_count", config.dataplaneEntryCandidateCount) .putInt("dataplane_exit_candidate_count", config.dataplaneExitCandidateCount) @@ -1179,7 +933,7 @@ public class RapVpnService extends VpnService { return false; } - stopPacketRelay(); + stopFabricTransport(); try { Fabricvpn.touch(); Manager manager = Fabricvpn.newManager(); @@ -1207,14 +961,13 @@ public class RapVpnService extends VpnService { long runtimeId = runtimeGeneration.incrementAndGet(); runtimeStartedAt = System.currentTimeMillis(); initializePacketRuntimeQueues(); - configureBackendBypass(""); writeRuntimeStatus("fabric", "QUIC fabric VPN runtime connected " + vpnConnectionId, 0, 0, 0, 0); writeRuntimeDetail("running", fabricSnapshot(), "fabric", 0, 0, "", -1); - uplinkThread = new Thread(() -> pumpTunToRelay(runtimeId, clusterId, vpnConnectionId), "rap-vpn-fabric-uplink"); + uplinkThread = new Thread(() -> pumpTunToFabricTransport(runtimeId, clusterId, vpnConnectionId), "rap-vpn-fabric-uplink"); uplinkSenderThreads = new Thread[uplinkWorkerCount]; for (int i = 0; i < uplinkWorkerCount; i++) { final int workerIndex = i; - uplinkSenderThreads[i] = new Thread(() -> pumpUplinkQueueToRelay(runtimeId, workerIndex, clusterId, vpnConnectionId), "rap-vpn-fabric-uplink-sender-" + workerIndex); + uplinkSenderThreads[i] = new Thread(() -> pumpUplinkQueueToFabricTransport(runtimeId, workerIndex, clusterId, vpnConnectionId), "rap-vpn-fabric-uplink-sender-" + workerIndex); } downlinkThread = new Thread(() -> pumpFabricDownlinkToQueue(runtimeId), "rap-vpn-fabric-downlink-receiver"); downlinkWriterThread = new Thread(() -> pumpDownlinkQueueToTun(runtimeId), "rap-vpn-fabric-downlink-writer"); @@ -1289,122 +1042,6 @@ public class RapVpnService extends VpnService { } } - private void startPacketRelay(String backendUrl, List candidateUrls, String clusterId, String vpnConnectionId) { - if (tunnel == null || backendUrl == null || backendUrl.isEmpty() || clusterId == null || clusterId.isEmpty() || vpnConnectionId == null || vpnConnectionId.isEmpty()) { - Log.e(TAG, "packet relay not started: tunnel=" + (tunnel != null) - + " backend=" + present(backendUrl) - + " cluster=" + present(clusterId) - + " vpn_connection=" + present(vpnConnectionId)); - writeRuntimeStatus("error", "relay not started: tunnel=" + (tunnel != null) - + " backend=" + present(backendUrl) - + " cluster=" + present(clusterId) - + " connection=" + present(vpnConnectionId), 0, 0, 0, 0); - return; - } - List relayUrls = activeFabricServiceChannel.enabled ? dedupeFabricRelayUrls(candidateUrls, backendUrl) : dedupeRelayUrls(candidateUrls, backendUrl); - String selectedRelayUrl = relayUrls.isEmpty() ? "" : relayUrls.get(0); - if ((selectedRelayUrl == null || selectedRelayUrl.isEmpty()) && !activeFabricServiceChannel.enabled) { - selectedRelayUrl = backendUrl; - } - if (selectedRelayUrl == null || selectedRelayUrl.isEmpty()) { - writeRuntimeStatus("error", "relay not started: missing fabric farm entry endpoint", 0, 0, 0, 0); - return; - } - activePacketRelayUrlByProfile = selectedRelayUrl; - activePacketRelayUrlsByProfile = new ArrayList<>(relayUrls); - activePacketRelayIndex = Math.max(0, relayUrls.indexOf(selectedRelayUrl)); - writeActivePacketRelayConfig(selectedRelayUrl, relayUrls); - stopPacketRelay(); - running = true; - long runtimeId = runtimeGeneration.incrementAndGet(); - runtimeStartedAt = System.currentTimeMillis(); - uplinkWorkerCount = Math.max(UPLINK_WORKER_MIN_COUNT, Math.min(UPLINK_WORKER_MAX_COUNT, Math.max(1, Runtime.getRuntime().availableProcessors()))); - uplinkQueueOffersByWorker = createAtomicCounters(uplinkWorkerCount); - uplinkQueueDropsByWorker = createAtomicCounters(uplinkWorkerCount); - uplinkSenderPacketsByWorker = createAtomicCounters(uplinkWorkerCount); - uplinkSenderErrorsByWorker = createAtomicCounters(uplinkWorkerCount); - uplinkPriorityQueue = new ArrayBlockingQueue<>(PRIORITY_QUEUE_CAPACITY); - uplinkQueues = new ArrayBlockingQueue[uplinkWorkerCount]; - for (int i = 0; i < uplinkWorkerCount; i++) { - uplinkQueues[i] = new ArrayBlockingQueue<>(UPLINK_QUEUE_CAPACITY); - } - downlinkFlowQueueCount = Math.max(1, Math.min(DOWNLINK_FLOW_QUEUE_MAX_COUNT, Math.max(1, Runtime.getRuntime().availableProcessors()))); - downlinkQueueOffersByFlow = createAtomicCounters(downlinkFlowQueueCount); - downlinkQueueDropsByFlow = createAtomicCounters(downlinkFlowQueueCount); - downlinkWriterPacketsByFlow = createAtomicCounters(downlinkFlowQueueCount); - downlinkPriorityQueue = new ArrayBlockingQueue<>(PRIORITY_QUEUE_CAPACITY); - downlinkQueues = new ArrayBlockingQueue[downlinkFlowQueueCount]; - int downlinkPerFlowCapacity = Math.max(512, DOWNLINK_QUEUE_CAPACITY / downlinkFlowQueueCount); - for (int i = 0; i < downlinkFlowQueueCount; i++) { - downlinkQueues[i] = new ArrayBlockingQueue<>(downlinkPerFlowCapacity); - } - configureBackendBypass(selectedRelayUrl); - if (PACKET_WEBSOCKET_DATAPLANE_ENABLED) { - startPacketWebSocketRelay(selectedRelayUrl, clusterId, vpnConnectionId); - if (activeFabricServiceChannel.enabled) { - writeRuntimeDetail("fabric_websocket_dataplane", "fabric websocket packet stream required; HTTP batch fallback disabled", "relay", 0, 0, "", -1); - } - } else { - writeRuntimeDetail("http_packet_batch", "packet websocket disabled; using confirmed HTTP batches", "relay", 0, 0, "", -1); - } - Log.i(TAG, "packet relay starting: backend=" + selectedRelayUrl + " cluster=" + clusterId + " vpn_connection=" + vpnConnectionId); - writeRuntimeStatus("relay", "relay starting " + vpnConnectionId, 0, 0, 0, 0); - writeRuntimeDetail("running", "packet relay active", "relay", 0, 0, ""); - final String legacyResetRelayUrl = selectedRelayUrl; - Thread resetThread = activeFabricServiceChannel.enabled ? null : new Thread(() -> { - try { - RapApiClient uplinkClient = packetRelayClientForUrl(legacyResetRelayUrl); - JSONObject reset = uplinkClient.resetVPNPacketQueues(clusterId, vpnConnectionId); - Log.i(TAG, "packet relay queues reset: " + reset.toString()); - writeRuntimeStatus("relay_reset", reset.toString(), 0, 0, 0, 0); - } catch (Exception e) { - Log.w(TAG, "vpn relay queue reset failed; continuing", e); - writeRuntimeStatus("relay_reset_warning", "queue reset failed: " + e.getMessage(), 0, 0, 0, 1); - } - }, "rap-vpn-relay-reset"); - uplinkThread = new Thread(() -> pumpTunToRelay(runtimeId, clusterId, vpnConnectionId), "rap-vpn-uplink"); - uplinkSenderThreads = new Thread[uplinkWorkerCount]; - for (int i = 0; i < uplinkWorkerCount; i++) { - final int workerIndex = i; - uplinkSenderThreads[i] = new Thread(() -> pumpUplinkQueueToRelay(runtimeId, workerIndex, clusterId, vpnConnectionId), "rap-vpn-uplink-sender-" + workerIndex); - } - downlinkThread = new Thread(() -> runDownlinkWithRestart(runtimeId, clusterId, vpnConnectionId), "rap-vpn-downlink-receiver"); - downlinkWriterThread = new Thread(() -> pumpDownlinkQueueToTun(runtimeId), "rap-vpn-downlink-writer"); - runtimeWatchdogThread = new Thread(() -> runRuntimeWatchdog(runtimeId, clusterId, vpnConnectionId), "rap-vpn-runtime-watchdog"); - diagnosticWatchdogThread = new Thread(this::runDiagnosticServiceWatchdog, "rap-vpn-diagnostic-watchdog"); - if (resetThread != null) { - resetThread.start(); - } else { - writeRuntimeStatus("farm_dataplane", "backend relay queue reset skipped; farm owns vpn packet routes", 0, 0, 0, 0); - } - uplinkThread.start(); - for (Thread senderThread : uplinkSenderThreads) { - senderThread.start(); - } - downlinkThread.start(); - downlinkWriterThread.start(); - runtimeWatchdogThread.start(); - diagnosticWatchdogThread.start(); - } - - private List singletonUrl(String value) { - List out = new ArrayList<>(); - addUniqueUrl(out, value); - return out; - } - - private void writeActivePacketRelayConfig(String selectedRelayUrl, List relayUrls) { - try { - String activeUrl = selectedRelayUrl == null ? "" : selectedRelayUrl; - getSharedPreferences(PREFS, MODE_PRIVATE).edit() - .putString("packet_relay_active_base_url", activeUrl) - .putString("packet_relay_base_url", activeUrl) - .putString("packet_relay_candidate_urls", joinList(relayUrls)) - .commit(); - } catch (Exception ignored) { - } - } - private String joinList(List values) { if (values == null || values.isEmpty()) { return ""; @@ -1422,188 +1059,9 @@ public class RapVpnService extends VpnService { return out.toString(); } - private List dedupeRelayUrls(List candidateUrls, String backendUrl) { - List out = new ArrayList<>(); - boolean backendIsPrivate = isPrivateURLHost(backendUrl); - if (candidateUrls != null) { - for (String url : candidateUrls) { - String normalized = normalizeHTTPBaseUrl(url); - if (!backendIsPrivate && isPrivateURLHost(normalized)) { - continue; - } - addUniqueUrl(out, normalized); - } - } - addUniqueUrl(out, normalizeHTTPBaseUrl(backendUrl)); - return out; - } - - private List dedupeFabricRelayUrls(List candidateUrls, String backendUrl) { - List ipv4OrHost = new ArrayList<>(); - List ipv6 = new ArrayList<>(); - boolean backendIsPrivate = isPrivateURLHost(backendUrl); - if (candidateUrls != null) { - for (String url : candidateUrls) { - String normalized = normalizeHTTPBaseUrl(url); - if (!backendIsPrivate && isPrivateURLHost(normalized)) { - continue; - } - if (isIPv6URLHost(normalized)) { - addUniqueUrl(ipv6, normalized); - } else { - addUniqueUrl(ipv4OrHost, normalized); - } - } - } - List out = new ArrayList<>(); - for (String url : ipv4OrHost) { - addUniqueUrl(out, url); - } - for (String url : ipv6) { - addUniqueUrl(out, url); - } - return out; - } - - private void addUniqueUrl(List urls, String value) { - if (urls == null || value == null) { - return; - } - String normalized = normalizeHTTPBaseUrl(value); - if (normalized.isEmpty()) { - return; - } - for (String existing : urls) { - if (normalized.equals(existing)) { - return; - } - } - urls.add(normalized); - } - - private RapApiClient packetRelayClientForUrl(String url) { - return new RapApiClient(url, this, activeFabricServiceChannel); - } - - private void startPacketWebSocketRelay(String relayUrl, String clusterId, String vpnConnectionId) { - try { - VpnPacketWebSocketRelay old = packetWebSocketRelay; - if (old != null && (!relayUrl.equals(old.baseUrl()) || !old.isOpen())) { - old.close(); - packetWebSocketRelay = null; - } - VpnPacketWebSocketRelay relay = packetWebSocketRelay; - if (relay == null) { - relay = new VpnPacketWebSocketRelay(relayUrl, this, activeFabricServiceChannel); - packetWebSocketRelay = relay; - } - relay.connect(clusterId, vpnConnectionId); - writeRuntimeDetail("websocket_connect", "packet websocket connect requested " + relayUrl, "relay", 0, 0, "", -1); - } catch (Exception e) { - writeRuntimeDetail("websocket_connect_failed", "packet websocket connect failed: " + e.getMessage(), "relay", 0, 1, e.getClass().getSimpleName(), -1); - } - } - - private String currentPacketRelayUrl() { - String active = activePacketRelayUrlByProfile; - if (active != null && !active.isEmpty()) { - return active; - } - List urls = activePacketRelayUrlsByProfile; - if (urls != null && !urls.isEmpty()) { - int index = activePacketRelayIndex; - if (index < 0 || index >= urls.size()) { - index = 0; - } - return urls.get(index); - } - return ""; - } - - private boolean switchPacketRelayUrl(String failedUrl, String reason) { - synchronized (packetRelaySwitchLock) { - List urls = activePacketRelayUrlsByProfile; - if (urls == null || urls.size() <= 1) { - return false; - } - String normalizedFailed = normalizeHTTPBaseUrl(failedUrl); - int start = activePacketRelayIndex; - if (!normalizedFailed.isEmpty()) { - int failedIndex = urls.indexOf(normalizedFailed); - if (failedIndex >= 0) { - start = failedIndex; - } - } - for (int offset = 1; offset <= urls.size(); offset++) { - int nextIndex = (start + offset) % urls.size(); - String next = urls.get(nextIndex); - if (next == null || next.isEmpty() || next.equals(normalizedFailed)) { - continue; - } - if (isIPv6URLHost(next) && hasNonIPv6RelayUrl(urls)) { - continue; - } - activePacketRelayIndex = nextIndex; - activePacketRelayUrlByProfile = next; - configureBackendBypass(next); - startPacketWebSocketRelay(next, getSharedPreferences(PREF_NAME, MODE_PRIVATE).getString(PREF_CLUSTER_ID, ""), getSharedPreferences(PREF_NAME, MODE_PRIVATE).getString(PREF_VPN_CONNECTION_ID, "")); - writeActivePacketRelayConfig(next, urls); - writeRuntimeStatus("relay_switch", "relay switched to " + next + " after " + reason, 0, 0, 0, 0); - writeRuntimeDetail("relay_switch", "relay switched from " + normalizedFailed + " to " + next + " reason=" + reason, "relay", 0, 0, ""); - return true; - } - return false; - } - } - - private boolean hasNonIPv6RelayUrl(List urls) { - if (urls == null) { - return false; - } - for (String url : urls) { - if (url != null && !url.isEmpty() && !isIPv6URLHost(url)) { - return true; - } - } - return false; - } - - private String selectReachablePacketRelayUrl(List relayUrls, String clusterId, String vpnConnectionId) { - if (relayUrls == null || relayUrls.isEmpty()) { - return ""; - } - StrictMode.ThreadPolicy previousPolicy = StrictMode.getThreadPolicy(); - StrictMode.setThreadPolicy(new StrictMode.ThreadPolicy.Builder(previousPolicy).permitNetwork().build()); - try { - for (String url : relayUrls) { - if (url == null || url.isEmpty()) { - continue; - } - try { - RapApiClient probe = new RapApiClient(url, this); - JSONObject reset = probe.resetVPNPacketQueues(clusterId, vpnConnectionId); - Log.i(TAG, "packet relay selected: " + url + " reset=" + reset.toString()); - writeRuntimeStatus("relay_selected", "relay selected " + url, 0, 0, 0, 0); - return url; - } catch (Exception e) { - Log.w(TAG, "packet relay candidate failed: " + url, e); - writeRuntimeStatus("relay_candidate_failed", url + ": " + e.getMessage(), 0, 0, 0, 1); - } - } - } finally { - StrictMode.setThreadPolicy(previousPolicy); - } - return ""; - } - - private void stopPacketRelay() { + private void stopFabricTransport() { runtimeGeneration.incrementAndGet(); running = false; - VpnPacketWebSocketRelay relay = packetWebSocketRelay; - packetWebSocketRelay = null; - if (relay != null) { - relay.close(); - } Manager manager = fabricVpnManager; fabricVpnManager = null; if (manager != null) { @@ -1757,7 +1215,7 @@ public class RapVpnService extends VpnService { "downlink", "downlink_tcp", "downlink_writer", - "relay", + "fabric_transport", "watchdog" }; String[] suffixes = new String[]{ @@ -1866,29 +1324,6 @@ public class RapVpnService extends VpnService { return running && runtimeGeneration.get() == runtimeId; } - private void runDownlinkWithRestart(long runtimeId, String clusterId, String vpnConnectionId) { - long restarts = 0; - while (isRuntimeActive(runtimeId)) { - downlinkRestarts = restarts; - writeRuntimeDetail("starting", "downlink loop starting", "downlink", 0, restarts, ""); - pumpRelayToTun(clusterId, vpnConnectionId); - if (!isRuntimeActive(runtimeId)) { - return; - } - restarts++; - downlinkRestarts = restarts; - writeRuntimeStatus("downlink_restart", "restarting downlink count=" + restarts, 0, 0, 0, restarts); - writeRuntimeDetail("restart", "downlink loop restarting", "downlink", 0, restarts, ""); - try { - Thread.sleep(100); - } catch (InterruptedException e) { - if (!isRuntimeActive(runtimeId)) { - return; - } - } - } - } - private void runRuntimeWatchdog(long runtimeId, String clusterId, String vpnConnectionId) { while (isRuntimeActive(runtimeId)) { try { @@ -1923,18 +1358,16 @@ public class RapVpnService extends VpnService { tcpHandshakeStalls.addAndGet(priorityStale); runtimeWatchdogRecoveries.incrementAndGet(); lastRuntimeWatchdogRecoveryAt = now; - recoverPacketRelayRuntime(clusterId, vpnConnectionId, "priority_tcp_handshake_stall stale=" + priorityStale); + scheduleHardRuntimeRestart(clusterId, vpnConnectionId, "priority_tcp_handshake_stall stale=" + priorityStale); continue; } - boolean relayOpen = isPacketWebSocketRelayOpen(); boolean recentDownlink = lastRuntimeWatchdogDownlinkProgressAt > 0 && now - lastRuntimeWatchdogDownlinkProgressAt < RUNTIME_WATCHDOG_RECENT_DOWNLINK_GRACE_MS; boolean recentUplinkSendError = lastUplinkSendErrorMessage != null && !lastUplinkSendErrorMessage.isEmpty(); - if (relayOpen && !recentUplinkSendError - && (recentDownlink - || stale < RUNTIME_WATCHDOG_OPEN_RELAY_STALE_SYNACKS_BEFORE_RECOVERY + if (recentDownlink && !recentUplinkSendError + && (stale < RUNTIME_WATCHDOG_OPEN_RELAY_STALE_SYNACKS_BEFORE_RECOVERY || runtimeWatchdogStaleRounds < RUNTIME_WATCHDOG_OPEN_RELAY_STALE_ROUNDS_BEFORE_RECOVERY)) { - writeRuntimeDetail("watchdog_open_relay_waiting", "stale=" + stale + " rounds=" + runtimeWatchdogStaleRounds + " relay_open=true recent_downlink=" + recentDownlink + " uplink_progress=" + uplinkProgressed, "watchdog", runtimeWatchdogRecoveries.get(), tcpHandshakeStalls.get(), "", -1); + writeRuntimeDetail("watchdog_recent_transport_waiting", "stale=" + stale + " rounds=" + runtimeWatchdogStaleRounds + " recent_downlink=true uplink_progress=" + uplinkProgressed, "watchdog", runtimeWatchdogRecoveries.get(), tcpHandshakeStalls.get(), "", -1); continue; } if (runtimeWatchdogStaleRounds < RUNTIME_WATCHDOG_STALE_ROUNDS_BEFORE_RECOVERY @@ -1950,11 +1383,7 @@ public class RapVpnService extends VpnService { tcpHandshakeStalls.addAndGet(stale); runtimeWatchdogRecoveries.incrementAndGet(); lastRuntimeWatchdogRecoveryAt = now; - if (shouldHardRestartRuntime(now)) { - scheduleHardRuntimeRestart(clusterId, vpnConnectionId, "tcp_handshake_stall stale=" + stale); - } else { - recoverPacketRelayRuntime(clusterId, vpnConnectionId, "tcp_handshake_stall stale=" + stale); - } + scheduleHardRuntimeRestart(clusterId, vpnConnectionId, "tcp_handshake_stall stale=" + stale); } catch (InterruptedException e) { if (!isRuntimeActive(runtimeId)) { return; @@ -1965,11 +1394,6 @@ public class RapVpnService extends VpnService { } } - private boolean isPacketWebSocketRelayOpen() { - VpnPacketWebSocketRelay relay = packetWebSocketRelay; - return relay != null && relay.isOpen(); - } - private void runDiagnosticServiceWatchdog() { getSharedPreferences(PREFS, MODE_PRIVATE).edit() .putLong("diagnostic_watchdog_started_at", System.currentTimeMillis()) @@ -1994,12 +1418,6 @@ public class RapVpnService extends VpnService { } private boolean shouldHardRestartRuntime(long now) { - if (!PACKET_WEBSOCKET_DATAPLANE_ENABLED) { - return now - lastRuntimeWatchdogHardRestartAt >= RUNTIME_WATCHDOG_HARD_RESTART_COOLDOWN_MS; - } - if (runtimeWatchdogRecoveries.get() < 2) { - return false; - } return now - lastRuntimeWatchdogHardRestartAt >= RUNTIME_WATCHDOG_HARD_RESTART_COOLDOWN_MS; } @@ -2017,24 +1435,16 @@ public class RapVpnService extends VpnService { try { SharedPreferences prefs = getSharedPreferences(PREF_NAME, MODE_PRIVATE); String profile = prefs.getString(PREF_PROFILE_JSON, ""); - String backendUrl = prefs.getString(PREF_BACKEND_URL, ""); + String fabricBootstrapConfig = prefs.getString(PREF_FABRIC_BOOTSTRAP_CONFIG, ""); String savedClusterId = prefs.getString(PREF_CLUSTER_ID, clusterId == null ? "" : clusterId); String savedVpnConnectionId = prefs.getString(PREF_VPN_CONNECTION_ID, vpnConnectionId == null ? "" : vpnConnectionId); - if (profile.isEmpty() || backendUrl.isEmpty() || savedClusterId.isEmpty() || savedVpnConnectionId.isEmpty()) { + if (profile.isEmpty() || fabricBootstrapConfig.isEmpty() || savedClusterId.isEmpty() || savedVpnConnectionId.isEmpty()) { writeRuntimeDetail("hard_restart_failed", "saved runtime config missing", "watchdog", runtimeWatchdogRecoveries.get(), runtimeWatchdogHardRestarts.get(), "CONFIG_MISSING", -1); return; } - try { - String relayUrl = currentPacketRelayUrl(); - if (relayUrl != null && !relayUrl.isEmpty()) { - packetRelayClientForUrl(relayUrl).resetVPNPacketQueues(savedClusterId, savedVpnConnectionId); - } - } catch (Exception e) { - writeRuntimeDetail("hard_restart_queue_reset_warning", e.getMessage(), "watchdog", runtimeWatchdogRecoveries.get(), runtimeWatchdogHardRestarts.get(), e.getClass().getSimpleName(), -1); - } Intent startIntent = new Intent(this, RapVpnService.class); startIntent.putExtra(EXTRA_PROFILE_JSON, profile); - startIntent.putExtra(EXTRA_BACKEND_URL, backendUrl); + startIntent.putExtra(EXTRA_FABRIC_BOOTSTRAP_CONFIG, fabricBootstrapConfig); startIntent.putExtra(EXTRA_CLUSTER_ID, savedClusterId); startIntent.putExtra(EXTRA_VPN_CONNECTION_ID, savedVpnConnectionId); if (Build.VERSION.SDK_INT >= 26) { @@ -2052,27 +1462,6 @@ public class RapVpnService extends VpnService { restartThread.start(); } - private void recoverPacketRelayRuntime(String clusterId, String vpnConnectionId, String reason) { - String relayUrl = currentPacketRelayUrl(); - writeRuntimeStatus("runtime_recovery", reason, 0, uplinkSentPackets.get(), downlinkReceivedPackets.get(), runtimeWatchdogRecoveries.get()); - writeRuntimeDetail("runtime_recovery", reason, "watchdog", runtimeWatchdogRecoveries.get(), tcpHandshakeStalls.get(), "", -1); - synchronized (pendingTcpHandshakes) { - pendingTcpHandshakes.clear(); - } - try { - VpnPacketWebSocketRelay relay = packetWebSocketRelay; - if (relay != null) { - relay.close(); - packetWebSocketRelay = null; - } - if (relayUrl != null && !relayUrl.isEmpty()) { - startPacketWebSocketRelay(relayUrl, clusterId, vpnConnectionId); - } - } catch (Exception e) { - writeRuntimeDetail("websocket_recover_failed", e.getMessage(), "watchdog", runtimeWatchdogRecoveries.get(), tcpHandshakeStalls.get(), e.getClass().getSimpleName(), -1); - } - } - private void clearPacketQueues() { BlockingQueue[] uplink = uplinkQueues; if (uplink != null) { @@ -2143,7 +1532,7 @@ public class RapVpnService extends VpnService { return value == null || value.isEmpty() ? "missing" : "present"; } - private void pumpTunToRelay(long runtimeId, String clusterId, String vpnConnectionId) { + private void pumpTunToFabricTransport(long runtimeId, String clusterId, String vpnConnectionId) { byte[] packet = new byte[32767]; long readPackets = 0; FileDescriptor fd = null; @@ -2195,10 +1584,6 @@ public class RapVpnService extends VpnService { } private void queueUplinkPacket(byte[] packet, int length) { - if (isUplinkBackendBypassPacket(packet, length)) { - recordUplinkBypassControl(length); - return; - } if (!shouldForwardUplinkPacket(packet, length)) { recordUplinkFiltered(length); return; @@ -2809,7 +2194,7 @@ public class RapVpnService extends VpnService { } } - private void pumpUplinkQueueToRelay(long runtimeId, int workerIndex, String clusterId, String vpnConnectionId) { + private void pumpUplinkQueueToFabricTransport(long runtimeId, int workerIndex, String clusterId, String vpnConnectionId) { long sentPackets = 0; long errors = 0; List batch = new ArrayList<>(VPN_BATCH_MAX_PACKETS); @@ -2922,61 +2307,8 @@ public class RapVpnService extends VpnService { if (manager != null) { return sendFabricUplinkBatch(manager, batch, workerIndex); } - Exception lastError = null; - lastUplinkSendErrorMessage = ""; - int relayAttempts = Math.max(1, activePacketRelayUrlsByProfile == null ? 1 : activePacketRelayUrlsByProfile.size()); - for (int relayAttempt = 0; relayAttempt < relayAttempts && running; relayAttempt++) { - String relayUrl = currentPacketRelayUrl(); - if (relayUrl == null || relayUrl.isEmpty()) { - return false; - } - if (sendUplinkBatchOverWebSocket(relayUrl, clusterId, vpnConnectionId, batch, workerIndex)) { - return true; - } - if (activeFabricServiceChannel.enabled) { - for (int attempt = 0; attempt <= UPLINK_TRANSIENT_RETRY_COUNT && running; attempt++) { - lastUplinkSendErrorMessage = "fabric websocket packet stream unavailable: " + lastWebSocketRelayError(); - writeRuntimeDetail("websocket_required_wait", "fabric websocket unavailable; HTTP batch fallback disabled relay=" + relayUrl + " attempt=" + attempt + " error=" + lastUplinkSendErrorMessage, "uplink_sender", -1, -1, "WEBSOCKET_REQUIRED", workerIndex); - sleepQuietly(Math.min(UPLINK_TRANSIENT_RETRY_MAX_SLEEP_MS, UPLINK_TRANSIENT_RETRY_SLEEP_MS * (attempt + 1L))); - if (sendUplinkBatchOverWebSocket(relayUrl, clusterId, vpnConnectionId, batch, workerIndex)) { - return true; - } - } - switchPacketRelayUrl(relayUrl, "websocket_unavailable"); - continue; - } - RapApiClient client = packetRelayClientForUrl(relayUrl); - int attempt = 0; - while (running) { - try { - client.sendClientPacketBatch(clusterId, vpnConnectionId, batch); - if (attempt > 0) { - writeRuntimeDetail("retry_ok", "uplink retry ok worker=" + workerIndex + " relay=" + relayUrl + " attempt=" + attempt + " batch=" + batch.size(), "uplink_sender", -1, -1, "", workerIndex); - } - return true; - } catch (Exception e) { - lastError = e; - lastUplinkSendErrorMessage = compactException(e); - writeRuntimeDetail("retry", "uplink send retry worker=" + workerIndex + " relay=" + relayUrl + " attempt=" + attempt + " error=" + lastUplinkSendErrorMessage, "uplink_sender", -1, -1, e.getClass().getSimpleName(), workerIndex); - boolean transientError = isTransientUplinkSendError(e); - int retryLimit = transientError ? UPLINK_TRANSIENT_RETRY_COUNT : UPLINK_SEND_RETRY_COUNT; - if (attempt >= retryLimit) { - break; - } - int baseSleepMs = transientError ? UPLINK_TRANSIENT_RETRY_SLEEP_MS : UPLINK_SEND_RETRY_SLEEP_MS; - int maxSleepMs = transientError ? UPLINK_TRANSIENT_RETRY_MAX_SLEEP_MS : UPLINK_SEND_RETRY_SLEEP_MS * (UPLINK_SEND_RETRY_COUNT + 1); - long sleepMs = Math.min(maxSleepMs, baseSleepMs * (attempt + 1L)); - sleepQuietly(sleepMs); - attempt++; - } - } - if (!switchPacketRelayUrl(relayUrl, lastError == null ? "send_failed" : lastError.getClass().getSimpleName())) { - break; - } - } - if (lastError != null) { - Log.w(TAG, "vpn uplink batch send failed after retry", lastError); - } + lastUplinkSendErrorMessage = "fabric QUIC runtime unavailable"; + writeRuntimeDetail("fabric_required", "Android VPN dataplane requires the QUIC fabric runtime; HTTP relay, WebSocket relay and backend fallback are removed", "fabric", -1, 1, "FABRIC_REQUIRED", workerIndex); return false; } @@ -3082,21 +2414,6 @@ public class RapVpnService extends VpnService { } } - private String lastWebSocketRelayError() { - VpnPacketWebSocketRelay relay = packetWebSocketRelay; - if (relay == null) { - return "relay_not_started"; - } - String error = relay.lastError(); - if (error == null || error.isEmpty()) { - if (relay.isOpen()) { - return "open_no_error"; - } - return "not_open"; - } - return error; - } - private boolean isTransientUplinkSendError(Exception e) { String message = e == null ? null : e.getMessage(); if (message == null) { @@ -3111,26 +2428,6 @@ public class RapVpnService extends VpnService { || lower.contains("forward peer unavailable"); } - private boolean sendUplinkBatchOverWebSocket(String relayUrl, String clusterId, String vpnConnectionId, List batch, int workerIndex) { - if (!PACKET_WEBSOCKET_DATAPLANE_ENABLED) { - return false; - } - VpnPacketWebSocketRelay relay = packetWebSocketRelay; - if (relay == null || relayUrl == null || !relayUrl.equals(relay.baseUrl())) { - return false; - } - try { - if (relay.sendClientPacketBatch(clusterId, vpnConnectionId, batch)) { - writeRuntimeDetail("websocket_sent", "worker=" + workerIndex + " sent batch=" + batch.size(), "uplink_sender", -1, -1, "", workerIndex); - return true; - } - writeRuntimeDetail("websocket_send_fallback", "websocket send fallback " + relay.lastError(), "uplink_sender", -1, -1, "WEBSOCKET_SEND", workerIndex); - } catch (Exception e) { - writeRuntimeDetail("websocket_send_fallback", "websocket send failed: " + e.getMessage(), "uplink_sender", -1, -1, e.getClass().getSimpleName(), workerIndex); - } - return false; - } - private boolean shouldForwardUplinkPacket(byte[] packet, int length) { if (packet == null || length < 20) { return false; @@ -3147,7 +2444,7 @@ public class RapVpnService extends VpnService { if (isBroadcastOrMulticastIPv4(packet)) { return false; } - return !isBackendControlPlanePacket(packet, length, ihlFast); + return true; } int version = (packet[0] >> 4) & 0x0f; if (version != 4) { @@ -3160,62 +2457,7 @@ public class RapVpnService extends VpnService { if (isBroadcastOrMulticastIPv4(packet)) { return false; } - return !isBackendControlPlanePacket(packet, length, ihl); - } - - private boolean isUplinkBackendBypassPacket(byte[] packet, int length) { - if (packet == null || length < 20) { - return false; - } - int version = (packet[0] >> 4) & 0x0f; - if (version != 4) { - return false; - } - int ihl = (packet[0] & 0x0f) * 4; - return ihl >= 20 && length >= ihl && isBackendControlPlanePacket(packet, length, ihl); - } - - private void configureBackendBypass(String backendUrl) { - backendBypassIPv4s = new byte[0][]; - backendBypassPort = 0; - try { - URI uri = URI.create(backendUrl == null ? "" : backendUrl); - byte[][] hosts = resolveBackendBypassIPv4(uri.getHost()); - if (hosts == null || hosts.length == 0) { - return; - } - int port = uri.getPort(); - if (port <= 0) { - port = "https".equalsIgnoreCase(uri.getScheme()) ? 443 : 80; - } - backendBypassIPv4s = hosts; - backendBypassPort = port; - } catch (Exception ignored) { - } - } - - private byte[][] resolveBackendBypassIPv4(String host) { - byte[] direct = ipv4Bytes(host); - if (direct != null) { - return new byte[][]{direct}; - } - if (host == null || host.trim().isEmpty()) { - return null; - } - try { - InetAddress[] addresses = InetAddress.getAllByName(host); - List result = new ArrayList<>(); - for (InetAddress address : addresses) { - if (address instanceof Inet4Address) { - result.add(address.getAddress()); - } - } - if (!result.isEmpty()) { - return result.toArray(new byte[0][]); - } - } catch (Exception ignored) { - } - return null; + return true; } private boolean isBroadcastOrMulticastIPv4(byte[] packet) { @@ -3223,42 +2465,6 @@ public class RapVpnService extends VpnService { return first >= 224 || first == 255; } - private boolean isBackendControlPlanePacket(byte[] packet, int length, int ihl) { - int port = backendBypassPort; - byte[][] hosts = backendBypassIPv4s; - if (hosts == null || hosts.length == 0 || port <= 0 || length < ihl + 4) { - return false; - } - if (!matchesBackendBypassAddress(packet)) { - return false; - } - int proto = packet[9] & 0xff; - if (proto != 6 && proto != 17) { - return false; - } - int dstPort = u16(packet, ihl + 2); - return dstPort == port; - } - - private boolean matchesBackendBypassAddress(byte[] packet) { - byte[][] hosts = backendBypassIPv4s; - if (hosts == null || hosts.length == 0) { - return false; - } - for (byte[] host : hosts) { - if (host == null || host.length != 4) { - continue; - } - if ((packet[16] & 0xff) == (host[0] & 0xff) - && (packet[17] & 0xff) == (host[1] & 0xff) - && (packet[18] & 0xff) == (host[2] & 0xff) - && (packet[19] & 0xff) == (host[3] & 0xff)) { - return true; - } - } - return false; - } - private void setUnderlyingNetworks(Builder builder) { if (Build.VERSION.SDK_INT < 22) { return; @@ -3331,7 +2537,7 @@ public class RapVpnService extends VpnService { private void startVPNReadinessWarmup(Set dnsServers, Set probeDomains, String vpnConnectionId) { Thread thread = new Thread(() -> { long deadline = System.currentTimeMillis() + VPN_START_WARMUP_TIMEOUT_MS; - writeRuntimeStatus("warming", "warming vpn dns and relay", 0, 0, downlinkReceivedPackets.get(), 0); + writeRuntimeStatus("warming", "warming vpn dns and fabric transport", 0, 0, downlinkReceivedPackets.get(), 0); Network vpn = null; while (running && System.currentTimeMillis() < deadline) { vpn = vpnNetwork(); @@ -3468,129 +2674,6 @@ public class RapVpnService extends VpnService { return null; } - private void pumpRelayToTun(String clusterId, String vpnConnectionId) { - long fetchedPackets = 0; - long errors = 0; - int downlinkPollMs = DOWNLINK_POLL_MS_MIN; - String relayUrl = ""; - RapApiClient client = null; - try { - while (running) { - try { - String currentRelayUrl = currentPacketRelayUrl(); - if (currentRelayUrl == null || currentRelayUrl.isEmpty()) { - Thread.sleep(100); - continue; - } - if (client == null || !currentRelayUrl.equals(relayUrl)) { - relayUrl = currentRelayUrl; - client = packetRelayClientForUrl(relayUrl); - } - List packets = receiveDownlinkBatch(relayUrl, client, clusterId, vpnConnectionId, downlinkPollMs); - for (byte[] packet : packets) { - if (!isIPv4Packet(packet)) { - recordDownlinkDrop(packet == null ? 0 : packet.length); - continue; - } - int length = effectiveIPv4Length(packet, packet.length); - if (length <= 0) { - errors++; - recordDownlinkDrop(packet.length); - writeRuntimeDetail("length_drop", packetSummary(packet, packet.length), "downlink", fetchedPackets, errors, "LENGTH"); - continue; - } - boolean restoredClientNAT = restoreClientSourceNATDestination(packet, length); - if (!fastPathModeEnabled && !hasIPv4Destination(packet, length)) { - long mismatch = downlinkDestinationMismatchPackets.incrementAndGet(); - if (mismatch > ADDRESS_MISMATCH_TOLERANCE_PACKETS) { - relaxedDownlinkDestinationValidation = true; - writeRuntimeStatus("recover", "downlink destination validation relaxed; packet checks will continue", 0, 0, 0, 0); - } else { - recordDownlinkDrop(length); - writeRuntimeDetail("length_drop", packetSummary(packet, packet.length), "downlink", fetchedPackets, errors, "DEST_MISMATCH"); - } - if (!relaxedDownlinkDestinationValidation) { - continue; - } - } - boolean transportChecksumWasValid = hasValidIPv4TransportChecksum(packet, length); - boolean normalized = normalizeIPv4PacketChecksums(packet, length); - if (normalized && (!transportChecksumWasValid || restoredClientNAT)) { - downlinkTransportChecksumRepairs.incrementAndGet(); - } - if (!normalized) { - errors++; - recordDownlinkDrop(length); - writeRuntimeDetail("normalize_drop", packetSummary(packet, length), "downlink", fetchedPackets, errors, "CHECKSUM_NORMALIZE"); - continue; - } - recordInboundTCPHandshake(packet, length); - if (offerDownlinkPacket(packet, length)) { - fetchedPackets++; - } else if (running) { - errors++; - recordDownlinkDrop(length); - writeRuntimeDetail("queue_drop", packetSummary(packet, length), "downlink", fetchedPackets, errors, "QUEUE_FULL"); - } - } - if (!packets.isEmpty()) { - downlinkPollMs = Math.max(DOWNLINK_POLL_MS_MIN, downlinkPollMs - DOWNLINK_POLL_MS_STEP); - writeRuntimeStatus("downlink", "queued batch=" + packets.size(), 0, 0, downlinkReceivedPackets.get(), errors); - } else if (fetchedPackets > 0) { - downlinkPollMs = Math.min(DOWNLINK_POLL_MS_MAX, downlinkPollMs + DOWNLINK_POLL_MS_STEP); - writeRuntimeStatus("downlink_idle", "waiting for gateway packets", 0, 0, downlinkReceivedPackets.get(), errors); - } - } catch (Exception e) { - if (running) { - Log.w(TAG, "vpn downlink receive failed; continuing", e); - errors++; - writeRuntimeStatus("error", "downlink failed: " + e.getMessage(), 0, 0, downlinkReceivedPackets.get(), errors); - writeRuntimeDetail("error", "downlink failed: " + e.getMessage(), "downlink", fetchedPackets, errors, e.getClass().getSimpleName()); - if (errors % 3 == 0) { - switchPacketRelayUrl(relayUrl, "downlink_" + e.getClass().getSimpleName()); - } - try { - Thread.sleep(100); - } catch (InterruptedException interrupted) { - if (!running) { - return; - } - } - } - } - } - } catch (Exception e) { - if (running) { - Log.e(TAG, "vpn downlink stopped", e); - writeRuntimeStatus("error", "downlink stopped: " + e.getMessage(), 0, 0, downlinkReceivedPackets.get(), errors); - writeRuntimeDetail("stopped", "downlink stopped: " + e.getMessage(), "downlink", fetchedPackets, errors, e.getClass().getSimpleName()); - } - } - } - - private List receiveDownlinkBatch(String relayUrl, RapApiClient client, String clusterId, String vpnConnectionId, int timeoutMs) throws Exception { - if (!PACKET_WEBSOCKET_DATAPLANE_ENABLED) { - return client.receiveClientPacketBatch(clusterId, vpnConnectionId, timeoutMs); - } - VpnPacketWebSocketRelay relay = packetWebSocketRelay; - if (relay != null && relayUrl != null && relayUrl.equals(relay.baseUrl())) { - List packets = relay.receiveClientPacketBatch(clusterId, vpnConnectionId, timeoutMs); - if (!packets.isEmpty()) { - writeRuntimeDetail("websocket_received", "received batch=" + packets.size(), "downlink", -1, -1, "", -1); - return packets; - } - if (relay.isOpen()) { - return packets; - } - writeRuntimeDetail("websocket_receive_fallback", "websocket receive fallback " + relay.lastError(), "downlink", -1, -1, "WEBSOCKET_RECEIVE", -1); - } - if (activeFabricServiceChannel.enabled) { - writeRuntimeDetail("websocket_receive_required", "fabric websocket unavailable; HTTP batch receive disabled " + lastWebSocketRelayError(), "downlink", -1, -1, "WEBSOCKET_REQUIRED", -1); - return new ArrayList<>(); - } - return client.receiveClientPacketBatch(clusterId, vpnConnectionId, timeoutMs); - } - private boolean offerDownlinkPacket(byte[] packet, int length) throws InterruptedException { BlockingQueue[] queues = downlinkQueues; if (queues == null || queues.length == 0 || length <= 0 || packet == null || length > packet.length) { @@ -3897,10 +2980,10 @@ public class RapVpnService extends VpnService { long now = System.currentTimeMillis(); boolean important = "error".equals(state) || "stopped".equals(state) - || "relay".equals(state) - || "relay_reset_warning".equals(state) + || "fabric_transport".equals(state) + || "fabric_transport_reset_warning".equals(state) || "tunnel".equals(state) - || "relay_reset".equals(state) + || "fabric_transport_reset".equals(state) || "runtime_recovery".equals(state) || "downlink_restart".equals(state); if (!important && now - lastRuntimeStatusAt < RUNTIME_STATUS_INTERVAL_MS) { @@ -4382,9 +3465,6 @@ public class RapVpnService extends VpnService { boolean meshNodeRouteMode; String fabricMeshExitEndpoints = ""; String fabricRuntimeConfigJson = ""; - String packetRelayBaseUrl = ""; - final List packetRelayBaseUrls = new ArrayList<>(); - FabricServiceChannel fabricServiceChannel = new FabricServiceChannel(); int dataplaneTransportCandidateCount = 0; int dataplaneEntryCandidateCount = 0; int dataplaneExitCandidateCount = 0; @@ -4414,3 +3494,4 @@ public class RapVpnService extends VpnService { } } } + diff --git a/clients/android/app/src/main/java/su/cin/rapvpn/TestTrafficActivity.java b/clients/android/app/src/main/java/su/cin/rapvpn/TestTrafficActivity.java index 19b9fd1..54c26ed 100644 --- a/clients/android/app/src/main/java/su/cin/rapvpn/TestTrafficActivity.java +++ b/clients/android/app/src/main/java/su/cin/rapvpn/TestTrafficActivity.java @@ -54,7 +54,7 @@ public class TestTrafficActivity extends Activity { setContentView(layout); String url = getIntent().getStringExtra(EXTRA_URL); if (url == null || url.isEmpty()) { - url = "http://192.168.200.61:18080/"; + url = "http://example.com/"; } target = url; assetErrorCount = 0; diff --git a/clients/android/app/src/main/java/su/cin/rapvpn/TestVpnActivity.java b/clients/android/app/src/main/java/su/cin/rapvpn/TestVpnActivity.java index a97dbf1..4eb6222 100644 --- a/clients/android/app/src/main/java/su/cin/rapvpn/TestVpnActivity.java +++ b/clients/android/app/src/main/java/su/cin/rapvpn/TestVpnActivity.java @@ -11,7 +11,7 @@ import java.nio.charset.StandardCharsets; public class TestVpnActivity extends Activity { public static final String EXTRA_PROFILE_JSON = "profile_json"; public static final String EXTRA_PROFILE_BASE64 = "profile_base64"; - public static final String EXTRA_BACKEND_URL = "backend_url"; + public static final String EXTRA_FABRIC_BOOTSTRAP_CONFIG = "fabric_bootstrap_config"; public static final String EXTRA_CLUSTER_ID = "cluster_id"; public static final String EXTRA_VPN_CONNECTION_ID = "vpn_connection_id"; private static final int VPN_PREPARE_REQUEST = 77; @@ -44,7 +44,10 @@ public class TestVpnActivity extends Activity { private Intent buildServiceIntent(Intent source) { Intent intent = new Intent(this, RapVpnService.class); intent.putExtra(RapVpnService.EXTRA_PROFILE_JSON, profileJson(source)); - intent.putExtra(RapVpnService.EXTRA_BACKEND_URL, source.getStringExtra(EXTRA_BACKEND_URL)); + String fabricBootstrapConfig = source.getStringExtra(EXTRA_FABRIC_BOOTSTRAP_CONFIG); + if (fabricBootstrapConfig != null && !fabricBootstrapConfig.isEmpty()) { + intent.putExtra(RapVpnService.EXTRA_FABRIC_BOOTSTRAP_CONFIG, fabricBootstrapConfig); + } intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, source.getStringExtra(EXTRA_CLUSTER_ID)); intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, source.getStringExtra(EXTRA_VPN_CONNECTION_ID)); return intent; diff --git a/clients/android/app/src/main/java/su/cin/rapvpn/VpnPacketWebSocketRelay.java b/clients/android/app/src/main/java/su/cin/rapvpn/VpnPacketWebSocketRelay.java deleted file mode 100644 index 6b2e5cb..0000000 --- a/clients/android/app/src/main/java/su/cin/rapvpn/VpnPacketWebSocketRelay.java +++ /dev/null @@ -1,393 +0,0 @@ -package su.cin.rapvpn; - -import android.net.VpnService; -import android.util.Log; - -import java.net.URI; -import java.util.ArrayList; -import java.util.List; -import java.util.concurrent.ArrayBlockingQueue; -import java.util.concurrent.BlockingQueue; -import java.util.concurrent.TimeUnit; - -import okhttp3.ConnectionPool; -import okhttp3.Dispatcher; -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.WebSocket; -import okhttp3.WebSocketListener; -import okio.ByteString; - -final class VpnPacketWebSocketRelay { - private static final String TAG = "RapVpnWebSocketRelay"; - private static final int MAX_PACKET_BATCH_PACKETS = 512; - private static final int MAX_PACKET_BATCH_BYTES = 1024 * 1024; - private static final int MAX_SINGLE_PACKET_BYTES = 65535; - private static final long CONNECTING_STALE_MS = 8000; - private static final long OPEN_WAIT_MS = 3500; - private static final int PRIORITY_GRACE_MS = 2; - - private final String baseUrl; - private final VpnService vpnService; - private final OkHttpClient httpClient; - private final FabricServiceChannel fabricServiceChannel; - private final BlockingQueue> priorityIncoming = new ArrayBlockingQueue<>(512); - private final BlockingQueue> incoming = new ArrayBlockingQueue<>(2048); - private final Object lock = new Object(); - - private WebSocket webSocket; - private String connectedClusterId = ""; - private String connectedVpnConnectionId = ""; - private volatile boolean open; - private volatile boolean connecting; - private volatile long connectingSinceMs; - private volatile long reconnectAfterMs; - private volatile String lastError = ""; - - VpnPacketWebSocketRelay(String baseUrl, VpnService vpnService) { - this(baseUrl, vpnService, new FabricServiceChannel()); - } - - VpnPacketWebSocketRelay(String baseUrl, VpnService vpnService, FabricServiceChannel fabricServiceChannel) { - this.baseUrl = trimRight(baseUrl); - this.vpnService = vpnService; - this.fabricServiceChannel = fabricServiceChannel == null ? new FabricServiceChannel() : fabricServiceChannel; - OkHttpClient.Builder builder = new OkHttpClient.Builder(); - if (vpnService != null) { - builder.socketFactory(new RapApiClient.ProtectedSocketFactory(vpnService)); - } - builder.dns(new RapApiClient.BackendPinnedDns(baseUrl)); - builder.connectTimeout(5, TimeUnit.SECONDS); - builder.writeTimeout(10, TimeUnit.SECONDS); - builder.readTimeout(0, TimeUnit.SECONDS); - builder.retryOnConnectionFailure(true); - Dispatcher dispatcher = new Dispatcher(); - dispatcher.setMaxRequests(16); - dispatcher.setMaxRequestsPerHost(8); - builder.dispatcher(dispatcher); - builder.connectionPool(new ConnectionPool(8, 5, TimeUnit.MINUTES)); - this.httpClient = builder.build(); - } - - String baseUrl() { - return baseUrl; - } - - boolean isOpen() { - return open; - } - - String lastError() { - return lastError == null ? "" : lastError; - } - - void connect(String clusterId, String vpnConnectionId) { - if (clusterId == null || clusterId.isEmpty() || vpnConnectionId == null || vpnConnectionId.isEmpty()) { - return; - } - long now = System.currentTimeMillis(); - synchronized (lock) { - if (open && clusterId.equals(connectedClusterId) && vpnConnectionId.equals(connectedVpnConnectionId)) { - return; - } - if (connecting && clusterId.equals(connectedClusterId) && vpnConnectionId.equals(connectedVpnConnectionId)) { - if (now - connectingSinceMs < CONNECTING_STALE_MS) { - return; - } - lastError = "stale websocket connect"; - closeLocked(); - } - if (now < reconnectAfterMs) { - return; - } - closeLocked(); - String wsUrl = webSocketUrl(clusterId, vpnConnectionId); - if (wsUrl.isEmpty()) { - lastError = "invalid websocket url"; - reconnectAfterMs = now + 5000; - return; - } - connectedClusterId = clusterId; - connectedVpnConnectionId = vpnConnectionId; - connecting = true; - connectingSinceMs = now; - Request.Builder requestBuilder = new Request.Builder().url(wsUrl); - this.fabricServiceChannel.applyHeaders(requestBuilder); - Request request = requestBuilder.build(); - lastError = "connecting"; - webSocket = httpClient.newWebSocket(request, new Listener()); - } - } - - boolean sendClientPacketBatch(String clusterId, String vpnConnectionId, List packets) { - packets = cleanPacketBatch(packets); - if (packets.isEmpty()) { - return true; - } - connect(clusterId, vpnConnectionId); - if (!awaitOpen(OPEN_WAIT_MS)) { - return false; - } - WebSocket socket = webSocket; - if (socket == null) { - lastError = "websocket missing after open"; - return false; - } - byte[] payload = encodePacketBatch(packets); - if (payload.length == 0) { - return true; - } - boolean queued = socket.send(ByteString.of(payload)); - if (!queued) { - lastError = "websocket send queue rejected batch"; - synchronized (lock) { - if (socket == webSocket) { - reconnectAfterMs = 0; - closeLocked(); - } - } - } - return queued; - } - - List receiveClientPacketBatch(String clusterId, String vpnConnectionId, int timeoutMs) throws InterruptedException { - connect(clusterId, vpnConnectionId); - awaitOpen(Math.min(OPEN_WAIT_MS, Math.max(1, timeoutMs))); - int waitMs = Math.max(1, timeoutMs); - List packets = priorityIncoming.poll(); - if (packets != null) { - return packets; - } - packets = priorityIncoming.poll(Math.min(PRIORITY_GRACE_MS, waitMs), TimeUnit.MILLISECONDS); - if (packets != null) { - return packets; - } - packets = incoming.poll(); - if (packets != null) { - return packets; - } - packets = priorityIncoming.poll(); - if (packets != null) { - return packets; - } - packets = incoming.poll(Math.max(1, waitMs - PRIORITY_GRACE_MS), TimeUnit.MILLISECONDS); - return packets == null ? new ArrayList<>() : packets; - } - - void close() { - synchronized (lock) { - closeLocked(); - } - } - - private void closeLocked() { - open = false; - connecting = false; - connectingSinceMs = 0; - priorityIncoming.clear(); - incoming.clear(); - if (webSocket != null) { - try { - webSocket.close(1000, "relay switch"); - } catch (Exception ignored) { - } - } - webSocket = null; - } - - private boolean awaitOpen(long timeoutMs) { - long deadline = System.currentTimeMillis() + Math.max(1, timeoutMs); - synchronized (lock) { - while (!open && connecting) { - long waitMs = deadline - System.currentTimeMillis(); - if (waitMs <= 0) { - break; - } - try { - lock.wait(waitMs); - } catch (InterruptedException e) { - Thread.currentThread().interrupt(); - lastError = "interrupted waiting for websocket open"; - return false; - } - } - if (!open && "connecting".equals(lastError)) { - lastError = "connecting_timeout"; - } - return open; - } - } - - private String webSocketUrl(String clusterId, String vpnConnectionId) { - try { - URI uri = URI.create(baseUrl); - String scheme = "https".equalsIgnoreCase(uri.getScheme()) ? "wss" : "ws"; - String path = uri.getRawPath() == null || uri.getRawPath().isEmpty() ? "" : trimRight(uri.getRawPath()); - String fabricPath = fabricServiceChannel.packetPathForBase(baseUrl, clusterId, vpnConnectionId, true); - if (!fabricPath.isEmpty()) { - path += fabricPath; - } else { - path += "/clusters/" + clusterId + "/vpn-connections/" + vpnConnectionId + "/tunnel/client/packets/ws"; - } - URI ws = new URI(scheme, uri.getRawUserInfo(), uri.getHost(), uri.getPort(), path, null, null); - return ws.toString(); - } catch (Exception e) { - lastError = e.getClass().getSimpleName() + ": " + e.getMessage(); - return ""; - } - } - - private final class Listener extends WebSocketListener { - @Override - public void onOpen(WebSocket webSocket, Response response) { - synchronized (lock) { - open = true; - connecting = false; - reconnectAfterMs = 0; - lastError = ""; - lock.notifyAll(); - } - Log.i(TAG, "vpn packet websocket opened " + baseUrl); - } - - @Override - public void onMessage(WebSocket webSocket, ByteString bytes) { - List packets = decodePacketBatch(bytes.toByteArray()); - if (packets.isEmpty()) { - return; - } - offerIncomingPacketBatch(packets); - } - - @Override - public void onClosed(WebSocket webSocket, int code, String reason) { - synchronized (lock) { - open = false; - connecting = false; - reconnectAfterMs = System.currentTimeMillis() + 1000; - lastError = "closed " + code + " " + reason; - lock.notifyAll(); - } - } - - @Override - public void onFailure(WebSocket webSocket, Throwable t, Response response) { - String responseStatus = ""; - if (response != null) { - responseStatus = " status=" + response.code(); - } - synchronized (lock) { - open = false; - connecting = false; - reconnectAfterMs = System.currentTimeMillis() + 3000; - lastError = (t == null ? "websocket failure" : t.getClass().getSimpleName() + ": " + t.getMessage()) + responseStatus; - lock.notifyAll(); - } - Log.w(TAG, "vpn packet websocket failed " + baseUrl + ": " + lastError); - } - } - - private static List cleanPacketBatch(List packets) { - List cleaned = new ArrayList<>(); - int bytes = 0; - if (packets == null) { - return cleaned; - } - for (byte[] packet : packets) { - if (packet == null || packet.length <= 0 || packet.length > MAX_SINGLE_PACKET_BYTES) { - continue; - } - int projected = bytes + 4 + packet.length; - if (cleaned.size() >= MAX_PACKET_BATCH_PACKETS || projected > MAX_PACKET_BATCH_BYTES) { - break; - } - cleaned.add(packet); - bytes = projected; - } - return cleaned; - } - - private static byte[] encodePacketBatch(List packets) { - packets = cleanPacketBatch(packets); - int total = 0; - for (byte[] packet : packets) { - total += 4 + packet.length; - } - byte[] out = new byte[total]; - int offset = 0; - for (byte[] packet : packets) { - int length = packet.length; - out[offset] = (byte) ((length >> 24) & 0xff); - out[offset + 1] = (byte) ((length >> 16) & 0xff); - out[offset + 2] = (byte) ((length >> 8) & 0xff); - out[offset + 3] = (byte) (length & 0xff); - offset += 4; - System.arraycopy(packet, 0, out, offset, length); - offset += length; - } - return out; - } - - private static List decodePacketBatch(byte[] payload) { - List packets = new ArrayList<>(); - int offset = 0; - while (payload != null && offset + 4 <= payload.length && packets.size() < MAX_PACKET_BATCH_PACKETS) { - int length = ((payload[offset] & 0xff) << 24) - | ((payload[offset + 1] & 0xff) << 16) - | ((payload[offset + 2] & 0xff) << 8) - | (payload[offset + 3] & 0xff); - offset += 4; - if (length <= 0 || length > MAX_SINGLE_PACKET_BYTES || offset + length > payload.length) { - break; - } - byte[] packet = new byte[length]; - System.arraycopy(payload, offset, packet, 0, length); - packets.add(packet); - offset += length; - } - return packets; - } - - private void offerIncomingPacketBatch(List packets) { - BlockingQueue> target = containsTCPControlPacket(packets) ? priorityIncoming : incoming; - if (!target.offer(packets)) { - target.poll(); - target.offer(packets); - } - } - - private static boolean containsTCPControlPacket(List packets) { - if (packets == null) { - return false; - } - for (byte[] packet : packets) { - if (isTCPControlPacket(packet)) { - return true; - } - } - return false; - } - - private static boolean isTCPControlPacket(byte[] packet) { - if (packet == null || packet.length < 20 || (packet[0] >> 4) != 4) { - return false; - } - int ihl = (packet[0] & 0x0f) * 4; - if (ihl < 20 || packet.length < ihl + 20 || packet[9] != 6) { - return false; - } - int flags = packet[ihl + 13] & 0xff; - return (flags & 0x17) != 0; - } - - private static String trimRight(String value) { - if (value == null) { - return ""; - } - while (value.endsWith("/")) { - value = value.substring(0, value.length() - 1); - } - return value; - } -} diff --git a/docs/architecture/ARCHITECTURE_GUARDRAILS.md b/docs/architecture/ARCHITECTURE_GUARDRAILS.md index e28695f..d6f2a80 100644 --- a/docs/architecture/ARCHITECTURE_GUARDRAILS.md +++ b/docs/architecture/ARCHITECTURE_GUARDRAILS.md @@ -6,6 +6,16 @@ This file exists so architecture documents have a stable guardrails reference inside `docs/architecture`. The operational Codex guardrails remain in `docs/codex/ARCHITECTURE_GUARDRAILS.md`. +Transport clarification: references in this document to direct worker WSS and +backend gateway fallback belong to the preserved historical RDP service +baseline. They are not the active source of truth for inter-node transport. +Current fabric node-to-node transport is QUIC-only and is defined by +`docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md`, +`docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md`, and +`docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md`. +Node survivability, recovery overlap, and no-manual-access repair rules are +defined by `docs/architecture/FABRIC_NODE_SURVIVAL_AND_RECOVERY_POLICY.md`. + ## 1. Preserve the Proven RDP Baseline The following are already proven and must remain stable: @@ -16,8 +26,8 @@ The following are already proven and must remain stable: - detach without killing the remote session - reattach without recreating the remote session - takeover without recreating the remote session -- direct worker WSS data plane -- backend gateway fallback +- historical direct worker WSS RDP path +- historical backend gateway fallback for the RDP baseline - C++ RDP Adapter as the active RDP runtime Architecture clarification must not silently weaken this behavior. @@ -191,6 +201,9 @@ Updates must support: - local update cache where approved - OS / architecture specific artifacts under signed release manifests - explicit migration bundles when data structures change +- legacy recovery compatibility until the fleet is converged or explicitly + retired +- multi-source artifact retrieval for stranded or NAT-only nodes Version Storage stores immutable release manifests, artifacts, hashes, signatures, compatibility metadata, provenance, and approved migration bundles. diff --git a/docs/architecture/CLUSTER_NODE_ADMIN_FOUNDATION.md b/docs/architecture/CLUSTER_NODE_ADMIN_FOUNDATION.md index f7ee761..ad71411 100644 --- a/docs/architecture/CLUSTER_NODE_ADMIN_FOUNDATION.md +++ b/docs/architecture/CLUSTER_NODE_ADMIN_FOUNDATION.md @@ -1059,7 +1059,8 @@ accepts a signed/introspected `remote_workspace` service-channel lease on `remote-workspaces/{resource_id}/streams/{channel_class}`, validates service class, channel class, selected entry node, and data-plane flow isolation, and reports access telemetry. It intentionally returns a probe contract with -`payload_flow=not_implemented` for non-empty RDP payloads; this stage proves +`payload_flow=validated_only` for empty control probes; non-empty RDP payloads are +rejected with `probe_only required`. This stage proves the Fabric ingress contract without forwarding desktop frames yet. The live smoke is `scripts/fabric/c19d-remote-workspace-entry-ingress-smoke.ps1`. diff --git a/docs/architecture/DATA_PLANE_V1.md b/docs/architecture/DATA_PLANE_V1.md index db534b6..cc835f3 100644 --- a/docs/architecture/DATA_PLANE_V1.md +++ b/docs/architecture/DATA_PLANE_V1.md @@ -1,5 +1,12 @@ # Data Plane v1 for RDP +Archived status: this document is a historical RDP/WebSocket stage record, not +the current runtime source of truth for transport architecture. The active +fabric transport model is QUIC-only between nodes; see +`docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md`, +`docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md`, and +`docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md`. + Status: DP-3A grayscale full-frame binary render foundation is implemented and smoke-proven on the test Docker environment as of 2026-04-25. DP-3B adaptive quality policy/selection is intentionally paused. The accepted C++ RDP Adapter baseline is the ordered-region path. RDP-Perf-6 makes direct dirty-region binary render explicit with `render.frame.full` / `render.frame.region` RAP2 message types and is build/probe/live-smoke-proven on the test Docker environment as of 2026-04-26. The current test Docker deployment for the RDP Adapter performance path is `rap-rdp-worker:rdp-perf6-dirty-region`. The Stage 5.2 core download data path remains runtime-proven for direct worker WSS and backend gateway fallback. Data-plane and RDP work are paused; the next active focus is Stage C10 Fabric Core / cluster foundation, not another data-plane feature. This document defines the first staged data-plane evolution for the RDP MVP. It does not implement direct worker WebSocket runtime, mesh routing, VPN, QUIC, UDP, WebRTC, relay nodes, or multi-cluster behavior. diff --git a/docs/architecture/DIRECT_WORKER_TLS_PKI.md b/docs/architecture/DIRECT_WORKER_TLS_PKI.md index ce4e967..7b6f010 100644 --- a/docs/architecture/DIRECT_WORKER_TLS_PKI.md +++ b/docs/architecture/DIRECT_WORKER_TLS_PKI.md @@ -1,5 +1,12 @@ # Direct Worker WSS TLS / PKI +Archived status: this document captures a direct-worker WSS trust design track +and is no longer the primary reference for node-to-node transport. The active +fabric transport model is QUIC-only between nodes; see +`docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md`, +`docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md`, and +`docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md`. + Status: P3.4 trust-model design/prep complete. This document defines the production trust model for direct worker WSS. It does diff --git a/docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md b/docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md index b6874e5..e365233 100644 --- a/docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md +++ b/docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md @@ -24,6 +24,21 @@ policy allows, host limited control/storage roles when approved, and report mobile-specific capacity signals such as battery, network type, NAT behavior, foreground/background state, and metered network policy. +Node survival and recovery across endpoint moves, NAT-only reachability, legacy +contract overlap, and unavailable manual host access are governed by +`docs/architecture/FABRIC_NODE_SURVIVAL_AND_RECOVERY_POLICY.md`. In +particular, nodes like `ifcm-rufms-s-mo1cr` must remain recoverable through the +fabric/update/recovery plane even when direct host login is unavailable. + +Android implementation contract: + +- app install/build contains a QUIC bootstrap seed set; +- runtime launch carries a `fabric_bootstrap_config`, not a backend URL; +- user login/profile selection happens over the fabric control channel; +- the Android VPN dataplane is QUIC fabric runtime only; HTTP batch packet + forwarding, WebSocket packet relay, and direct backend packet relay are not + part of the supported runtime path. + ## What Was Missing The current implementation proves route leases and production VPN forwarding, @@ -60,8 +75,9 @@ route and stream semantics. must keep working through cached policy, peer directories, route leases, and local health when central components are degraded. 7. Mobile nodes are first-class nodes with stricter capability scoring. -8. HTTP forwarding remains a compatibility and emergency fallback, not the - primary high-speed data plane. +8. QUIC is the single runtime transport between fabric nodes. HTTP/HTTPS may + serve human-facing download or panel pages, but it is not a node data-plane + fallback and must not carry service packets. 9. There must be no single management service that can seize the fabric. Control, storage, update distribution, route authority, and certificate authority are fabric roles assigned to eligible nodes and protected by quorum signatures. @@ -73,6 +89,20 @@ route and stream semantics. the usable candidate locally by policy, reachability, latency, load, and trust. +## Transport vs Control API + +The system must keep two layers separate in naming, design, and diagnostics: + +- `Fabric Transport` means inter-node runtime delivery only. It is QUIC over UDP + and carries leased service-channel/data-plane traffic between nodes. +- `Control API` means human/operator/programmatic management surfaces such as + web-admin, release publication, policy mutation, audit queries, and status + reads. Today that surface is HTTP/JSON and may sit behind HTTPS ingress. + +The HTTP Control API is not a fallback transport for node-to-node runtime +traffic. A `409 Conflict` from the backend, a panel page load, or a release +download is control-plane behavior, not fabric transport behavior. + ## Distributed Control And Trust The target fabric behaves like a distributed network, not a client/server @@ -145,6 +175,143 @@ Endpoint state is also distributed: - Neighbor selection is local and latency/load-aware; the state log announces facts and policy, not a forced single next hop. +### Fabric Registry Gossip + +Moving a service must not break the farm. + +`RAP_BACKEND_URL` or any fixed HTTP/API address is only a migration fallback for +old nodes. It is not cluster truth. After bootstrap, a node finds services by +logical role through signed fabric registry records that can be carried by any +reachable peer. + +The rule is: + +- any node may relay registry knowledge; +- only authorized signatures can create or replace trusted registry truth; +- a new record becomes active only after signature/authority checks and a + successful live probe through the fabric or a policy-approved direct QUIC + candidate; +- older still-valid records remain as fallback until their TTL expires. + +Registry record shape: + +```text +schema_version: rap.fabric.registry.gossip_record.v1 +cluster_id +service: control-api | update-store | update-cache | web-admin | vpn-egress-pool | ... +scope: farm | cluster | organization +organization_id: optional +epoch: monotonic service epoch +generation: optional human/debug generation +issued_at +expires_at +issuer_node_id +issuer_role: control-authority | update-authority | storage-authority | route-authority +endpoints: + - endpoint_id + address: quic://... + transport: direct_quic | relay_quic | reverse_quic + reachability + connectivity_mode + priority / weight + peer_cert_sha256 +signatures: + - key_id + issuer_id + role + alg: ed25519 + value +``` + +Acceptance algorithm: + +1. Reject records for a different cluster, expired records, future records past + allowed clock skew, unsupported schema, missing endpoints, or non-QUIC + endpoints. +2. Verify the canonical record payload, excluding `signatures`, against the + configured authority set. +3. Check the signer role is allowed for that service and scope. +4. Require quorum where policy says M-of-N; development may use one trusted + signer but must mark that signer as bootstrap/development authority. +5. Store accepted records as `candidate`. +6. Promote `candidate` to `active` only after live-probing at least one endpoint + and verifying the endpoint identity/pin. +7. Prefer higher epoch, then newer issued time, then generation. Do not replace + a live active record with an older record. +8. Keep the previous active record usable as fallback until TTL expiry when a + newer candidate is not yet live-verified. + +This is the recovery path for mass moves. If every known service endpoint moves +at once, the operator or a control-authority node only has to deliver a signed +registry record to one reachable fabric node. That node validates it, probes it, +promotes it, and gossips it onward. User/mobile/candidate nodes may carry the +record, but cannot make it authoritative unless their role certificate permits +that service/scope. + +Service classes that must use this registry before production hardening: + +- `control-api`: heartbeat, auth/profile control projection, node registration, + policy/snapshot fetch. +- `update-store`: signed release manifests and compatibility windows. +- `update-cache`: artifact mirrors close to nodes. +- `web-admin`: management UI/API ingress replicas. +- `vpn-egress-pool`: user-visible exit pools; users see pools, not backing + nodes. + +Legacy endpoint compatibility is allowed only for rolling migration: + +- Old nodes may use their baked HTTP/control URL only to fetch a new version or + a signed registry bootstrap record. +- New nodes must treat fixed URLs as fallback hints, not as authority. +- Old code is removed only after every live node reports a version that supports + signed registry gossip and service discovery by role. + +Listener configuration is split into bind sockets and reachability candidates: + +- `listen_addr` is what the local process binds, for example + `0.0.0.0:18080` on `home-1`. +- `endpoint_candidates` is the ordered set of addresses other nodes may try. + A single node can publish LAN addresses, addresses on several network + adapters, STUN/reflexive addresses, and multiple public NAT forwards from + different providers. +- Public NAT forwards are modeled as candidates with metadata, not as a + replacement for the internal bind address. Example: + `quic://94.141.118.222:19199 reachability=public connectivity=direct + provider=isp1 maps_to=192.168.200.85:18080`. +- A candidate may be valid only from outside the NAT. Same-LAN hairpin failure + is not a proof that the public candidate is broken; verification must be + scoped to an external peer or remote probe. +- The route builder scores candidates by reachability, measured latency, loss, + load, policy, and verification freshness. If one provider or interface fails, + the node keeps the same node identity and republishes a new candidate epoch. + +## Install Artifact Bootstrap Contract + +Every installable artifact is a node image plus a bootstrap seed set. + +This applies to Android, Docker, Linux services, and Windows services. The seed +set is baked into the artifact or delivered beside it as signed install +metadata. It is not a single backend URL and not a management server choice. It +is a bounded list of known fabric endpoint candidates that may be reachable from +different network positions: + +- public QUIC candidates, for example `usa-los-1` or externally reachable + `home-1`; +- private/LAN QUIC candidates, for example Docker-test or home LAN nodes; +- closed-site candidates that have no Internet route themselves but can reach a + neighboring fabric node; +- optional pinned certificate hashes or authority descriptors for high-trust + entry candidates. + +On first start the installed node tries the seed set, joins through any reachable +peer, registers as a candidate node with minimal rights, and then receives +signed peer-directory, role, update, and policy state through the fabric. If a +node is installed in an isolated network, it can still become visible and usable +when at least one nearby seed node can route onward to the rest of the fabric. +User login on Android is only identity/profile selection for the `vpn-client` +service; the underlying phone node already exists and participates in the +fabric with candidate permissions. + ## Node Roles Initial role vocabulary: @@ -172,7 +339,7 @@ uplink stability, foreground state, and user cost policy. Nodes must advertise capability facts in heartbeats and peer updates: - supported fabric protocol versions; -- supported transports: UDP/QUIC, TCP, WebSocket, HTTPS fallback; +- supported transport: UDP/QUIC; - NAT type and reachability; - measured RTT/loss/jitter/bandwidth to peers and entry candidates; - CPU, memory, queue depth, file descriptor/socket pressure; @@ -184,9 +351,8 @@ Nodes must advertise capability facts in heartbeats and peer updates: ## Fabric Data Session V1 -The first practical protocol step is a persistent binary data session. It may -initially run over WebSocket/TCP for faster delivery, but the framing must be -transport-neutral so the same protocol can move to QUIC/UDP. +The first practical protocol step is a persistent binary QUIC data session. +The framing stays service-neutral, but the runtime transport is QUIC only. Minimum frame set: @@ -338,69 +504,36 @@ Deliverables: ### Stage FNP-3: WebSocket/TCP Compatibility Transport -Status: started with a transport-neutral `io.Reader`/`io.Writer` frame loop, -WebSocket frame adapter in `agents/rap-node-agent/internal/fabricproto`, and a -gated/authenticated mesh smoke endpoint/client at `/mesh/v1/fabric/session/ws`. -`rap-host-agent fabric-session-smoke` provides the first operator smoke command -and can pass signed fabric-session authority payload/signature headers for -authority-pinned nodes. -Node-agent exposes the endpoint only when `RAP_MESH_FABRIC_SESSION_ENABLED` / -`-mesh-fabric-session-enabled` is set, and reports the enabled endpoint in -heartbeat metadata. -`mesh-live-smoke` includes a fabric-session `PING`/`PONG` check alongside the -existing route and test-service probes. Mesh client code now has a reusable -`FabricSessionClient` for multiple frame exchanges over one WebSocket session, -plus a pump mode with outbound/inbound queues for asynchronous stream traffic. -Live smoke verifies two `PING`/`PONG` round trips on the same connection. -`vpnruntime` has a binary VPN packet-batch mapper for `FrameData` payloads so -packet delivery can move away from JSON production envelopes in a gated mode. -`FabricSessionPacketTransport` now adapts that mapper to the existing -`PacketTransport` interface and can demultiplex inbound DATA frames into the -VPN packet inbox by stream id. -`mesh-live-smoke` now sends a real VPN packet batch through -`FabricSessionPacketTransport` over the WebSocket fabric session and requires a -stream ACK from the remote node. -Mesh has a peer session manager that reuses one pump per peer endpoint, giving -VPN transport selection a stable place to acquire long-lived fabric sessions. -Node config now carries a separate gated -`RAP_VPN_FABRIC_SESSION_TRANSPORT_ENABLED` switch and heartbeat report for the -binary VPN packet transport, keeping endpoint exposure and VPN dataplane -rollout independently controllable. -When the VPN fabric-session switch is enabled, node-agent now attempts to use a -long-lived peer session for gateway packet transport and falls back to the -existing HTTP production envelope path when the peer session is unavailable. -Peer session reuse now evicts closed pumps before reuse, so failed WebSocket -sessions can be reopened on the next transport acquisition. -Heartbeat telemetry includes peer session manager counters for active sessions, -reuses, opens, closed-pump evictions, and explicit close operations. -The mesh package now exposes a service-neutral `FabricTransport` abstraction; -the current WebSocket carrier implements it as `WebSocketFabricTransport`, so -future QUIC/UDP transport can be added without changing VPN/RDP/HTTP services. -`QUICFabricTransport` now implements the same interface and carries the same -binary `fabricproto` frames over a QUIC stream, with local smoke coverage for -`PING`/`PONG` and DATA/ACK. -Carrier selection understands QUIC transport labels and `quic://host:port` -endpoints while preserving WebSocket as the default fallback. -`QUICFabricServer` provides the matching node-side QUIC listener for accepting -fabric streams and running the same session frame handler as other carriers. -Node-agent can now gate the QUIC listener with -`RAP_MESH_QUIC_FABRIC_ENABLED` / `RAP_MESH_QUIC_FABRIC_LISTEN_ADDR`, report it -in heartbeat metadata, and pass the setting through host-agent install/update -profiles. -`mesh-live-smoke` verifies the QUIC carrier by starting a temporary QUIC fabric -server and requiring a `PING`/`PONG` round trip over `QUICFabricTransport`. -Nodes now advertise enabled QUIC fabric listeners as `direct_quic` fast-path -endpoint candidates, and endpoint ranking prefers QUIC over WebSocket/HTTPS -compatibility candidates for fabric sessions. +Status: retired as a migration-only stage. + +This stage existed to bootstrap binary frame semantics before QUIC routing and +carrier reuse were ready. It introduced the transport-neutral frame loop, +session-shaped packet mapper, and early smoke tooling. That work was useful as +scaffolding, but it is no longer the target runtime. + +Current rule: + +- WebSocket/TCP fabric-session transport is not part of the supported node + dataplane. +- QUIC/UDP is the only supported runtime carrier between fabric nodes. +- Old WebSocket/TCP smoke helpers are being removed; migration/debug tooling + must move to QUIC-native smoke and recovery paths. +- Any routing, heartbeat, registry, peer probe, or service dataplane logic must + reject WebSocket/TCP carriers as non-QUIC transport, not treat them as a + valid alternate path. + +What survives from this stage is the service-neutral frame model and the +`FabricSessionPacketTransport` mapping, which now ride on QUIC carriers instead +of a WebSocket fallback. VPN fabric-session gateway transport now consumes ranked endpoint candidates, -so dataplane sessions can select QUIC fast-path candidates and fall back to -legacy peer endpoints when the control plane has not published candidates yet. +so dataplane sessions can select QUIC fast-path candidates and refuse non-QUIC +peer endpoints when the control plane has not published valid candidates yet. The temporary self-signed QUIC listener advertises its SHA-256 certificate fingerprint in endpoint metadata, and the QUIC client can pin that fingerprint instead of disabling verification while the cluster CA path is being finished. VPN fabric-session dialing now walks all ranked endpoint candidates before -falling back to the legacy peer endpoint, so a failed QUIC candidate does not -block WebSocket/HTTPS compatibility transport. +declaring the target unavailable, so a failed QUIC candidate does not silently +re-enable WebSocket/HTTPS compatibility transport. Successful VPN fabric-session dialing logs the selected candidate, transport, certificate pin usage, and remaining fallback count for phone-side diagnostics. Heartbeat telemetry now includes VPN fabric-session dial counters for attempts, @@ -416,8 +549,8 @@ Endpoint health observations are now emitted as a bounded standalone heartbeat report (`rap.vpn_fabric_endpoint_health_report.v1`) so control plane can ingest candidate feedback without parsing the transport diagnostics blob. VPN fabric-session transport telemetry is carrier-neutral -(`fabric_session_binary_frames`) and reports QUIC/WebSocket as available -carriers instead of describing the dataplane as WebSocket-only. +(`fabric_session_binary_frames`) and reports QUIC selection plus non-QUIC +candidate rejection instead of describing the dataplane as WebSocket-capable. Endpoint health observations are pruned in-memory by age and count before snapshot/report generation, preventing long-running nodes from accumulating unbounded candidate history. @@ -583,10 +716,10 @@ propagated by host-agent install profiles. Deliverables: -- carry binary frames over one persistent WebSocket/TCP connection; +- carry binary frames over one persistent QUIC fabric session; - replace high-frequency `/mesh/v1/forward` packet POST usage for VPN routes in a gated mode; -- keep HTTP forwarding as fallback. +- remove HTTP/WebSocket packet forwarding from the supported dataplane. ### Stage FNP-4: Android As Mobile Fabric Node @@ -609,12 +742,12 @@ Deliverables: ### Stage FNP-6: QUIC/UDP Transport -Status: started with `QUICFabricTransport` in `internal/mesh`. +Status: active runtime baseline in `internal/mesh`. Deliverables: - implement QUIC transport for Fabric Data Session V1; -- preserve WebSocket/TCP as fallback; +- keep QUIC/UDP as the only supported inter-node runtime transport; - test 4G/Wi-Fi transition and NAT behavior; - benchmark throughput, latency, and recovery against current HTTP forwarding. diff --git a/docs/architecture/FABRIC_AREA_AND_PEER_STABILITY_MODEL.md b/docs/architecture/FABRIC_AREA_AND_PEER_STABILITY_MODEL.md new file mode 100644 index 0000000..9262c3b --- /dev/null +++ b/docs/architecture/FABRIC_AREA_AND_PEER_STABILITY_MODEL.md @@ -0,0 +1,183 @@ +# Fabric Area And Peer Stability Model + +Status: active design correction. + +This document replaces the oversimplified rule "every node must keep 3 +connections" with a stability model based on failure domains ("areas"), +multi-path reachability, and live peer memory. + +## 1. Why the old "3 connections" rule is not enough + +A raw connection count is too weak as a resilience rule. + +Three links are not equivalent when: + +- all three peers are in the same private network; +- all three depend on the same NAT or relay path; +- all three depend on the same public ingress; +- all three are relay-ready but not direct-ready; +- all three are stale observations rather than recently verified paths. + +Therefore the fabric must not use a single scalar count as the stability +criterion. + +## 2. Area + +Introduce the concept of an `area`. + +An area is a failure domain with high mutual reachability and shared external +risk. Examples: + +- `home` - nodes in the same home/private site +- `test` - nodes in the same test Docker/LAN site +- `usa` - a public node in a remote Internet site +- `ifcm` - a separate NAT/domain behind another administrative boundary + +An area can be derived from: + +- operator-declared site/area label; +- shared private address space or local interface group; +- shared public egress/NAT identity; +- shared administrative host or cluster. + +The area label must be part of live node metadata and endpoint candidate +metadata. + +## 3. Stability objective + +Each node should maintain a working peer set with diversity, not just count. + +### 3.1 Minimum stable peer objective + +For an ordinary production node: + +- at least `2` recently verified direct-ready peers overall; +- at least `2` distinct external areas represented in the ready set when more + than one external area exists; +- at least `1` persistent recovery-capable path outside the local area; +- at least `1` additional relay-ready or rendezvous-capable path outside the + primary recovery path. + +For an area gateway or strategically important public node: + +- at least `3` direct-ready peers overall; +- at least `2` distinct external areas represented in the direct-ready set; +- at least `1` extra recovery path that does not share the same public ingress + or NAT dependency. + +For a node in a tiny fleet where only one external area currently exists: + +- the system must report `reduced-diversity mode`, not pretend the target is + fully satisfied. + +### 3.2 What counts as "ready" + +`ready` means: + +- recently verified; +- usable for immediate QUIC route establishment; +- not only a historical candidate; +- not blocked on stale relay replacement; +- not only a compatibility `Control API/downloads` overlap path. + +`relay_ready` does not replace `direct_ready`. + +## 4. What a node must remember + +Every node must keep a live working set, not just a tiny current-peer list. + +Minimum retained peer memory: + +1. all currently healthy nodes in the fleet, when the fleet is small enough; +2. for larger fleets, a bounded full directory plus prioritized recent working + peers; +3. for every known node: + - node id + - area + - role summary + - latest verified direct candidates + - latest verified relay/rendezvous candidates + - last success timestamp + - last failure class + - NAT / ingress dependency hints + - cert pin / authority compatibility metadata + +For the current fleet size, every node should indeed be capable of remembering +the full directory of every other node. There is no scale excuse at 6-8 nodes. + +## 5. Probe strategy + +The node should not aggressively probe every possible path at full frequency. +It should maintain a layered strategy. + +### 5.1 Hot set + +Always keep a hot set of: + +- current direct-ready peers; +- one recovery peer outside the local area; +- one alternate peer per external area. + +These should be revalidated frequently. + +### 5.2 Warm set + +Maintain a warm set of: + +- previously successful peers; +- peers from underrepresented areas; +- peers that would restore diversity if a hot peer fails. + +These should be revalidated on a slower cadence and promoted when diversity or +direct-ready count drops. + +### 5.3 Cold directory + +Retain the full known directory and signed registry records, even if not +actively probed at the same rate. + +## 6. Failure handling + +When a direct-ready peer is lost: + +1. do not merely replace it with the numerically cheapest peer; +2. prefer restoring: + - area diversity + - independent ingress diversity + - direct-ready count +3. only then fall back to relay-ready stabilization if direct replacement is + not currently available. + +## 7. Implications for the current fleet + +Current area mapping should be treated approximately as: + +- `home`: `home-1`, `home-2`, `home-3` +- `test`: `test-1`, `test-2`, `test-3` +- `usa`: `usa-los-1` +- `ifcm`: `ifcm-rufms-s-mo1cr` + +Under this model: + +- a node in `home` should avoid satisfying its minimum peer objective using + only `home` peers plus one relay; +- `usa-los-1` and `ifcm-rufms-s-mo1cr` should both maintain direct-ready links + that span at least two foreign areas when possible; +- a fleet-wide alert should trigger when a node loses cross-area diversity even + if its total peer count still looks healthy. + +## 8. Required implementation changes + +1. Add `area` to node metadata and endpoint candidate metadata. +2. Track peer readiness by area, not only total count. +3. Separate: + - `direct_ready_count` + - `relay_ready_count` + - `external_area_ready_count` + - `independent_ingress_ready_count` +4. Alert on: + - zero recovery path outside the local area + - direct-ready deficit + - area diversity deficit + - registry resolution deficit +5. Preserve a full node directory for the current small fleet. diff --git a/docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md b/docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md index 925b236..2df2ee4 100644 --- a/docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md +++ b/docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md @@ -289,7 +289,10 @@ Production fabric-core migration boundary: LAN/interface QUIC, STUN reflexive `ice_quic`, reverse/outbound-only, and `relay_quic` fallback. Candidate metadata carries `local_segment_id`, `nat_group_id`, `stun_server`, `ice_foundation`, `relay_node_id`, and - `relay_endpoint` when configured. + `relay_endpoint` when configured. When a relay endpoint is the first physical + QUIC hop, its advertised certificate fingerprint must survive route planning + so public-IP relay paths can verify the relay node by pin instead of falling + back to hostname/IP SAN matching. - Endpoint candidate scoring is QUIC-mode only. It ranks `direct_quic`, `lan_quic`, `ice_quic`, `reverse_quic`, and `relay_quic` using freshness, health observations, latency, reliability, region, policy tags, and live diff --git a/docs/architecture/FABRIC_LIVE_AUDIT_2026-05-18.md b/docs/architecture/FABRIC_LIVE_AUDIT_2026-05-18.md new file mode 100644 index 0000000..2317dbb --- /dev/null +++ b/docs/architecture/FABRIC_LIVE_AUDIT_2026-05-18.md @@ -0,0 +1,179 @@ +# Fabric Live Audit 2026-05-18 + +Status: live operational audit of the current fabric. This document records the +real state observed on 2026-05-18 and explicitly calls out where runtime +behavior still differs from the target architecture. + +## Current confirmed state + +- Inter-node transport for the live node-agent fleet is `QUIC over UDP`. +- The active node set + - `home-1` + - `home-2` + - `home-3` + - `test-1` + - `test-2` + - `test-3` + - `usa-los-1` + - `ifcm-rufms-s-mo1cr` + is converged on `0.2.321-directreadytarget`. +- `ifcm-rufms-s-mo1cr` recovered through the compatibility recovery path and is + no longer stale. + +## Why TCP traffic is still visible + +Visible TCP traffic is not coming from the inter-node fabric transport. It is +coming from the temporary compatibility recovery overlap that is still active. + +Observed live listeners: + +- `docker-test` + - `19191/tcp` - compatibility `Control API/downloads` bridge + - `18080/tcp` - web-admin + - `18090/tcp` - release files + - `18121/tcp` - backend Control API + - `19132/udp`, `19133/udp`, `19134/udp` - QUIC fabric listeners +- `usa-los-1` + - `19131/udp` - QUIC fabric listener + - `19191/tcp` - external compatibility bridge currently held open so legacy + recovery contracts can still reach `Control API/downloads` + +Therefore: + +- `TCP` is still present by design for recovery overlap. +- `UDP/QUIC` is the current node-to-node transport. +- The statement "the fabric is fully UDP-only" is not yet true at the full + system level while `19191/tcp` compatibility recovery remains enabled. + +## Why nodes were still falling away + +### 1. Nodes do not yet operate from a fully active signed registry gossip plane + +Observed on the live `ifcm-rufms-s-mo1cr` heartbeat: + +- `fabric_registry_runtime_report.status = candidate_only` +- `resolved_service_count = 0` +- `resolved_services.control-api = no_active_record` +- `resolved_services.update-store = no_active_record` +- `resolved_services.update-cache = no_active_record` + +This means the current runtime still depends on compatibility control URLs more +than the target architecture allows. The node is alive in the fabric, but not +yet operating from a fully resolved active registry view. + +### 2. Legacy control/download contracts are still real dependencies + +Observed on the live `ifcm-rufms-s-mo1cr` heartbeat after recovery: + +- `mesh_outbound_session_report.control_plane_url = http://vpn.cin.su:19191/api/v1` + +This confirms the root recovery lesson: + +- a NAT node without manual host access was still anchored to the old recovery + contract; +- until that contract was temporarily restored, the node could not advance; +- the node did not disappear because QUIC failed; it disappeared because the + recovery/control overlap was removed before the node had converged. + +### 3. Direct peer resilience is still below the intended threshold + +Observed from live heartbeat metadata: + +- `ifcm-rufms-s-mo1cr` + - `peer_connection_ready = 2` + - `peer_connection_relay_ready = 3` + - `target_ready_peers = 3` +- `usa-los-1` + - `peer_connection_ready = 1` + - `peer_connection_relay_ready = 5` + - `target_ready_peers = 3` + +This means the direct-path resilience target is not satisfied yet, even though +the nodes are healthy. + +The practical reason is simple: + +- the cluster has only a small number of externally reachable direct QUIC + endpoints; +- some nodes still advertise only private/LAN-reachable direct candidates; +- relay-ready adjacency is masking direct peer deficit, but it does not replace + the requirement for at least three direct-ready peers. + +### 4. Observability is still heterogeneous + +Live heartbeat coverage is inconsistent: + +- `test-*`, `ifcm`, `usa-los-1` emit rich `c17z20` heartbeat metadata with + endpoint, peer recovery, and registry sections. +- `home-*` currently do not expose the same full sections in their latest + heartbeat rows. + +This means operator visibility is uneven and the documentation must not imply +uniform live introspection across every node today. + +## What is true right now + +1. The fleet is converged on one live node-agent version. +2. QUIC/UDP is the actual node-to-node transport. +3. Compatibility `19191/tcp` is still required for recovery overlap. +4. Signed registry gossip is not yet the sole active discovery/control source. +5. The "at least 3 direct-ready peers per node" resilience target is not yet + met for all externally significant nodes. + +## Operational rule until the next audit + +Do not remove the compatibility `19191/tcp` recovery overlap while any of the +following remain true: + +- any live node still reports a `control_plane_url` on the `19191` contract; +- any live node has `fabric_registry_runtime_report.status != active`; +- any externally significant node has fewer than 3 direct-ready peers; +- any node can only recover through legacy `Control API/downloads` overlap. + +## Required next work + +### A. Finish signed registry activation + +Each node must be able to resolve active records for at least: + +- `control-api` +- `update-store` +- `update-cache` + +without falling back to the `19191` compatibility contract. + +### B. Promote full direct endpoint dissemination + +All nodes with public reachability must advertise every valid public direct QUIC +endpoint, and nodes must retain enough live peer memory to reconnect without +operator intervention. + +### C. Enforce the direct-ready floor as a live alert + +If a node has fewer than 3 direct-ready peers, this must remain a real +operational alert even when relay-ready peers exist. + +### D. Normalize heartbeat observability + +Every production node must emit the same minimum audit surface: + +- endpoint candidates +- peer recovery counts +- registry runtime state +- update runtime state + +without mixing rich and reduced heartbeat schemas across the fleet. + +### E. Replace the naive peer-count rule + +The live fleet shows that a plain "3 links per node" rule is not a sufficient +resilience model. + +The current corrective design is documented in +[FABRIC_AREA_AND_PEER_STABILITY_MODEL.md](\\nas\\MST\\codex\\rdp-proxy\\docs\\architecture\\FABRIC_AREA_AND_PEER_STABILITY_MODEL.md) +and introduces: + +- `area` as a failure-domain label; +- direct-ready vs relay-ready separation; +- cross-area diversity requirements; +- full-directory retention for small fleets. diff --git a/docs/architecture/FABRIC_NODE_SURVIVAL_AND_RECOVERY_POLICY.md b/docs/architecture/FABRIC_NODE_SURVIVAL_AND_RECOVERY_POLICY.md new file mode 100644 index 0000000..2a14152 --- /dev/null +++ b/docs/architecture/FABRIC_NODE_SURVIVAL_AND_RECOVERY_POLICY.md @@ -0,0 +1,427 @@ +# Fabric Node Survival And Recovery Policy + +Status: active architecture policy. + +This document defines the non-negotiable survival, compatibility, and recovery +rules for Secure Access Fabric nodes. It exists because losing a node is not an +acceptable operating model once the fabric grows beyond a small manually +maintained fleet. + +Reference incident: + +- `ifcm-rufms-s-mo1cr` is the canonical recovery case. +- The node is behind NAT. +- There is no direct administrative access to the Windows host. +- The node must remain recoverable through the fabric/update/recovery plane + without relying on manual host login. + +The latest live recovery evidence for this case is documented in +[FABRIC_LIVE_AUDIT_2026-05-18.md](\\nas\\MST\\codex\\rdp-proxy\\docs\\architecture\\FABRIC_LIVE_AUDIT_2026-05-18.md). + +This policy applies to Linux, Windows, Android, containerized nodes, and future + node types. + +## 1. Core Decision + +The fabric must be able to lose: + +- old API endpoints; +- old artifact URLs; +- previous public IP addresses; +- previous NAT mappings; +- previous relay nodes; +- previous route-authority replicas; +- previous update-cache replicas; +- old service locations; +- operator access to the host OS; +- the current physical location of a workload; +- part of the cluster. + +And still keep the node recoverable. + +Manual repair is allowed as an emergency tool. It must not be the default +survival strategy. + +## 2. Non-Negotiable Invariants + +### 2.1 Node Identity Must Survive + +A recoverable node must preserve: + +- `node_id`; +- node keypair or key reference; +- pinned cluster authority / quorum descriptor; +- last accepted signed registry records; +- last accepted bootstrap seed set; +- last known good update policy; +- last known good workload desired state; +- rollback metadata; +- recovery audit trail. + +Reinstall or repair must prefer preserving local state. Identity reset is a +high-risk operator action, not the default repair path. + +### 2.2 Compatibility Must Stay Until Recovery Is Complete + +Any change to the fabric must keep older nodes recoverable until one of these +is true: + +1. every node has confirmed the new contract; or +2. the missing nodes were manually retired, revoked, or explicitly accepted as + lost. + +This applies to: + +- update plan formats; +- signed registry schemas; +- artifact install types; +- authority signature envelopes; +- bootstrap config formats; +- recovery seed formats; +- host-agent / updater runtime contracts; +- control endpoints needed only for migration. + +The rule is strict: do not delete the old recovery format while nodes that may +still need it remain unrecovered. + +### 2.3 QUIC-Only Transport Does Not Mean Single Bootstrap Location + +Node-to-node runtime transport remains QUIC over UDP only. + +That does not permit: + +- one bootstrap address; +- one update mirror; +- one registry carrier; +- one ingress node; +- one relay; +- one control replica. + +QUIC is the transport. Survivability requires many signed ways to discover the +current valid QUIC endpoints. + +### 2.4 No Single Service May Own Recovery + +Recovery must not depend on one: + +- backend URL; +- DNS name; +- HTTP ingress; +- update repository host; +- relay node; +- cluster admin node. + +Any of those may disappear while the node is still healthy enough to recover. + +## 3. Required Recovery Layers + +### 3.1 Embedded Bootstrap Seed Set + +Each installable node package must contain a bounded bootstrap seed set: + +- multiple seed nodes; +- public and private candidates where appropriate; +- QUIC endpoint candidates only; +- signed bootstrap metadata; +- expiry / epoch rules; +- optional organization / cluster scope constraints. + +The bootstrap seed set is only the first door, not cluster truth. + +### 3.2 Signed Registry Gossip + +After bootstrap, a node must learn current service locations through signed +fabric registry records that can be carried by any reachable peer. + +Required properties: + +- multiple records per service; +- quorum or otherwise policy-approved signatures; +- monotonic epoch/generation; +- expiry and freshness checks; +- live probe before promotion; +- ability to accept newer records from a reachable neighbor even when old + origins are gone. + +### 3.3 Outbound-Only Recovery Attachment + +A node behind NAT or in passive mode must be recoverable through an outbound +attachment. + +Required behaviors: + +- the node can maintain at least one long-lived outbound QUIC control channel; +- that channel survives IP changes by reconnecting through any remaining seed or + signed registry endpoint; +- the node may receive updated registry truth, update triggers, workload + changes, and recovery instructions over that channel; +- the fabric must not require inbound TCP/UDP reachability to repair the node. + +### 3.4 Local Recovery Agent Boundary + +The node must have a minimal recovery-capable local agent boundary that is +separate from ordinary service workloads. + +It must be able to: + +- validate signed update plans; +- download artifacts from multiple mirrors; +- stage replacement binaries; +- restart node-agent or host-agent tasks; +- rollback to previous binaries; +- swap to new signed registry/bootstrap records; +- emit recovery status when transport returns. + +If node workloads fail, this local recovery boundary must still exist. + +### 3.5 Multi-Source Artifact Delivery + +Artifacts must be retrievable from more than one source: + +- local cached file; +- cluster update-cache; +- organization-local cache if policy allows; +- public or internet-reachable mirror; +- neighbor-assisted relay transfer over the fabric. + +A node must not become unrecoverable because one artifact hostname or one +download service disappeared. + +### 3.6 Trigger And Subscription Plane + +Polling alone is not enough for very large fleets. + +Required model: + +- nodes may still perform slow fallback polling; +- primary update notification uses subscription/signal delivery; +- update-cache or registry service can repeatedly signal pending updates until + acknowledged; +- signals are idempotent; +- signals do not require the old control endpoint to remain alive. + +## 4. Update Safety Rules + +### 4.1 Upgrade Contracts + +Every release that changes recovery-critical contracts must explicitly declare: + +- minimum supported old version; +- maximum tolerated skew; +- whether migration is rolling-safe; +- whether the node must first update host-agent or node-agent; +- rollback compatibility; +- whether old bootstrap/registry envelopes remain accepted. + +### 4.2 Two-Key Rule For Breaking Changes + +Do not simultaneously break: + +- discovery of where to get the update; and +- ability to understand the update once found. + +At least one of those must remain compatible until fleet convergence or +explicit retirement. + +### 4.3 Old Artifact Retention + +Recovery-critical artifact versions must remain available until: + +- all nodes have moved past them; or +- the remaining nodes are revoked/retired and recorded as intentionally lost. + +Do not garbage-collect the last working host-agent or node-agent build for an +unrecovered population. + +### 4.4 Install Type Continuity + +If historical nodes request different install types for the same product +(`windows_binary`, `windows_service`, `native`, `linux_binary`, etc.), recovery +planning must keep compatibility aliases until the fleet converges. + +The fabric must not strand nodes on an install-type naming mismatch. + +### 4.5 Legacy Recovery Contract Drift Must Be Treated As A Blocking Risk + +A stale node may report: + +- a compatible recovery artifact exists under the current registry; but +- the last local updater/host-agent status still says `no_matching_artifact` or + an equivalent legacy contract failure. + +This means the node is not only waiting for a heartbeat. It is running an older +recovery planner contract and may still depend on: + +- historical install-type aliases; +- older artifact matching semantics; +- older update-plan interpretation rules; +- overlap in signed registry / bootstrap envelopes. + +This condition must be classified as `legacy recovery contract drift` and must +block compatibility removal the same way an artifact gap does. + +Operationally this also means: + +- the node requires a `recovery bridge`; +- the cluster enters `bridge hold active` for compatibility-removal decisions; +- `bridge hold` remains active until the node reports a recovery-compatible + status on the current contract or the operator explicitly retires the node; +- when a compatible artifact and target mapping already exist, the node should + be classified as `bridge replay ready`, meaning the system can replay the + legacy-compatible update plan as soon as the node regains an outbound control + cycle; +- operator tooling should expose a canonical `bridge replay plan` per node so + recovery replay uses the same signed update-plan logic as normal updates; +- compatibility aliases / overlap must remain enabled for that node population; +- dashboards and rollout guards must show this separately from ordinary + `waiting recovery heartbeat`. + +Canonical example: + +- `ifcm-rufms-s-mo1cr` is stale; +- the current backend can match a Windows-compatible host-agent artifact; +- the last host-agent report still says `no_matching_artifact`; +- therefore the node must be treated as a legacy recovery-contract blocker, not + merely as a delayed heartbeat. + +## 5. Service And Location Mobility Rules + +Moving a service must not strand nodes that only know the old location. + +Required pattern: + +1. publish new signed registry records; +2. keep old records valid during overlap; +3. allow any reachable peer to relay the new records; +4. live-probe and promote the new endpoints; +5. only then retire the old location; +6. keep enough overlap for slow or partitioned nodes to catch up. + +This applies to: + +- control-api replicas; +- update-cache/update-store replicas; +- web/admin ingress replicas; +- relay/rendezvous nodes; +- service-channel endpoints. + +## 6. Failure Classes The Fabric Must Tolerate + +The design must explicitly handle all of these: + +- node behind NAT with only outbound connectivity; +- several nodes behind one NAT/local segment; +- node changes public IP; +- node changes private IP; +- old DNS/URL becomes dead; +- artifact mirror disappears; +- control ingress disappears; +- relay disappears; +- update install fails halfway; +- binary staged but restart fails; +- old task/service name changes; +- local disk is nearly full; +- time skew causes signature freshness risk; +- authority rotates; +- route authority replica disappears; +- state directory survives but binary is broken; +- binary survives but state directory is partly stale; +- node reboots during update; +- only one peer still knows the new registry truth; +- node is partitioned for a long time and rejoins later; +- platform removes legacy support too early; +- operator has no shell/RDP/WinRM/SSH access to the host. + +## 7. Required Local State And Journaling + +The node local state store must retain at least: + +- active and previous signed registry records; +- active and previous bootstrap seeds; +- last successful update plan per product; +- last applied artifact hash/version; +- last rollback candidate; +- last successful service endpoints used for update/control; +- pending trigger generation; +- recovery attempts with timestamps and reasons; +- last known good runtime command line / task/unit identity; +- last known workload desired states. + +Writes must be atomic. A power loss must not leave the node with zero valid +state. + +## 8. Observability And Fleet Safety Rules + +The control plane must make invisible-recovery risk explicit. + +It must surface: + +- nodes with stale heartbeat but recent updater activity; +- nodes with no working compatible recovery artifact; +- nodes whose pinned registry/bootstrap epoch is too old; +- nodes whose only known artifact URL is dead; +- nodes whose desired state requires a contract they cannot parse; +- nodes whose local agent version is below the minimum recovery floor; +- nodes whose last successful contact depended on a single service replica. + +Cluster-wide changes that would strand such nodes must be blocked or require an +explicit recovery-admin override. + +## 9. Release And Migration Checklist + +Before deleting old code, old formats, or old endpoints, verify all of these: + +1. every active node has confirmed a compatible version; or the remaining nodes + are explicitly marked for manual retirement/recovery; +2. host-agent and node-agent recovery paths both have matching artifacts; +3. bootstrap/registry overlap exists for the migration window; +4. at least two independent artifact sources remain reachable; +5. signed registry gossip can carry the new locations without the old API + hostname; +6. rollback artifacts are still available; +7. install type aliases remain for historical agents where needed; +8. NAT/passive/outbound-only nodes were explicitly tested; +9. stale-node risk report is empty or consciously accepted by recovery-admin; +10. removal of legacy support is documented with the exact cutoff conditions. + +## 10. `ifcm-rufms-s-mo1cr` Rule + +`ifcm-rufms-s-mo1cr` is the standing reference case for future work. + +For this node class, the platform must assume: + +- the host is behind NAT; +- the node may only keep outbound channels; +- no direct Windows administrative access exists; +- old discovery endpoints may disappear; +- only the fabric/update/recovery plane can save the node. + +Any future transport, update, authority, bootstrap, registry, or workload +change must be reviewed against this question: + +> If `ifcm-rufms-s-mo1cr` is still on the older contract and we cannot log in to +> the host, can the fabric still recover it? + +If the answer is no, the change is incomplete. + +## 11. Immediate Follow-Through + +The system should keep implementing these concrete items: + +- separate documented recovery-plane tests for Windows NAT nodes; +- signed registry retention and overlap checks before endpoint migration; +- compatibility alias coverage for historical install types; +- artifact availability health over all mirrors; +- stale-node risk dashboard/report before legacy removal; +- node-local journaling for last good registry/update state; +- neighbor-assisted artifact relay path; +- explicit recovery simulation for outbound-only nodes with dead old endpoints. + +## 12. Decision + +The fabric must treat node survival as a first-class architecture contract. + +A node is not considered safe merely because the happy path works. It is safe +only when it can survive protocol migration, endpoint relocation, partial +cluster loss, artifact source loss, and lack of manual host access without +being abandoned. diff --git a/docs/architecture/FABRIC_SERVICE_CHANNEL_RUNTIME.md b/docs/architecture/FABRIC_SERVICE_CHANNEL_RUNTIME.md index 1b74c14..2db69c0 100644 --- a/docs/architecture/FABRIC_SERVICE_CHANNEL_RUNTIME.md +++ b/docs/architecture/FABRIC_SERVICE_CHANNEL_RUNTIME.md @@ -256,9 +256,11 @@ The first backend contract slice is implemented: observations, and degraded backend relay usage. These incidents keep backend relay visible as degraded compatibility behavior rather than hidden steady state. -- Node-agent access telemetry distinguishes backend relay actually used from - backend relay blocked by signed data-plane policy. Blocked fallback reports - include `backend_fallback_blocked` and the last violation status/reason, and +- Node-agent access telemetry distinguishes degraded compatibility requested + from degraded compatibility blocked by signed data-plane policy. Blocked + compatibility reports include `degraded_compatibility_blocked` and the last + violation status/reason, while preserving the original raw violation code in + a separate field for historical correlation, and backend projects them to access telemetry plus `data_plane_contract` incidents. - Backend correlates access-report send failures with active service-channel @@ -421,8 +423,8 @@ The first backend contract slice is implemented: keeps failing outside manual retry cooldown creates a bounded rebuild request. If an unfenced alternate is available, Control Plane marks the rebuild `applied` and selects that route generation; if no alternate exists, - it records `pending_degraded_fallback` and keeps backend relay as the - explicit degraded path until a new route appears. The compatibility release + it records `pending_degraded_route_state` and keeps the channel in explicit + degraded route state until a new route appears. The compatibility release `0.2.175` keeps node/host-agent signed-config models aligned with these new fields. - C18U moves rebuild metadata into node-agent runtime behavior. Node-agent @@ -437,10 +439,10 @@ The first backend contract slice is implemented: - C18V adds route-manager transition telemetry and churn coverage. Node-agent `0.2.177` reports `route_manager_transition` alongside the current manager snapshot, including previous/current generation, status, decision count, - withdrawn route count, restored route count, pending-degraded fallback count, + withdrawn route count, restored route count, pending degraded route-state count, rebuild applied count, and any cached selected route cleared because Control Plane withdrew it. Coverage verifies three service-neutral lifecycle cases: - applied rebuild replacement, pending degraded fallback when no alternate is + applied rebuild replacement, pending degraded route state when no alternate is available, and rollback/restoration when a fresh config removes the rebuild decision. - C18W adds a live docker-test verification loop for that telemetry. The smoke @@ -973,8 +975,8 @@ The first backend contract slice is implemented: in C18Z45; rebuild snapshot maintenance health with overdue/runtime-evidence visibility landed in C18Z46; node-agent signed service-channel lease enforcement when cluster authority is pinned landed in C18Z47; backend - introspection fallback for unsigned compatibility clients landed in C18Z48; - accepted-by telemetry for signed/introspection/legacy ingress landed in + introspection fallback for token-authorized compatibility clients landed in C18Z48; + accepted-by telemetry for signed/introspection/token-authorized ingress landed in C18Z49; durable lease introspection across backend restarts landed in C18Z50; bounded durable lease cleanup and admin visibility landed in C18Z51; durable accepted-by access telemetry aggregation with heartbeat fallback and admin @@ -983,9 +985,9 @@ The first backend contract slice is implemented: visibility landed in C18Z53; C18Z54 smoke proves the same diagnostics on a normal non-fallback primary route with healthy rolling route-quality feedback; C18Z55 smoke proves degraded/fenced normal-route feedback is shown separately - from explicit backend fallback; C18Z56 adds active-channel remediation + from explicit degraded compatibility requests; C18Z56 adds active-channel remediation diagnostics (`none`, `rebuild_route`, `prefer_alternate_route`, - `use_backend_fallback`) to make the next runtime action explicit, and its + `hold_degraded_route_state`) to make the next runtime action explicit, and its alternate-route branch is live-smoke-proven with backend fallback kept off. C18Z57 adds the bounded machine-readable `remediation_command` contract to active access telemetry rows so route-manager can consume a short-lived @@ -1058,7 +1060,7 @@ The first backend contract slice is implemented: `rebuild_request_recorded` or `rebuild_request_rejected` for the active channel. C18Z76 adds node-side acknowledgement for the allowed `rebuild_route` branch: node-agent consumes the command as a route-manager - `pending_degraded_fallback` decision with source + `pending_degraded_route_state` decision with source `service_channel_remediation_command`, while guarded commands remain ignored. Backend access telemetry correlates that heartbeat evidence with the durable ledger and reports `rebuild_request_recorded_node_pending`. C18Z77 resolves @@ -1089,7 +1091,7 @@ The first backend contract slice is implemented: reselecting the degraded replacement or adding fallback/failure/drop deltas. C18Z82 proves the no-safe-recovery branch: if that replacement is also fenced and no safe recovery route exists, synthetic config reports - `service_channel_feedback_no_alternate` / `pending_degraded_fallback` with + `service_channel_feedback_no_alternate` / `pending_degraded_route_state` with `no_unfenced_alternate_route` instead of silently keeping a bad route. C18Z83 projects that route-manager decision into active access telemetry and web-admin active-channel diagnostics, including decision source, route id, @@ -1124,7 +1126,8 @@ The first backend contract slice is implemented: `data_plane` is present in the lease, authority payload, introspection response, and lease-maintenance/admin list. It declares backend API as control-plane transport, fabric service channel/fabric route as working - data/steady-state transport, backend relay as degraded fallback only, and + data/steady-state transport, degraded compatibility relay as an explicit + compatibility state only, and service-neutral protocol-agnostic isolated logical flows as the runtime contract for VPN, Remote Workspace, files, video, and future services. C18Z91 makes node-agent consume the signed/introspected data-plane contract, apply @@ -1187,12 +1190,13 @@ channel class, selected entry node, allowed flow isolation, and data-plane contract on `remote-workspaces/{resource_id}/streams/{channel_class}`. Empty probe requests return `202` with a remote-workspace ingress probe contract and access telemetry; real RDP frame forwarding remains deliberately -`not_implemented` until the service adapter work begins. +`validated_only` for empty probes until the service adapter work begins. C19E adds a narrow frame-batch probe on that boundary. The adapter contract advertises `rap.remote_workspace_frame_batch.v1`, and entry-node accepts non-empty payloads only when they are JSON probe batches with `probe_only=true`, valid remote-workspace logical channels, valid directions, and bounded payload -metadata. Accepted probes return `payload_flow=validated_probe_only`; production +metadata. Accepted frame probes return `payload_flow=validated_probe_only`, while +empty/control probes return `payload_flow=validated_only`; production frame forwarding is still not enabled. C19F connects that validated probe to a node-agent local adapter sink. The in-memory `node_agent_rdp_worker_contract_probe` sink accepts only validated diff --git a/docs/architecture/MESH_ROUTING_RUNTIME_IMPLEMENTATION_PLAN.md b/docs/architecture/MESH_ROUTING_RUNTIME_IMPLEMENTATION_PLAN.md index f6a33ea..216d686 100644 --- a/docs/architecture/MESH_ROUTING_RUNTIME_IMPLEMENTATION_PLAN.md +++ b/docs/architecture/MESH_ROUTING_RUNTIME_IMPLEMENTATION_PLAN.md @@ -3,7 +3,7 @@ Status: Stage C17 planning completed. Stage C17A synthetic mesh runtime skeleton, Stage C17B route health/failover probes, Stage C17C relay semantic hardening, Stage C17D non-production test-service path experiment, Stage C17E -live node-to-node synthetic HTTP transport skeleton, Stage C17F scoped +historical live node-to-node synthetic HTTP transport skeleton, Stage C17F scoped synthetic route config boundary, Stage C17G Control Plane scoped synthetic config read boundary, Stage C17H deployed multi-agent synthetic config smoke, Stage C17I production forwarding gate, Stage C17J production envelope @@ -44,8 +44,9 @@ invalidation. C17C added synthetic relay validation, per-channel bounded queues, QoS dequeue order, telemetry-only drop/backpressure, and reliable fabric/control rejection behavior. C17D added one bounded `synthetic.echo` test-service path over direct, single-relay, and forced fallback routes. C17E -added real HTTP peer transport and a disabled-by-default node-agent synthetic -endpoint/smoke harness for direct and single-relay synthetic traffic. C17F +added one historical real-HTTP peer transport experiment and a +disabled-by-default node-agent synthetic endpoint/smoke harness for direct and +single-relay synthetic traffic only. C17F added scoped synthetic peer/route config loading and synthetic route-health link observation reporting. C17G added the Control Plane read boundary for node-scoped synthetic mesh config. C17H proved that boundary in a deployed @@ -596,10 +597,12 @@ C17H implemented a deployed multi-agent synthetic config smoke on VPN/IP tunnel work remains a separate C18 track and must not be mixed into C17 mesh runtime work. -## 15.4 C17E Result +## 15.4 C17E Historical Result -C17E implemented live node-to-node synthetic HTTP transport while preserving -the production forwarding kill-switch: +C17E implemented a historical live node-to-node synthetic HTTP transport +experiment while preserving the production forwarding kill-switch. This result +is retained only as test-history context; it is not the active transport +direction for the fabric runtime: - `HTTPPeerTransport` maps explicit peer node IDs to synthetic HTTP endpoint URLs. @@ -613,6 +616,13 @@ the production forwarding kill-switch: - `/mesh/v1/forward` remains disabled. - no production service traffic is authorized. +Current direction: + +- active fabric runtime transport is QUIC-only +- synthetic HTTP motion is historical test-only context +- production forwarding/runtime acceptance must use QUIC route execution rather + than HTTP peer transport + Verification: ```powershell @@ -888,9 +898,11 @@ runtime. Stage C17A implements the first narrow runtime skeleton for synthetic Fabric messages only. Stage C17B adds route health/failover observations using synthetic Fabric messages only. Stage C17C adds relay semantic hardening for synthetic channel classes only. Stage C17D adds one bounded non-production -`synthetic.echo` service-path experiment only. Stage C17E proves live -node-to-node synthetic HTTP transport using real local endpoints only. Stage -C17F proves scoped synthetic config loading and route-health reporting only. +`synthetic.echo` service-path experiment only. Stage C17E proves one +historical synthetic HTTP carrier experiment using real local endpoints only; +it is test-only and not representative of the active QUIC fabric runtime. +Stage C17F proves scoped synthetic config loading and route-health reporting +only. Stage C17G proves Control Plane scoped synthetic config read/consume only. Stage C17H proves deployed multi-agent Control Plane synthetic config consumption and synthetic route-health reporting on `docker-test` only. diff --git a/docs/architecture/PRODUCTION_DIRECT_WORKER_WSS_TRUST.md b/docs/architecture/PRODUCTION_DIRECT_WORKER_WSS_TRUST.md index c643396..c6783c4 100644 --- a/docs/architecture/PRODUCTION_DIRECT_WORKER_WSS_TRUST.md +++ b/docs/architecture/PRODUCTION_DIRECT_WORKER_WSS_TRUST.md @@ -1,5 +1,12 @@ # Production Direct Worker WSS Trust +Archived status: this document describes an older direct-worker WSS trust +track. It is not the current runtime transport source of truth. For the active +fabric transport model, use +`docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md`, +`docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md`, and +`docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md`. + Status: P3.4 design/prep complete. This document defines the production trust model for direct worker WSS. It is a diff --git a/docs/architecture/RDP_ADAPTER_RUNTIME.md b/docs/architecture/RDP_ADAPTER_RUNTIME.md index 899e920..974ab40 100644 --- a/docs/architecture/RDP_ADAPTER_RUNTIME.md +++ b/docs/architecture/RDP_ADAPTER_RUNTIME.md @@ -1,5 +1,13 @@ # RDP Adapter Runtime +Paused/archival note: this document remains useful for RDP adapter internals, +but it is not the current source of truth for transport/runtime architecture. +Fabric transport is now QUIC-only between nodes. For active transport, +recovery, and routing behavior, see +`docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md`, +`docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md`, and +`docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md`. + Status: active implementation plan for the new C++ RDP Adapter internals. Current implementation status: diff --git a/docs/architecture/RDP_FILE_DOWNLOAD_STAGE_5_2.md b/docs/architecture/RDP_FILE_DOWNLOAD_STAGE_5_2.md index d00d158..370fafb 100644 --- a/docs/architecture/RDP_FILE_DOWNLOAD_STAGE_5_2.md +++ b/docs/architecture/RDP_FILE_DOWNLOAD_STAGE_5_2.md @@ -1,5 +1,12 @@ # RDP Stage 5.2 Design Pass - Server-To-Client File Download +Archived status: this document belongs to the earlier direct-worker/back-gateway +RDP track and is not the current source of truth for fabric transport +architecture. The active inter-node transport model is QUIC-only; see +`docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md`, +`docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md`, and +`docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md`. + Status: design-complete proposal, no runtime implementation in this step. Date: 2026-04-26 diff --git a/docs/architecture/RDP_SERVICE_CPP_PERFORMANCE_TARGET.md b/docs/architecture/RDP_SERVICE_CPP_PERFORMANCE_TARGET.md index 36b7819..11c59a2 100644 --- a/docs/architecture/RDP_SERVICE_CPP_PERFORMANCE_TARGET.md +++ b/docs/architecture/RDP_SERVICE_CPP_PERFORMANCE_TARGET.md @@ -1,5 +1,13 @@ # RDP Service C++ Performance Target +Paused/archival note: this document is an RDP performance track record, not the +current source of truth for node-to-node transport. Fabric transport is now +QUIC-only between nodes; use +`docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md`, +`docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md`, and +`docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md` for the active transport +model. + ## Status This is the paused RDP service performance direction. The implementation name is `RDP Adapter`: a concrete `Service Adapter` that translates Microsoft RDP into the platform session/data-plane protocol. The common adapter contract is defined in `docs/architecture/SERVICE_ADAPTER_PROTOCOL.md`; the RDP-specific runtime plan is defined in `docs/architecture/RDP_ADAPTER_RUNTIME.md`. diff --git a/docs/architecture/RDP_SERVICE_CSHARP_TARGET.md b/docs/architecture/RDP_SERVICE_CSHARP_TARGET.md index 1fc8abe..5f8a99d 100644 --- a/docs/architecture/RDP_SERVICE_CSHARP_TARGET.md +++ b/docs/architecture/RDP_SERVICE_CSHARP_TARGET.md @@ -1,5 +1,13 @@ # RDP Service C# Target Architecture +Archived scope note: this document is retained as historical RDP runtime +research and is not the current source of truth for node-to-node transport. +Fabric transport is now QUIC-only between nodes; use +`docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md`, +`docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md`, and +`docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md` for the active transport +model. + ## Status Superseded. diff --git a/docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md b/docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md index 2d53f95..a38e88d 100644 --- a/docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md +++ b/docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md @@ -8,6 +8,12 @@ The current proven RDP lifecycle remains a preserved implementation baseline. RDP work is currently paused by product decision. The active architecture focus is the lower Fabric Core / cluster / node foundation. +Transport clarification: historical references in this document to direct +worker WSS or backend gateway fallback describe the earlier RDP service proof +path and migration context. They must not be read as the current inter-node +transport contract. The active fabric node-to-node runtime transport is +QUIC-only. + ## 1. Project Vision The project is a Secure Access Fabric: a distributed, multi-tenant platform for secure access to private resources across sites, networks, and organizations. @@ -1702,7 +1708,7 @@ Channels must have independent priority, reliability, and backpressure behavior. The current RDP MVP proves lifecycle and basic viewer behavior. It is not the target production performance model. -Target RDP realtime model: +Target RDP realtime model for the paused historical RDP service track: - client connects to direct/relay data plane, not backend frame relay - input/control channels are separate from render/video @@ -2459,7 +2465,11 @@ This is an incremental migration plan. It must not be executed as a big-bang rew ### Current Fallback -Keep the current backend WebSocket gateway as fallback while the production data plane is introduced. +Historical migration note: the older RDP MVP kept the backend WebSocket +gateway as a temporary fallback while an earlier production data-plane design +was being introduced. This is not the active fabric transport plan. Current +fabric node-to-node runtime transport is QUIC-only, and old compatibility paths +are being removed rather than extended. Current RDP MVP remains the preserved service-adapter baseline, but it is not the active implementation focus while Fabric Core stages are underway. @@ -2543,9 +2553,14 @@ These stages must be introduced only through explicit, narrow implementation prompts. RDP/VNC/SSH/VPN/video/file services remain above the Fabric Core and must not define the lower fabric foundation. -### Stage DP-1: Direct Worker WSS +### Historical Stage DP-1: Direct Worker WSS -Introduce a short-lived authorized direct WSS path from client to worker or worker-local live endpoint. +This stage records an earlier RDP service migration concept. It is paused and +retained for historical context only. It must not be read as the active fabric +transport roadmap. + +Introduce a short-lived authorized direct WSS path from client to worker or +worker-local live endpoint. Goals: @@ -2554,7 +2569,7 @@ Goals: - keep session broker lifecycle unchanged - keep fallback gateway available -### Stage DP-2: Binary Frames +### Historical Stage DP-2: Binary Frames Replace base64 JSON frame payloads with binary frame messages. @@ -2565,7 +2580,7 @@ Goals: - reduce JSON/base64 overhead - preserve latest-frame-only behavior -### Stage DP-3: Adaptive Quality +### Historical Stage DP-3: Adaptive Quality Implement adaptive RDP quality profiles. @@ -2577,9 +2592,10 @@ Goals: - bandwidth and latency feedback - bounded frame queues -### Stage DP-4: Relay Nodes +### Historical Stage DP-4: Relay Nodes -Introduce `entry-node` and `relay-node` roles for data-plane routing. +Introduce `entry-node` and `relay-node` roles for the earlier service-specific +data-plane routing model. Goals: diff --git a/docs/architecture/SECURITY_SECRETS_READINESS.md b/docs/architecture/SECURITY_SECRETS_READINESS.md index 2534950..8fbc41e 100644 --- a/docs/architecture/SECURITY_SECRETS_READINESS.md +++ b/docs/architecture/SECURITY_SECRETS_READINESS.md @@ -1,20 +1,28 @@ # Security And Secrets Readiness -Status: P3.3 test-stand smoke complete for encrypted resource secrets, -assignment-time resolution, and production fallback behavior with smoke-only -direct worker WSS trust. +Archived scope note: this document records an earlier RDP/direct-worker trust +and secret-handling stage. It is not the current source of truth for fabric +transport architecture. The active inter-node transport model is QUIC-only; see +`docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md`, +`docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md`, and +`docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md`. + +Status: P3.3 historical test-stand smoke complete for encrypted resource +secrets, assignment-time resolution, and legacy RDP baseline behavior with +smoke-only direct-worker trust. This document defines the next security hardening layer around the accepted RDP MVP baseline. It does not implement mesh, VPN, server-to-client download, new protocol adapters, or another RDP rendering mode. -## Current Accepted Baseline +## Current Accepted Historical RDP Baseline - RDP worker baseline: `rap-rdp-worker:rdp-p1-region-order2` - Backend control plane remains source of truth. - Redis remains live coordination/routing only. -- Direct worker WSS is preferred for realtime RDP. -- Backend gateway remains fallback/debug. +- Historical direct-worker WSS was the preferred realtime RDP path in this + stage. +- Historical backend gateway remained a fallback/debug path for this stage. - Text clipboard is policy-gated and accepted. - Client-to-server file upload and restricted `RAP_Transfers` visibility are accepted. @@ -124,22 +132,24 @@ Already accepted: - worker rejects wrong worker, wrong attachment, wrong organization, wrong resource, over-broad channels, failed/terminated sessions, and jti replay -Production still needs: +Production still needed for that stage: -- deployed certificate chain for direct worker WSS on production nodes -- pinned or platform-issued worker certificates in live production config +- deployed certificate chain for the historical direct-worker WSS path on + production nodes +- pinned or platform-issued worker certificates in live production config for + that historical path - no smoke-only TLS bypass in production clients - rotation process for data-plane signing keys - audit for failed token validation/bind attempts -P3.2 guard exists: +P3.2 historical guard exists: -- backend distinguishes `smoke_insecure`, `public_ca`, and `platform_ca` - direct worker WSS trust modes -- production backend omits smoke-only direct candidates -- Windows production client skips untrusted or smoke-only direct candidates +- backend distinguished `smoke_insecure`, `public_ca`, and `platform_ca` + direct-worker trust modes for the historical RDP path +- production backend omitted smoke-only direct candidates on that path +- Windows production client skipped untrusted or smoke-only direct candidates -P3.3 test-stand smoke exists: +P3.3 historical test-stand smoke exists: - `resource_secrets` migration is applied on `docker-test` - backend runs as `APP_ENV=production` with a test-only @@ -149,9 +159,9 @@ P3.3 test-stand smoke exists: - `resources.metadata`, `remote_sessions.metadata`, and `audit_events` were checked for plaintext username/password leakage - production backend with `DATA_PLANE_DIRECT_WORKER_TLS_TRUST_MODE=smoke_insecure` - returns backend gateway fallback only + returned the historical backend gateway debug path only - development/smoke backend with the same trust mode advertises the explicit - smoke-only direct worker WSS candidate + smoke-only historical direct-worker candidate - `RAP_Transfers` smoke passed on the secret-backed resource ## Required Regression Tests @@ -202,8 +212,8 @@ P3.1 implemented audit events for: assignment payload; a future resolver pull/token flow should reduce exposure in Redis control queues. - Worker still depends on plaintext assignment metadata for development smoke. -- Production direct worker WSS certificate issuance/rotation and platform CA - distribution are not complete. +- Production certificate issuance/rotation and platform CA distribution for the + historical direct-worker path are not complete. - The test-stand secret key is a host-local test file, not a production KMS or HSM-backed key. - Automated end-to-end policy denial coverage is still thin. diff --git a/docs/architecture/SERVICE_ADAPTER_PROTOCOL.md b/docs/architecture/SERVICE_ADAPTER_PROTOCOL.md index 35e99cc..4cba542 100644 --- a/docs/architecture/SERVICE_ADAPTER_PROTOCOL.md +++ b/docs/architecture/SERVICE_ADAPTER_PROTOCOL.md @@ -1,7 +1,21 @@ # Service Adapter Protocol +Scope note: this document remains the common adapter-model reference, but it is +not the current source of truth for transport/runtime topology between fabric +nodes. Fabric transport is now QUIC-only between nodes; for active transport, +routing, and recovery behavior see +`docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md`, +`docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md`, and +`docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md`. + Status: target contract and compile-safe foundation. This document defines the common adapter model for RDP, SSH, VNC, and future services. It does not replace the current backend control plane or current RDP runtime by itself. +Transport clarification: historical references in this document to direct +worker WSS, backend gateway fallback, or DP-1 channel shape belong to the +earlier RDP service baseline. They are not the active inter-node transport +contract. Current fabric node-to-node transport is QUIC-only; service adapters +consume fabric routes rather than define transport fallback behavior. + ## 1. Purpose The platform client must not implement third-party protocols directly. @@ -94,12 +108,16 @@ adapter runtime. - Service Adapter does not know UI implementation details. - Control Plane remains authoritative for session lifecycle and policy. - PostgreSQL remains source of truth; Redis remains live coordination only. -- Direct worker WSS and backend gateway fallback remain valid transports. +- Fabric transport remains QUIC-only between nodes; any historical direct + worker or backend fallback paths belong to paused service-specific baselines, + not to the active fabric transport contract. - Adapter runtime must not create sessions outside broker/assignment control. ## 4. Logical Channels -The session protocol is channel-oriented even when DP-1 uses one WSS connection. +The session protocol is channel-oriented regardless of the concrete carrier. A +historical DP-1 single-WSS shape may still appear in paused RDP notes, but it +is not the current fabric transport contract. | Channel | Direction | Reliability | Priority | Purpose | | --- | --- | --- | --- | --- | diff --git a/docs/architecture/VPN_IP_TUNNEL_SERVICE_TARGET.md b/docs/architecture/VPN_IP_TUNNEL_SERVICE_TARGET.md index 593b403..3d9622d 100644 --- a/docs/architecture/VPN_IP_TUNNEL_SERVICE_TARGET.md +++ b/docs/architecture/VPN_IP_TUNNEL_SERVICE_TARGET.md @@ -7,6 +7,11 @@ Secure Access Fabric. It does not implement VPN runtime, packet routing, TUN devices, mesh traffic, service workload execution, API changes, migrations, or RDP behavior changes. +Transport clarification: this document defines a service layer above Fabric +Core. It does not redefine node-to-node transport. Current fabric inter-node +transport is QUIC-only; VPN/IP tunnel runtime must request and use fabric +routes instead of introducing a separate packet transport contract. + ## Purpose VPN/IP tunnel is a service above the Fabric Core, not a node-local setting. diff --git a/docs/architecture/WEB_INGRESS_AND_ADMIN_UI_MODEL.md b/docs/architecture/WEB_INGRESS_AND_ADMIN_UI_MODEL.md index dc1265b..4dc2990 100644 --- a/docs/architecture/WEB_INGRESS_AND_ADMIN_UI_MODEL.md +++ b/docs/architecture/WEB_INGRESS_AND_ADMIN_UI_MODEL.md @@ -9,6 +9,15 @@ Secure Access Fabric. The fabric node-to-node transport remains QUIC-only. HTTP/HTTPS is allowed only as an external client-facing service edge. +Terminology rule: + +- `Fabric Transport` = QUIC/UDP node-to-node runtime layer. +- `Control API` = HTTP/HTTPS management surface for UI, automation, releases, + policy, audit, and status. + +The Control API may use HTTP/HTTPS, but it is not a fallback or alternate +carrier for fabric node-to-node runtime traffic. + ## Purpose The platform needs a clear distinction between: diff --git a/scripts/ops/test-docker-cluster-guard.sh b/scripts/ops/test-docker-cluster-guard.sh index 740e7b4..b6f3d78 100644 --- a/scripts/ops/test-docker-cluster-guard.sh +++ b/scripts/ops/test-docker-cluster-guard.sh @@ -115,9 +115,9 @@ for container in rap_test_postgres rap_test_redis rap_test_backend rap_web_admin done redis_guard -probe_http "downloads" "$BACKEND_URL/downloads/rap-android-rdp-vpn-build.json" +probe_http "downloads" "$BACKEND_URL/downloads/rap-android-vpn-build.json" probe_http "web_admin_root" "$BACKEND_URL/" -probe_http "diagnostics" "$PUBLIC_URL/api/v1/clusters/$CLUSTER_ID/vpn/client-diagnostics" +probe_http "backend_healthz" "http://127.0.0.1:18121/healthz" used_after="$(disk_used_percent)" status="ok" diff --git a/web-admin/deploy/html/assets/index-CiNvRobk.js b/web-admin/deploy/html/assets/index-CiNvRobk.js new file mode 100644 index 0000000..edf9c62 --- /dev/null +++ b/web-admin/deploy/html/assets/index-CiNvRobk.js @@ -0,0 +1,31 @@ +var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var S=Array.isArray;function C(){}var w={H:null,A:null,T:null,S:null},T=Object.prototype.hasOwnProperty;function ee(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function te(e,t){return ee(e.type,t,e.props)}function E(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function D(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var ne=/\/+/g;function re(e,t){return typeof e==`object`&&e&&e.key!=null?D(``+e.key):t.toString(36)}function ie(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(C,C):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function ae(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,ae(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+re(e,0):a,S(o)?(i=``,c!=null&&(i=c.replace(ne,`$&/`)+`/`),ae(o,r,i,``,function(e){return e})):o!=null&&(E(o)&&(o=te(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(ne,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(S(e))for(var u=0;u{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,S||(S=!0,E());else{var t=n(l);t!==null&&re(x,t.startTime-e)}}var S=!1,C=-1,w=5,T=-1;function ee(){return g?!0:!(e.unstable_now()-Tt&&ee());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&re(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?E():S=!1}}}var E;if(typeof y==`function`)E=function(){y(te)};else if(typeof MessageChannel<`u`){var D=new MessageChannel,ne=D.port2;D.port1.onmessage=te,E=function(){ne.postMessage(null)}}else E=function(){_(te,0)};function re(t,n){C=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(C),C=-1):h=!0,re(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,S||(S=!0,E()))),r},e.unstable_shouldYield=ee,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=f(),n=u(),r=m();function i(e){var t=`https://react.dev/errors/`+e;if(1ue||(e.current=le[ue],le[ue]=null,ue--)}function pe(e,t){ue++,le[ue]=e.current,e.current=t}var me=de(null),he=de(null),ge=de(null),_e=de(null);function ve(e,t){switch(pe(ge,t),pe(he,e),pe(me,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}fe(me),pe(me,e)}function A(){fe(me),fe(he),fe(ge)}function ye(e){e.memoizedState!==null&&pe(_e,e);var t=me.current,n=Hd(t,e.type);t!==n&&(pe(he,e),pe(me,n))}function be(e){he.current===e&&(fe(me),fe(he)),_e.current===e&&(fe(_e),Qf._currentValue=ce)}var xe,Se;function Ce(e){if(xe===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);xe=t&&t[1]||``,Se=-1)`:-1i||c[r]!==l[i]){var u=` +`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{we=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?Ce(n):``}function Ee(e,t){switch(e.tag){case 26:case 27:case 5:return Ce(e.type);case 16:return Ce(`Lazy`);case 13:return e.child!==t&&t!==null?Ce(`Suspense Fallback`):Ce(`Suspense`);case 19:return Ce(`SuspenseList`);case 0:case 15:return Te(e.type,!1);case 11:return Te(e.type.render,!1);case 1:return Te(e.type,!0);case 31:return Ce(`Activity`);default:return``}}function j(e){try{var t=``,n=null;do t+=Ee(e,n),n=e,e=e.return;while(e);return t}catch(e){return` +Error generating stack: `+e.message+` +`+e.stack}}var De=Object.prototype.hasOwnProperty,Oe=t.unstable_scheduleCallback,ke=t.unstable_cancelCallback,Ae=t.unstable_shouldYield,je=t.unstable_requestPaint,Me=t.unstable_now,Ne=t.unstable_getCurrentPriorityLevel,Pe=t.unstable_ImmediatePriority,Fe=t.unstable_UserBlockingPriority,Ie=t.unstable_NormalPriority,Le=t.unstable_LowPriority,M=t.unstable_IdlePriority,Re=t.log,ze=t.unstable_setDisableYieldValue,Be=null,Ve=null;function He(e){if(typeof Re==`function`&&ze(e),Ve&&typeof Ve.setStrictMode==`function`)try{Ve.setStrictMode(Be,e)}catch{}}var Ue=Math.clz32?Math.clz32:Ke,We=Math.log,Ge=Math.LN2;function Ke(e){return e>>>=0,e===0?32:31-(We(e)/Ge|0)|0}var qe=256,Je=262144,Ye=4194304;function Xe(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ze(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Xe(n))):i=Xe(o):i=Xe(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Xe(n))):i=Xe(o)):i=Xe(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Qe(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function N(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function $e(){var e=Ye;return Ye<<=1,!(Ye&62914560)&&(Ye=4194304),e}function et(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function tt(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function nt(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),dn=!1;if(un)try{var fn={};Object.defineProperty(fn,`passive`,{get:function(){dn=!0}}),window.addEventListener(`test`,fn,fn),window.removeEventListener(`test`,fn,fn)}catch{dn=!1}var pn=null,mn=null,hn=null;function gn(){if(hn)return hn;var e,t=mn,n=t.length,r,i=`value`in pn?pn.value:pn.textContent,a=i.length;for(e=0;e=Kn),Yn=` `,B=!1;function V(e,t){switch(e){case`keyup`:return Wn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Xn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Zn=!1;function Qn(e,t){switch(e){case`compositionend`:return Xn(t);case`keypress`:return t.which===32?(B=!0,Yn):null;case`textInput`:return e=t.data,e===Yn&&B?null:e;default:return null}}function $n(e,t){if(Zn)return e===`compositionend`||!Gn&&V(e,t)?(e=gn(),hn=mn=pn=null,Zn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=br(n)}}function Sr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Sr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Cr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Rt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=Rt(e.document)}return t}function wr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Tr=un&&`documentMode`in document&&11>=document.documentMode,Er=null,Dr=null,Or=null,kr=!1;function Ar(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;kr||Er==null||Er!==Rt(r)||(r=Er,`selectionStart`in r&&wr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Or&&yr(Or,r)||(Or=r,r=Td(Dr,`onSelect`),0>=o,i-=o,bi=1<<32-Ue(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),ki&&Si(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),ki&&Si(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return ki&&Si(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),ki&&Si(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===E&&ya(l)===r.type){n(e,r.sibling),c=a(r,o.props),Ea(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=si(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=oi(o.type,o.key,o.props,null,e.mode,c),Ea(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=ui(o,e.mode,c),c.return=e,e=c}return s(e);case E:return o=ya(o),b(e,r,o,c)}if(se(o))return h(e,r,o,c);if(ie(o)){if(l=ie(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Ta(o),c);if(o.$$typeof===C)return b(e,r,Xi(e,o),c);Da(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=ci(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{wa=0;var i=b(e,t,n,r);return Ca=null,i}catch(t){if(t===ma||t===ha)throw t;var a=W(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var ka=Oa(!0),Aa=Oa(!1),ja=!1;function Ma(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Na(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Pa(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Fa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Pl&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=ti(e),ei(e,null,n),t}return Zr(e,r,t,n),ti(e)}function Ia(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,it(e,n)}}function La(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var Ra=!1;function za(){if(Ra){var e=oa;if(e!==null)throw e}}function Ba(e,t,n,r){Ra=!1;var i=e.updateQueue;ja=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(Q&f)===f:(r&f)===f){f!==0&&f===aa&&(Ra=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(m=g.payload,typeof m==`function`){d=m.call(_,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=g.payload,f=typeof m==`function`?m.call(_,d,f):m,f==null)break a;d=h({},d,f);break a;case 2:ja=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Ul|=o,e.lanes=o,e.memoizedState=d}}function Va(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Ha(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=O.T,s={};O.T=s,Ds(e,!1,t,n);try{var c=i(),l=O.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Es(e,t,la(c,r),du(e)):Es(e,t,r,du(e))}catch(n){Es(e,t,{then:function(){},status:`rejected`,reason:n},du())}finally{k.p=a,o!==null&&s.types!==null&&(o.types=s.types),O.T=o}}function gs(){}function _s(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=vs(e).queue;hs(e,a,t,ce,n===null?gs:function(){return ys(e),n(r)})}function vs(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ce,baseState:ce,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Oo,lastRenderedState:ce},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Oo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function ys(e){var t=vs(e);t.next===null&&(t=e.alternate.memoizedState),Es(e,t.next.queue,{},du())}function bs(){return Yi(Qf)}function xs(){return Co().memoizedState}function Ss(){return Co().memoizedState}function Cs(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=du();e=Pa(n);var r=Fa(t,e,n);r!==null&&(pu(r,t,n),Ia(r,t,n)),t={cache:K()},e.payload=t;return}t=t.return}}function ws(e,t,n){var r=du();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Os(e)?ks(t,n):(n=Qr(e,t,n,r),n!==null&&(pu(n,e,r),As(n,t,r)))}function Ts(e,t,n){Es(e,t,n,du())}function Es(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Os(e))ks(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,vr(s,o))return Zr(e,t,i,0),Fl===null&&Xr(),!1}catch{}if(n=Qr(e,t,i,r),n!==null)return pu(n,e,r),As(n,t,r),!0}return!1}function Ds(e,t,n,r){if(r={lane:2,revertLane:ud(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Os(e)){if(t)throw Error(i(479))}else t=Qr(e,n,r,2),t!==null&&pu(t,e,2)}function Os(e){var t=e.alternate;return e===X||t!==null&&t===X}function ks(e,t){oo=ao=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function As(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,it(e,n)}}var js={readContext:Yi,use:Eo,useCallback:po,useContext:po,useEffect:po,useImperativeHandle:po,useLayoutEffect:po,useInsertionEffect:po,useMemo:po,useReducer:po,useRef:po,useState:po,useDebugValue:po,useDeferredValue:po,useTransition:po,useSyncExternalStore:po,useId:po,useHostTransitionStatus:po,useFormState:po,useActionState:po,useOptimistic:po,useMemoCache:po,useCacheRefresh:po};js.useEffectEvent=po;var Ms={readContext:Yi,use:Eo,useCallback:function(e,t){return So().memoizedState=[e,t===void 0?null:t],e},useContext:Yi,useEffect:ns,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),es(4194308,4,cs.bind(null,t,e),n)},useLayoutEffect:function(e,t){return es(4194308,4,e,t)},useInsertionEffect:function(e,t){es(4,2,e,t)},useMemo:function(e,t){var n=So();t=t===void 0?null:t;var r=e();if(so){He(!0);try{e()}finally{He(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=So();if(n!==void 0){var i=n(t);if(so){He(!0);try{n(t)}finally{He(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=ws.bind(null,X,e),[r.memoizedState,e]},useRef:function(e){var t=So();return e={current:e},t.memoizedState=e},useState:function(e){e=Ro(e);var t=e.queue,n=Ts.bind(null,X,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:us,useDeferredValue:function(e,t){return ps(So(),e,t)},useTransition:function(){var e=Ro(!1);return e=hs.bind(null,X,e.queue,!0,!1),So().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=X,a=So();if(ki){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),Fl===null)throw Error(i(349));Q&127||No(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,ns(Fo.bind(null,r,o,e),[e]),r.flags|=2048,Qo(9,{destroy:void 0},Po.bind(null,r,o,n,t),null),n},useId:function(){var e=So(),t=Fl.identifierPrefix;if(ki){var n=xi,r=bi;n=(r&~(1<<32-Ue(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=co++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[dt]=t,o[ft]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Ec(t)}}return jc(t),Dc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Ec(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=ge.current,Ii(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Di,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[dt]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||jd(e.nodeValue,n)),e||Ni(t,!0)}else e=Bd(e).createTextNode(r),e[dt]=t,t.stateNode=e}return jc(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Ii(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[dt]=t}else Li(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;jc(t),e=!1}else n=Ri(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?($a(t),t):($a(t),null);if(t.flags&128)throw Error(i(558))}return jc(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Ii(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[dt]=t}else Li(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;jc(t),a=!1}else a=Ri(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?($a(t),t):($a(t),null)}return $a(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),kc(t,t.updateQueue),jc(t),null);case 4:return A(),e===null&&xd(t.stateNode.containerInfo),jc(t),null;case 10:return Ui(t.type),jc(t),null;case 19:if(fe(eo),r=t.memoizedState,r===null)return jc(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Ac(r,!1);else{if(Hl!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=to(e),o!==null){for(t.flags|=128,Ac(r,!1),e=o.updateQueue,t.updateQueue=e,kc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)ai(n,e),n=n.sibling;return pe(eo,eo.current&1|2),ki&&Si(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Me()>$l&&(t.flags|=128,a=!0,Ac(r,!1),t.lanes=4194304)}else{if(!a)if(e=to(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,kc(t,e),Ac(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!ki)return jc(t),null}else 2*Me()-r.renderingStartTime>$l&&n!==536870912&&(t.flags|=128,a=!0,Ac(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(jc(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Me(),e.sibling=null,n=eo.current,pe(eo,a?n&1|2:n&1),ki&&Si(t,r.treeForkCount),e);case 22:case 23:return $a(t),qa(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(jc(t),t.subtreeFlags&6&&(t.flags|=8192)):jc(t),n=t.updateQueue,n!==null&&kc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&fe(da),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Ui(ta),jc(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Nc(e,t){switch(Ti(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Ui(ta),A(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return be(t),null;case 31:if(t.memoizedState!==null){if($a(t),t.alternate===null)throw Error(i(340));Li()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if($a(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));Li()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return fe(eo),null;case 4:return A(),null;case 10:return Ui(t.type),null;case 22:case 23:return $a(t),qa(),e!==null&&fe(da),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Ui(ta),null;case 25:return null;default:return null}}function Pc(e,t){switch(Ti(t),t.tag){case 3:Ui(ta),A();break;case 26:case 27:case 5:be(t);break;case 4:A();break;case 31:t.memoizedState!==null&&$a(t);break;case 13:$a(t);break;case 19:fe(eo);break;case 10:Ui(t.type);break;case 22:case 23:$a(t),qa(),e!==null&&fe(da);break;case 24:Ui(ta)}}function Fc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Uu(t,t.return,e)}}function Ic(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Uu(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Uu(t,t.return,e)}}function Lc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Ha(t,n)}catch(t){Uu(e,e.return,t)}}}function Rc(e,t,n){n.props=zs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Uu(e,t,n)}}function zc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Uu(e,t,n)}}function Bc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Uu(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Uu(e,t,n)}else n.current=null}function Vc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Uu(e,e.return,t)}}function Hc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[ft]=t}catch(t){Uu(e,e.return,t)}}function Uc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function Wc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Uc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Gc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=en));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Gc(e,t,n),e=e.sibling;e!==null;)Gc(e,t,n),e=e.sibling}function Kc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(Kc(e,t,n),e=e.sibling;e!==null;)Kc(e,t,n),e=e.sibling}function qc(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[dt]=e,t[ft]=n}catch(t){Uu(e,e.return,t)}}var Jc=!1,Yc=!1,Xc=!1,Zc=typeof WeakSet==`function`?WeakSet:Set,Qc=null;function $c(e,t){if(e=e.containerInfo,Rd=sp,e=Cr(e),wr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,Qc=t;Qc!==null;)if(t=Qc,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,Qc=e;else for(;Qc!==null;){switch(t=Qc,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[dt]=e,P(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=xr(s,h),v=xr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,O.T=null,n=su,su=null;var o=ru,s=au;if(nu=0,iu=ru=null,au=0,Pl&6)throw Error(i(331));var c=Pl;if(Pl|=4,kl(o.current),xl(o,o.current,s,n),Pl=c,rd(0,!1),Ve&&typeof Ve.onPostCommitFiberRoot==`function`)try{Ve.onPostCommitFiberRoot(Be,o)}catch{}return!0}finally{k.p=a,O.T=r,zu(e,t)}}function Hu(e,t,n){t=fi(n,t),t=Gs(e.stateNode,t,2),e=Fa(e,t,2),e!==null&&(tt(e,2),nd(e))}function Uu(e,t,n){if(e.tag===3)Hu(e,e,n);else for(;t!==null;){if(t.tag===3){Hu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(tu===null||!tu.has(r))){e=fi(n,e),n=Ks(2),r=Fa(t,n,2),r!==null&&(qs(n,r,t,e),tt(r,2),nd(r));break}}t=t.return}}function Wu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Nl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Bl=!0,i.add(n),e=Gu.bind(null,e,t,n),t.then(e,e))}function Gu(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Fl===e&&(Q&n)===n&&(Hl===4||Hl===3&&(Q&62914560)===Q&&300>Me()-Zl?!(Pl&2)&&bu(e,0):Gl|=n,ql===Q&&(ql=0)),nd(e)}function Ku(e,t){t===0&&(t=$e()),e=$r(e,t),e!==null&&(tt(e,t),nd(e))}function qu(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Ku(e,n)}function Ju(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),Ku(e,n)}function Yu(e,t){return Oe(e,t)}var Xu=null,Zu=null,Qu=!1,$u=!1,ed=!1,td=0;function nd(e){e!==Zu&&e.next===null&&(Zu===null?Xu=Zu=e:Zu=Zu.next=e),$u=!0,Qu||(Qu=!0,ld())}function rd(e,t){if(!ed&&$u){ed=!0;do for(var n=!1,r=Xu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-Ue(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,cd(r,a))}else a=Q,a=Ze(r,r===Fl?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Qe(r,a)||(n=!0,cd(r,a));r=r.next}while(n);ed=!1}}function id(){ad()}function ad(){$u=Qu=!1;var e=0;td!==0&&Gd()&&(e=td);for(var t=Me(),n=null,r=Xu;r!==null;){var i=r.next,a=od(r,t);a===0?(r.next=null,n===null?Xu=i:n.next=i,i===null&&(Zu=n)):(n=r,(e!==0||a&3)&&($u=!0)),r=i}nu!==0&&nu!==5||rd(e,!1),td!==0&&(td=0)}function od(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=L(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),P(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+L(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+L(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+L(n.imageSizes)+`"]`)):i+=`[href="`+L(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=h({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),P(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+L(r)+`"][href="`+L(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=h({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),P(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=Ct(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=h({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);P(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=Ct(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),P(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=Ct(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),P(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var a=(a=ge.current)?gf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=Ct(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=Ct(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=Ct(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Af(e){return`href="`+L(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return h({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),P(t),e.head.appendChild(t))}function Pf(e){return`[src="`+L(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+L(n.href)+`"]`);if(r)return t.instance=r,P(r),r;var a=h({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),P(r),Pd(r,`style`,a),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Af(n.href);var o=e.querySelector(jf(a));if(o)return t.state.loading|=4,t.instance=o,P(o),o;r=Mf(n),(a=mf.get(a))&&Rf(r,a),o=(e.ownerDocument||e).createElement(`link`),P(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(a=e.querySelector(Ff(o)))?(t.instance=a,P(a),a):(r=n,(a=mf.get(o))&&(r=h({},n),zf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),P(a),Pd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,P(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),P(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=h()})),_=c(u(),1),v=g(),y=class{baseUrl;actorUserId;constructor(e){this.baseUrl=e.baseUrl.replace(/\/$/,``),this.actorUserId=e.actorUserId.trim()}async login(e){return this.post(`/auth/login`,{email:e.email,password:e.password,device_fingerprint:T(),device_label:e.deviceLabel,trust_device:e.trustDevice})}async refresh(e){return this.post(`/auth/refresh`,{refresh_token:e.refreshToken})}async getInstallationStatus(){return(await this.request(`/installation/status`,{method:`GET`})).installation}async bootstrapOwner(e){return this.post(`/installation/bootstrap-owner`,{email:e.email,password:e.password,activation_payload:e.activationPayload,activation_signature:e.activationSignature||``})}async revokeAuthSession(e){await this.post(`/auth/sessions/revoke`,{user_id:e.userId,auth_session_id:e.authSessionId,reason:e.reason})}async listClusters(){return(await this.get(`/clusters`)).clusters??[]}async createCluster(e){return(await this.post(`/clusters/`,{actor_user_id:this.actorUserId,slug:e.slug,name:e.name,region:e.region||null,metadata:{}})).cluster}async updateCluster(e,t){return(await this.put(`/clusters/${e}`,{actor_user_id:this.actorUserId,name:t.name,status:t.status,region:t.region||null,metadata:t.metadata||{}})).cluster}async listClusterSummaries(){return(await this.get(`/cluster-admin-summaries`)).cluster_summaries??[]}async getClusterAuthority(e){return(await this.get(`/clusters/${e}/authority`)).authority_state}async updateClusterAuthority(e,t){return(await this.put(`/clusters/${e}/authority`,{actor_user_id:this.actorUserId,authority_state:t.authorityState,mutation_mode:t.mutationMode,notes:t.notes||null})).authority_state}async listNodes(e){return(await this.get(`/clusters/${e}/nodes`)).nodes??[]}async listNodeGroups(e){return(await this.get(`/clusters/${e}/node-groups`)).node_groups??[]}async createNodeGroup(e,t){return(await this.post(`/clusters/${e}/node-groups`,{actor_user_id:this.actorUserId,parent_group_id:t.parentGroupId||null,name:t.name,description:t.description||null,sort_order:t.sortOrder||0,metadata:{}})).node_group}async assignNodeGroup(e,t,n){return(await this.put(`/clusters/${e}/nodes/${t}/group`,{actor_user_id:this.actorUserId,group_id:n||null})).node}async disableMembership(e,t,n){await this.post(`/clusters/${e}/nodes/${t}/membership/disable`,{actor_user_id:this.actorUserId,reason:n})}async attachExistingNode(e,t,n){return(await this.post(`/clusters/${e}/nodes/${t}/membership/attach`,{actor_user_id:this.actorUserId,roles:n})).node}async revokeNodeIdentity(e,t,n){await this.post(`/clusters/${e}/nodes/${t}/identity/revoke`,{actor_user_id:this.actorUserId,reason:n})}async deleteClusterNode(e,t,n){await this.delete(`/clusters/${e}/nodes/${t}`,{actor_user_id:this.actorUserId,reason:n})}async listJoinRequests(e){return(await this.get(`/clusters/${e}/join-requests`)).join_requests??[]}async createJoinToken(e,t){let n=new Date(Date.now()+Math.max(t.ttlHours,1)*60*60*1e3).toISOString();return(await this.post(`/clusters/${e}/join-tokens`,{actor_user_id:this.actorUserId,scope:t.scope,expires_at:n,max_uses:Math.max(t.maxUses,1)})).join_token}async listJoinTokens(e){return(await this.get(`/clusters/${e}/join-tokens`)).join_tokens??[]}async revokeJoinToken(e,t){return(await this.post(`/clusters/${e}/join-tokens/${t}/revoke`,{actor_user_id:this.actorUserId})).join_token}async approveJoinRequest(e,t){await this.post(`/clusters/${e}/join-requests/${t}/approve`,{actor_user_id:this.actorUserId,ownership_type:`platform_managed`})}async rejectJoinRequest(e,t,n){await this.post(`/clusters/${e}/join-requests/${t}/reject`,{actor_user_id:this.actorUserId,reason:n})}async listNodeRoles(e,t){return(await this.get(`/clusters/${e}/nodes/${t}/roles`)).role_assignments??[]}async assignRole(e,t,n,r){await this.setRoleStatus(e,t,n,`active`,r)}async setRoleStatus(e,t,n,r,i){await this.post(`/clusters/${e}/nodes/${t}/roles`,{actor_user_id:this.actorUserId,organization_id:i||null,role:n,status:r,policy:{}})}async listWorkloadStatuses(e,t){return(await this.get(`/clusters/${e}/nodes/${t}/workloads/status`)).workload_statuses??[]}async listDesiredWorkloads(e,t){return(await this.get(`/clusters/${e}/nodes/${t}/workloads/desired`)).desired_workloads??[]}async listNodeHeartbeats(e,t,n=100){return(await this.get(`/clusters/${e}/nodes/${t}/heartbeats?limit=${n}`)).heartbeats??[]}async listNodeTelemetry(e,t,n=120){return(await this.get(`/clusters/${e}/nodes/${t}/telemetry?limit=${n}`)).telemetry??[]}async listReleaseVersions(e,t=`rap-node-agent`,n=`dev`){let r=new URLSearchParams({product:t,channel:n});return(await this.get(`/clusters/${e}/updates/releases?${r.toString()}`)).release_versions??[]}async createReleaseVersion(e,t){return(await this.post(`/clusters/${e}/updates/releases`,{actor_user_id:this.actorUserId,product:t.product,version:t.version,channel:t.channel||`stable`,status:t.status||`active`,compatibility:t.compatibility||{},changelog:t.changelog||``,artifacts:t.artifacts.map(e=>({os:e.os,arch:e.arch,install_type:e.installType,kind:e.kind,url:e.url,sha256:e.sha256,size_bytes:e.sizeBytes||0,metadata:e.metadata||{}}))})).release_version}async getStaleNodeRiskReport(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/updates/stale-node-risk-report?${t.toString()}`)).stale_node_risk_report}async getNodeUpdatePlan(e,t,n){let r=new URLSearchParams({product:n.product||`rap-node-agent`,current_version:n.currentVersion||``,os:n.os||`linux`,arch:n.arch||`amd64`,install_type:n.installType||`docker`,channel:n.channel||`dev`});return(await this.get(`/clusters/${e}/nodes/${t}/updates/plan?${r.toString()}`)).node_update_plan}async getNodeBridgeReplayPlan(e,t){let n=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/nodes/${t}/updates/bridge-replay-plan?${n.toString()}`)).node_bridge_replay_plan}async upsertNodeUpdatePolicy(e,t,n){return(await this.put(`/clusters/${e}/nodes/${t}/updates/policy`,{actor_user_id:this.actorUserId,product:n.product,channel:n.channel||`dev`,target_version:n.targetVersion??null,strategy:n.strategy||`rolling`,enabled:n.enabled??!0,rollback_allowed:n.rollbackAllowed??!0,health_window_seconds:n.healthWindowSeconds||180})).node_update_policy}async listNodeUpdateStatuses(e,t,n=80){let r=new URLSearchParams({actor_user_id:this.actorUserId,limit:String(n)});return(await this.get(`/clusters/${e}/nodes/${t}/updates/statuses?${r.toString()}`)).node_update_statuses??[]}async listFabricTestingFlags(){return(await this.get(`/fabric/testing-flags`)).testing_flags??[]}async updateFabricTestingFlag(e){return(await this.put(`/fabric/testing-flags`,{actor_user_id:this.actorUserId,scope_type:e.scopeType,scope_id:e.scopeId||null,cluster_id:e.clusterId||null,enabled:e.enabled,telemetry_enabled:e.telemetryEnabled,synthetic_links_enabled:e.syntheticLinksEnabled,history_retention_hours:e.historyRetentionHours,metadata:e.metadata||{}})).testing_flag}async setDesiredWorkload(e,t,n,r){await this.put(`/clusters/${e}/nodes/${t}/workloads/${n}/desired`,{actor_user_id:this.actorUserId,desired_state:r.desiredState,version:r.version||null,runtime_mode:r.runtimeMode,artifact_ref:null,config:r.config,environment:r.environment})}async listMeshLinks(e){return(await this.get(`/clusters/${e}/mesh/links`)).mesh_links??[]}async listRouteIntents(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/mesh/route-intents?${t.toString()}`)).route_intents??[]}async expireRouteIntent(e,t,n){return(await this.post(`/clusters/${e}/mesh/route-intents/${t}/expire`,{actor_user_id:this.actorUserId,reason:n})).route_intent}async disableRouteIntent(e,t,n){return(await this.post(`/clusters/${e}/mesh/route-intents/${t}/disable`,{actor_user_id:this.actorUserId,reason:n})).route_intent}async getNodeSyntheticMeshConfig(e,t){return(await this.get(`/clusters/${e}/nodes/${t}/mesh/synthetic-config`)).synthetic_mesh_config}async listFabricServiceChannelRouteFeedback(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.reporterNodeId&&n.set(`reporter_node_id`,t.reporterNodeId),t.routeId&&n.set(`route_id`,t.routeId),t.serviceClass&&n.set(`service_class`,t.serviceClass),t.feedbackStatus&&n.set(`feedback_status`,t.feedbackStatus),t.includeExpired&&n.set(`include_expired`,`true`),(await this.get(`/clusters/${e}/fabric/service-channels/route-feedback?${n.toString()}`)).route_feedback??[]}async expireFabricServiceChannelRouteFeedback(e,t){return(await this.post(`/clusters/${e}/fabric/service-channels/route-feedback/expire`,{actor_user_id:this.actorUserId,route_id:t.routeId,reporter_node_id:t.reporterNodeId||``,service_class:t.serviceClass||``,reason:t.reason||`expired from admin fabric diagnostics`})).route_feedback_expire}async listFabricServiceChannelRouteRebuildAttempts(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.reporterNodeId&&n.set(`reporter_node_id`,t.reporterNodeId),t.routeId&&n.set(`route_id`,t.routeId),t.replacementRouteId&&n.set(`replacement_route_id`,t.replacementRouteId),t.serviceClass&&n.set(`service_class`,t.serviceClass),t.rebuildStatus&&n.set(`rebuild_status`,t.rebuildStatus),t.rebuildRequestId&&n.set(`rebuild_request_id`,t.rebuildRequestId),t.generation&&n.set(`generation`,t.generation),t.feedbackSource&&n.set(`feedback_source`,t.feedbackSource),t.feedbackChannelId&&n.set(`feedback_channel_id`,t.feedbackChannelId),t.feedbackViolationStatus&&n.set(`feedback_violation_status`,t.feedbackViolationStatus),t.enrichment&&n.set(`enrichment`,t.enrichment),t.limit&&n.set(`limit`,String(t.limit)),t.offset&&n.set(`offset`,String(t.offset)),(await this.get(`/clusters/${e}/fabric/service-channels/rebuild-attempts?${n.toString()}`)).rebuild_attempts??[]}async getFabricServiceChannelRouteRebuildHealthSummary(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),(await this.get(`/clusters/${e}/fabric/service-channels/rebuild-health?${n.toString()}`)).rebuild_health}async getFabricServiceChannelReadiness(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),(await this.get(`/clusters/${e}/fabric/service-channels/readiness?${n.toString()}`)).fabric_service_channel_readiness}async getFabricServiceChannelSchemaStatus(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/fabric/service-channels/schema-status?${t.toString()}`)).fabric_service_channel_schema_status}async getFabricServiceChannelRebuildSnapshotMaintenanceHealth(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),t.minAgeSeconds&&n.set(`min_age_seconds`,String(t.minAgeSeconds)),t.heartbeatThreshold&&n.set(`heartbeat_threshold`,String(t.heartbeatThreshold)),(await this.get(`/clusters/${e}/fabric/service-channels/rebuild-snapshots/health?${n.toString()}`)).rebuild_snapshot_health}async warmupFabricServiceChannelRebuildSnapshots(e,t={}){return(await this.post(`/clusters/${e}/fabric/service-channels/rebuild-snapshots/warmup`,{actor_user_id:this.actorUserId,limit:t.limit||10,stale_after_seconds:t.staleAfterSeconds||60})).rebuild_snapshot_warmup}async getFabricServiceChannelLeaseMaintenance(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),t.includeExpired&&n.set(`include_expired`,`true`),(await this.get(`/clusters/${e}/fabric/service-channels/leases?${n.toString()}`)).fabric_service_channel_lease_maintenance}async cleanupFabricServiceChannelLeases(e,t={}){return(await this.post(`/clusters/${e}/fabric/service-channels/leases/cleanup`,{actor_user_id:this.actorUserId,limit:t.limit||100})).fabric_service_channel_lease_maintenance}async getFabricServiceChannelAccessTelemetry(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),(await this.get(`/clusters/${e}/fabric/service-channels/access-telemetry?${n.toString()}`)).fabric_service_channel_access_telemetry}async listFabricServiceChannelRouteRebuildIncidents(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),(await this.get(`/clusters/${e}/fabric/service-channels/rebuild-incidents?${n.toString()}`)).rebuild_incidents??[]}async getFabricServiceChannelRebuildInvestigationBreadcrumbs(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),t.currentWindowSeconds&&n.set(`current_window_seconds`,String(t.currentWindowSeconds)),t.historyWindowSeconds&&n.set(`history_window_seconds`,String(t.historyWindowSeconds)),(await this.get(`/clusters/${e}/fabric/service-channels/rebuild-investigations/breadcrumbs?${n.toString()}`)).rebuild_investigation_breadcrumbs}async recordFabricServiceChannelRouteRebuildInvestigation(e,t){await this.post(`/clusters/${e}/fabric/service-channels/rebuild-incidents/investigations`,{actor_user_id:this.actorUserId,reporter_node_id:t.reporterNodeId,route_id:t.routeId,service_class:t.serviceClass||``,generation:t.generation||``,guard_status:t.guardStatus||``,incident_id:t.incidentId||``,feedback_source:t.feedbackSource||``,feedback_channel_id:t.feedbackChannelId||``,feedback_violation_status:t.feedbackViolationStatus||``,drilldown_source:t.drilldownSource||``,reason:t.reason||`operator opened deep rebuild ledger`})}async silenceFabricServiceChannelRouteRebuildAlert(e,t){return(await this.post(`/clusters/${e}/fabric/service-channels/rebuild-health/silences`,{actor_user_id:this.actorUserId,incident_source:t.incidentSource||``,channel_id:t.channelId||``,reporter_node_id:t.reporterNodeId,route_id:t.routeId,guard_status:t.guardStatus,generation:t.generation||``,reason:t.reason||`operator acknowledged rebuild alert`,ttl_seconds:t.ttlSeconds||21600})).rebuild_alert_silence}async listFabricServiceChannelRouteRebuildAlertSilences(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/fabric/service-channels/rebuild-health/silences?${t.toString()}`)).rebuild_alert_silences??[]}async unsilenceFabricServiceChannelRouteRebuildAlert(e,t,n){return(await this.delete(`/clusters/${e}/fabric/service-channels/rebuild-health/silences/${encodeURIComponent(t)}`,{actor_user_id:this.actorUserId,reason:n||`operator removed rebuild alert silence`})).rebuild_alert_silence}async getFabricServiceChannelRecoveryPolicy(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/fabric/service-channels/recovery-policy?${t.toString()}`)).fabric_service_channel_recovery_policy}async updateFabricServiceChannelRecoveryPolicy(e,t){return(await this.put(`/clusters/${e}/fabric/service-channels/recovery-policy`,{actor_user_id:this.actorUserId,hysteresis_penalty:t.hysteresisPenalty,promotion_min_samples:t.promotionMinSamples,demotion_failure_threshold:t.demotionFailureThreshold,demotion_drop_threshold:t.demotionDropThreshold,demotion_slow_threshold:t.demotionSlowThreshold,demotion_rebuild_enabled:t.demotionRebuildEnabled,demotion_fenced_enabled:t.demotionFencedEnabled})).fabric_service_channel_recovery_policy}async getFabricServiceChannelAdaptivePolicy(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/fabric/service-channels/adaptive-policy?${t.toString()}`)).fabric_service_channel_adaptive_policy}async updateFabricServiceChannelAdaptivePolicy(e,t){return(await this.put(`/clusters/${e}/fabric/service-channels/adaptive-policy`,{actor_user_id:this.actorUserId,max_parallel_window:t.maxParallelWindow,bulk_pressure_channel_threshold:t.bulkPressureChannelThreshold,queue_pressure_high_watermark:t.queuePressureHighWatermark,queue_pressure_max_in_flight:t.queuePressureMaxInFlight,class_windows:t.classWindows})).fabric_service_channel_adaptive_policy}async getFabricServiceChannelPoolPolicy(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/fabric/service-channels/pool-policy?${t.toString()}`)).fabric_service_channel_pool_policy}async updateFabricServiceChannelPoolPolicy(e,t){return(await this.put(`/clusters/${e}/fabric/service-channels/pool-policy`,{actor_user_id:this.actorUserId,entry_pool_node_ids:t.entryPoolNodeIds,exit_pool_node_ids:t.exitPoolNodeIds,preferred_entry_node_id:t.preferredEntryNodeId,preferred_exit_node_id:t.preferredExitNodeId,selection_strategy:t.selectionStrategy,route_rebuild:t.routeRebuild,entry_failover:t.entryFailover,exit_failover:t.exitFailover,backend_fallback_allowed:t.backendFallbackAllowed,sticky_session:t.stickySession})).fabric_service_channel_pool_policy}async getFabricServiceChannelBreadcrumbWindowPolicy(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/fabric/service-channels/breadcrumb-window-policy?${t.toString()}`)).fabric_service_channel_breadcrumb_window_policy}async updateFabricServiceChannelBreadcrumbWindowPolicy(e,t){return(await this.put(`/clusters/${e}/fabric/service-channels/breadcrumb-window-policy`,{actor_user_id:this.actorUserId,current_window_seconds:t.currentWindowSeconds,history_window_seconds:t.historyWindowSeconds})).fabric_service_channel_breadcrumb_window_policy}async listQoSPolicies(e){return(await this.get(`/clusters/${e}/mesh/qos-policies`)).qos_policies??[]}async listVPNConnections(e){return(await this.get(`/clusters/${e}/vpn-connections`)).vpn_connections??[]}async createVPNConnection(e,t){return(await this.post(`/clusters/${e}/vpn-connections`,{actor_user_id:this.actorUserId,organization_id:t.organizationId,name:t.name,target_endpoint:t.targetEndpoint,protocol_family:t.protocolFamily,credential_ref:t.credentialRef||null,mode:`single_active`,desired_state:t.desiredState,allowed_node_policy:t.allowedNodePolicy,routing_usage:t.routingUsage,route_policy:t.routePolicy,qos_policy:t.qosPolicy,placement_policy:t.placementPolicy,metadata:{}})).vpn_connection}async updateVPNConnectionDesiredState(e,t,n){return(await this.put(`/clusters/${e}/vpn-connections/${t}/desired-state`,{actor_user_id:this.actorUserId,desired_state:n})).vpn_connection}async getActiveVPNLease(e,t){try{return(await this.get(`/clusters/${e}/vpn-connections/${t}/leases/active`)).lease}catch{return null}}async getVPNPacketStats(e,t){return(await this.get(`/clusters/${e}/vpn-connections/${t}/tunnel/stats`)).vpn_packet_stats??{}}async getVPNClientDiagnosticStatus(e,t){if(!t.trim())return null;try{return(await this.get(`/clusters/${e}/vpn/client-diagnostics/${encodeURIComponent(t.trim())}/status`)).vpn_client_diagnostic_status??null}catch{return null}}async listVPNClientDiagnosticStatuses(e){return(await this.get(`/clusters/${e}/vpn/client-diagnostics`)).vpn_client_diagnostic_statuses??[]}async enqueueVPNClientDiagnosticCommand(e,t,n){return(await this.post(`/clusters/${e}/vpn/client-diagnostics/${encodeURIComponent(t.trim())}/commands`,n)).vpn_client_diagnostic_command}async expireStaleVPNLeases(e){return(await this.post(`/clusters/${e}/vpn-connection-leases/expire-stale`,{actor_user_id:this.actorUserId})).expired_leases??[]}async listAudit(e,t={}){return(await this.listAuditDetailed(e,t)).events}async listAuditDetailed(e,t={}){let n=new URLSearchParams({limit:String(t.limit||100)});for(let e of t.eventTypes||[])e&&n.append(`event_type`,e);for(let e of t.targetTypes||[])e&&n.append(`target_type`,e);t.correlation&&n.set(`correlation`,t.correlation);let r=await this.get(`/clusters/${e}/audit?${n.toString()}`);return{events:r.audit_events??[],summary:r.audit_summary}}clusterEventsURL(e){return`${this.baseUrl}/clusters/${encodeURIComponent(e)}/events?actor_user_id=${encodeURIComponent(this.actorUserId)}`}async getOrganizationAdminSummary(e){return(await this.get(`/organizations/${e}/admin-summary`)).admin_summary}async listOrganizations(){return(await this.request(`/organizations?user_id=${encodeURIComponent(this.actorUserId)}`,{method:`GET`})).organizations??[]}async createOrganization(e){return(await this.post(`/organizations/`,{actor_user_id:this.actorUserId,slug:e.slug,name:e.name,metadata:e.metadata||{}})).organization}async listUsers(){return(await this.get(`/users/`)).users??[]}async createUser(e){return(await this.post(`/users/`,{actor_user_id:this.actorUserId,email:e.email,password:e.password,platform_role:e.platformRole||`user`})).user}async listOrganizationMemberships(e){return(await this.request(`/organizations/${e}/memberships?user_id=${encodeURIComponent(this.actorUserId)}`,{method:`GET`})).memberships??[]}async addOrganizationMembership(e,t){return(await this.post(`/organizations/${e}/memberships`,{actor_user_id:this.actorUserId,user_id:t.userId,role_id:t.roleId})).membership}async listResources(e){let t=new URLSearchParams({user_id:this.actorUserId});return e&&t.set(`organization_id`,e),(await this.request(`/resources?${t.toString()}`,{method:`GET`})).resources??[]}async createResource(e){return(await this.post(`/resources/`,{actor_user_id:this.actorUserId,organization_id:e.organizationId,name:e.name,address:e.address,protocol:e.protocol||`rdp`,secret_ref:e.secretRef||null,certificate_verification_mode:e.certificateVerificationMode||`strict`,render_quality_profile:e.renderQualityProfile||`balanced`,clipboard_mode:e.clipboardMode||`disabled`,file_transfer_mode:e.fileTransferMode||`disabled`,metadata:e.metadata||{}})).resource}async upsertResourceSecret(e,t){await this.put(`/resources/${e}/secret`,{actor_user_id:this.actorUserId,payload:{username:t.username||``,password:t.password||``,domain:t.domain||``},metadata:{source:`web-admin`}})}async get(e){let t=e.includes(`?`)?`&`:`?`;return this.request(`${e}${t}actor_user_id=${encodeURIComponent(this.actorUserId)}`,{method:`GET`})}async post(e,t){return this.request(e,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(t)})}async put(e,t){return this.request(e,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify(t)})}async delete(e,t){return this.request(e,{method:`DELETE`,headers:{"Content-Type":`application/json`},body:JSON.stringify(t)})}async request(e,t){let n=await fetch(`${this.baseUrl}${e}`,t);if(!n.ok){let e=`Запрос завершился ошибкой HTTP ${n.status}`;try{let t=await n.json();e=b(t,n.status)||t.error?.fallback_message||t.error?.code||e}catch{}throw Error(e)}return await n.json()}};function b(e,t){let n=e.error;if(!n)return``;if(t===409&&n.code===`conflict.legacy_compatibility_removal_is_blocked_while_stale_recovery_risk_nodes_remain`){let e=n.details||{},t=[`Compatibility cleanup заблокирован.`],r=x(e,`blocked_operation`);r&&t.push(`Операция: ${r}.`);let i=[S(e,`blocked_nodes`)?`blockers ${S(e,`blocked_nodes`)}`:``,S(e,`stale_nodes`)?`stale ${S(e,`stale_nodes`)}`:``,S(e,`artifact_gap_nodes`)?`artifact gap ${S(e,`artifact_gap_nodes`)}`:``,S(e,`unknown_profile_nodes`)?`profile unknown ${S(e,`unknown_profile_nodes`)}`:``,S(e,`waiting_update_status_nodes`)?`waiting status ${S(e,`waiting_update_status_nodes`)}`:``,S(e,`unknown_version_nodes`)?`version unknown ${S(e,`unknown_version_nodes`)}`:``,S(e,`legacy_recovery_contract_nodes`)?`legacy contract ${S(e,`legacy_recovery_contract_nodes`)}`:``,S(e,`recovery_bridge_required_nodes`)?`recovery bridge ${S(e,`recovery_bridge_required_nodes`)}`:``,S(e,`recovery_bridge_replay_ready_nodes`)?`bridge replay ready ${S(e,`recovery_bridge_replay_ready_nodes`)}`:``,S(e,`waiting_recovery_heartbeat_nodes`)?`waiting heartbeat ${S(e,`waiting_recovery_heartbeat_nodes`)}`:``].filter(Boolean);i.length>0&&t.push(i.join(` / `)+`.`);let a=w(e,`blocked_node_ids`);if(a.length>0&&t.push(`Blocked nodes: ${a.join(`, `)}.`),C(e,`bridge_hold_required`)){let n=w(e,`bridge_hold_reasons`),r=w(e,`bridge_hold_node_ids`),i=[];n.length>0&&i.push(`reasons ${n.join(`, `)}`),r.length>0&&i.push(`nodes ${r.join(`, `)}`),t.push(`Recovery bridge hold active${i.length>0?`: ${i.join(` / `)}`:``}.`)}let o=n.trace_id?.trim();return o&&t.push(`Trace: ${o}.`),t.join(` `)}return``}function x(e,t){let n=e[t];return typeof n==`string`?n.trim():``}function S(e,t){let n=e[t];return typeof n==`number`&&Number.isFinite(n)?n:0}function C(e,t){return e[t]===!0}function w(e,t){let n=e[t];return Array.isArray(n)?n.filter(e=>typeof e==`string`&&e.trim().length>0):[]}function T(){let e=`rap.webAdmin.deviceFingerprint`,t=localStorage.getItem(e);if(t)return t;let n=`web-admin-${ee()}`;return localStorage.setItem(e,n),n}function ee(){if(typeof globalThis.crypto?.randomUUID==`function`)return globalThis.crypto.randomUUID();if(typeof globalThis.crypto?.getRandomValues==`function`){let e=new Uint8Array(16);globalThis.crypto.getRandomValues(e),e[6]=e[6]&15|64,e[8]=e[8]&63|128;let t=Array.from(e,e=>e.toString(16).padStart(2,`0`)).join(``);return`${t.slice(0,8)}-${t.slice(8,12)}-${t.slice(12,16)}-${t.slice(16,20)}-${t.slice(20)}`}return`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}var te=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),E=o(((e,t)=>{t.exports=te()}))(),D={baseUrl:`rap.webAdmin.baseUrl`,actorUserId:`rap.webAdmin.actorUserId`,auth:`rap.webAdmin.auth`,language:`rap.webAdmin.language`,vpnDiagnosticDeviceId:`rap.webAdmin.vpnDiagnosticDeviceId`},ne=`/api/v1`,re=`http://192.168.200.61:8080/api/v1`,ie={reporterNodeId:``,routeId:``,serviceClass:``,generation:``,feedbackSource:``,feedbackChannelId:``,feedbackViolationStatus:``,offset:0},ae=[`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`],oe={"public-ingress":`Public HTTPS ingress`,"admin-ingress":`Admin HTTPS ingress`,"global-admin-runtime":`Global admin runtime`,"cluster-admin-runtime":`Cluster admin runtime`,"organization-portal-runtime":`Organization portal runtime`,"user-portal-runtime":`User portal runtime`,"identity-runtime":`Identity runtime`,"policy-authority":`Policy authority`,"audit-sink":`Audit sink`,"entry-node":`Entry node`,"relay-node":`Relay node`,"core-mesh":`Mesh core`,"rdp-worker":`RDP worker`,"vnc-worker":`VNC worker`,"vpn-exit":`VPN exit`,"vpn-connector":`VPN connector`,"vpn-client":`VPN client node`,"ipv4-egress":`IPv4 egress`,"file-storage-cache":`File/cache storage`,"update-cache":`Update cache`,"video-relay":`Video relay`},se={"public-ingress":[`can_accept_client_ingress`,`fabric_service_channel_runtime`],"admin-ingress":[`can_accept_client_ingress`,`fabric_service_channel_runtime`],"global-admin-runtime":[`can_run_admin_runtime`,`platform_owner_trusted_node`],"cluster-admin-runtime":[`can_run_admin_runtime`],"organization-portal-runtime":[`can_run_admin_runtime`],"user-portal-runtime":[`can_run_admin_runtime`],"identity-runtime":[`can_run_identity_runtime`],"policy-authority":[`can_run_policy_authority`,`platform_owner_trusted_node`],"audit-sink":[`can_run_audit_sink`,`platform_owner_trusted_node`],"entry-node":[`can_accept_client_ingress`],"relay-node":[`mesh_rendezvous_relay_control_contract`,`mesh_peer_connection_manager`],"core-mesh":[`native_node_agent`,`mesh_peer_connection_manager`,`mesh_listener_diagnostics`],"rdp-worker":[`can_run_rdp_worker`],"vnc-worker":[`can_run_vnc_worker`],"vpn-exit":[`can_run_vpn_exit`],"vpn-connector":[`can_run_vpn_connector`],"vpn-client":[`can_run_vpn_client`,`fabric_service_channel_required`],"ipv4-egress":[`can_egress_internet`,`fabric_service_channel_required`],"file-storage-cache":[`can_run_file_cache`],"update-cache":[`can_run_update_cache`],"video-relay":[`can_run_video_relay`]},O=[{id:`command`,ru:`Обзор`,en:`Command`},{id:`clusters`,ru:`Кластеры`,en:`Clusters`},{id:`cluster-settings`,ru:`Настройки кластера`,en:`Cluster Settings`},{id:`nodes`,ru:`Узлы`,en:`Nodes`},{id:`enrollment`,ru:`Новый узел`,en:`New Node`},{id:`roles`,ru:`Роли`,en:`Roles`},{id:`workloads`,ru:`Сервисы`,en:`Workloads`},{id:`fabric`,ru:`Связи Fabric`,en:`Fabric Links`},{id:`vpn`,ru:`VPN Control`,en:`VPN Control`},{id:`servers`,ru:`Серверы`,en:`Servers`},{id:`org-safe`,ru:`Организации`,en:`Organizations`},{id:`audit`,ru:`Аудит`,en:`Audit`}];function k(e){if(!e||typeof e!=`object`)return null;let t=e;return typeof t.userId!=`string`||typeof t.email!=`string`||typeof t.authSessionId!=`string`||typeof t.accessToken!=`string`||typeof t.refreshToken!=`string`||typeof t.accessTokenExpiresAt!=`string`||typeof t.refreshTokenExpiresAt!=`string`||!t.userId||!t.refreshToken?null:{userId:t.userId,email:t.email,authSessionId:t.authSessionId,accessToken:t.accessToken,refreshToken:t.refreshToken,accessTokenExpiresAt:t.accessTokenExpiresAt,refreshTokenExpiresAt:t.refreshTokenExpiresAt}}function ce(e){let t=Date.parse(e);return!Number.isFinite(t)||t<=Date.now()}function le(){try{let e=localStorage.getItem(D.auth);if(!e)return null;let t=k(JSON.parse(e));return!t||ce(t.refreshTokenExpiresAt)?null:t}catch{return null}}var ue={ttlHours:24,maxUses:1,roles:[`core-mesh`],nodeName:``,nodeGroupId:``,ownershipType:`platform_managed`,purpose:``,installMode:`docker`,dockerImage:`rap-node-agent:dev-enrollment-bootstrap-smoke`,dockerContainerName:``,dockerNetwork:`host`,windowsStartupMode:`auto`,windowsInstallDir:``,windowsNodeAgentSHA256:``,linuxInstallDir:``,linuxNodeAgentSHA256:``,meshListenAddr:``,meshListenPortMode:`auto`,meshListenAutoPortStart:19131,meshListenAutoPortEnd:19231,meshAdvertiseEndpoint:``,meshAdvertiseEndpoints:``,meshAdvertiseTransport:`direct_quic`,meshConnectivityMode:`private_lan`,meshNATType:`none`,meshRegion:`docker-test`,controlPlaneEndpoint:``,artifactEndpoints:``,dockerImageArtifactSHA256:``,pullImage:!1,replace:!0,syntheticRuntime:!1},de={ru:{productOwner:`Владелец продукта`,controlPlane:`Панель управления`,sideText:`Главная панель владельца платформы для кластеров, узлов, доверия, ролей и безопасного desired state.`,signInTitle:`Вход`,signInText:`Введите учетные данные.`,bootstrapTitle:`Первый владелец`,bootstrapText:`Пустая установка принимает только подписанную активацию продукта.`,activationPayload:`Activation manifest JSON`,activationSignature:`Подпись manifest`,createOwner:`Создать владельца`,creatingOwner:`Создание...`,ownerCreated:`Владелец создан. Теперь можно войти.`,installationLocked:`Установка уже активирована`,insecureBootstrapDisabled:`Insecure bootstrap выключен. Нужна strict-активация с ключом продукта.`,email:`Логин`,password:`Пароль`,backendApi:`Backend API`,useLocalProxy:`Использовать локальный /api/v1 proxy`,language:`Язык`,deviceLabel:`Устройство`,rememberMe:`Запомнить меня`,trustDevice:`Доверять этому устройству`,signIn:`Войти`,signingIn:`Вход...`,logout:`Выйти`,profile:`Профиль`,refresh:`Обновить`,refreshing:`Обновление...`,autoRefresh:`Автообновление`,lastRefresh:`Данные обновлены`,activeCluster:`Активный кластер`,slugLabel:`Технический код`,slugHelp:`Slug — постоянный короткий технический идентификатор кластера для URL, скриптов, логов и интеграций. Его лучше не менять после создания.`,clusterCatalog:`Каталог кластеров`,clusterCatalogText:`Список реальных кластеров из Control/API слоя. Выберите активный кластер или раскройте карточку для подробностей.`,makeActive:`Сделать активным`,openSettings:`Открыть настройки`,selected:`Выбран`,createCluster:`Создать кластер`,clusterDetails:`Подробнее`,consoleTitle:`Панель владельца платформы`,boundary:`WEB является только представлением. HTTP Control API управляет политикой, релизами и аудитом; межузловой transport Fabric остается QUIC/UDP.`,noLoginError:`Войдите как владелец продукта или администратор платформы, чтобы загрузить панель.`,accessDenied:`Доступ к этой панели запрещен.`,sessionMode:`Режим сессии`,sessionModeAdmin:`Админ`,sessionModeUser:`Пользователь`,sessionRefreshedAt:`Сессия обновлена`,emptyLiveTitle:`Кластер пока пустой`,emptyLiveText:`Это реальные данные, не заглушка: в выбранном кластере ещё нет одобренных node-agent узлов. Создайте join token, запустите rap-node-agent и подтвердите join request.`,realDataNote:`Показываются только данные из PostgreSQL и Control/API слоя. Если значения нулевые, значит соответствующих узлов, ролей или сервисов пока нет.`,signedInAs:`Вход выполнен`,actorUser:`Actor user`,testMode:`Тестирование`,testModeText:`Включает тестовую телеметрию и синтетические наблюдения связей. Это не production mesh runtime.`,platformTestFlag:`Тестирование сервера`,nodeTelemetry:`Телеметрия узла`,heartbeatHistory:`История heartbeat`,noTelemetry:`Телеметрии пока нет`,enableTelemetry:`Включить телеметрию`,enableSyntheticLinks:`Включить тестовые связи`,saveTestFlag:`Сохранить флаг`,nodeManagement:`Управление узлом`,nodeScope:`Область просмотра`,currentClusterNodes:`Узлы активного кластера`,allNodes:`Все узлы платформы`,showAllPlatformNodes:`Показать все узлы платформы`,currentClusterMembership:`Участие в активном кластере`,clusterMemberships:`Участие по кластерам`,notMemberOfActiveCluster:`не состоит`,nodeIdentity:`Физическая идентичность узла`,activeClusterScope:`Область активного кластера`,activeClusterScopeText:`Один физический узел может состоять в нескольких кластерах. Роли и desired-сервисы ниже относятся только к выбранному активному кластеру.`,capabilityConfirmed:`способность подтверждена heartbeat`,capabilityMissing:`способность не заявлена узлом`,capabilityUnknown:`способность не подтверждена: нет heartbeat`,nodeGlobalInventoryText:`Один физический узел показан один раз. Участие и роли остаются кластерными: в разных кластерах этот же узел может иметь разные назначения.`,nodeSearch:`Поиск узлов`,groupNodesBy:`Группировать`,groupByMembership:`по участию`,groupByHealth:`по здоровью`,groupByOwnership:`по владению`,groupByClusterCount:`по числу кластеров`,nodeGroups:`Группы узлов`,nodeGroupTree:`Дерево групп`,nodeGroupFilter:`Фильтр по группе`,allNodeGroups:`Все группы`,nodeGroupCreatePanel:`Создание группы`,nodeGroupName:`Название группы`,parentNodeGroup:`Родительская группа`,rootNodeGroup:`Корень`,ungroupedNodes:`Без группы`,createNodeGroup:`Создать группу`,createSubgroup:`Создать подгруппу`,collapseGroup:`Свернуть`,expandGroup:`Развернуть`,assignNodeGroup:`Переместить в группу`,removeFromNodeGroup:`Убрать из группы`,connectExistingNode:`Подключить к активному кластеру`,connectExistingNodeTitle:`Подключить существующий узел`,connectExistingNodeText:`Будет создано или повторно включено участие конкретного физического узла в активном кластере. Роли ниже назначаются только в этом кластере.`,connectWithRoles:`Подключить с ролями`,nodeDetails:`Сведения`,manageNode:`Настроить`,nodeFunctions:`Функции узла`,nodeFunctionsText:`Одна строка управляет функцией целиком: роль задает разрешение в активном кластере, desired-сервис задает запуск, observed показывает факт от node-agent.`,rolePermission:`Разрешение`,permissionGranted:`разрешено`,permissionDenied:`нет разрешения`,organizationScopeForEnable:`Область организации для новых включений, опционально`,clusterWideRolePlaceholder:`пусто = роль на весь кластер`,desiredRuntime:`Желаемое состояние`,observedRuntime:`Фактически`,enableFunction:`Включить функцию`,disableFunction:`Выключить функцию`,close:`Закрыть`,nodeBriefList:`Краткий список узлов`,noActiveClusterMembership:`Узел не входит в активный кластер`,nodeBriefListHelp:`Список сгруппирован деревом активного кластера. Полные сведения, управление, роли, сервисы и статистика открываются из строки узла.`,nodeSearchPlaceholder:`имя, ключ, кластер, статус`,nodeGroupInventoryText:`Группы — это кластерная инвентарная структура. Перенос узла в группу меняет только его размещение внутри активного кластера, не роли и не членство.`,nodeGroupCreated:`Группа узлов создана.`,noNodesTitle:`Нет узлов`,noNodesByFilter:`По текущему фильтру узлы не найдены.`,cancel:`Отмена`,alreadyMember:`Уже в активном кластере`,revokedMembership:`Участие отозвано`,addNode:`Подключить узел`,addNodeText:`Подключение существующего физического узла к активному кластеру выполняется из списка узлов: включите общий режим и нажмите «Подключить к активному кластеру».`,joinTokenTitle:`Создать новый Docker-узел`,joinTokenText:`Сначала создается одноразовый install token и Docker install profile. Затем команда запускается на Docker-хосте, агент отправляет заявку, а владелец платформы подтверждает ее.`,ttlHours:`Срок действия, часов`,ttlHelp:`Через это время token станет недействительным, даже если им никто не воспользовался. Для ручного подключения обычно достаточно 1–24 часов.`,maxUses:`Максимум использований`,maxUsesHelp:`Сколько node-agent смогут использовать этот token. Самый безопасный вариант — 1 token на 1 новый узел.`,tokenPurpose:`Назначение token`,nodeOwnership:`Тип владения узлом`,suggestedRoles:`Разрешенные/ожидаемые роли`,generatedScope:`Сгенерированная область действия`,generatedScopeHelp:`JSON формируется автоматически из настроек выше. Оператор не должен писать его руками, чтобы не ошибиться синтаксисом или областью доступа.`,manualApprovalRequired:`Подтверждение заявки вручную обязательно`,nodeRoles:`Роли узла`,desiredServices:`Желаемые сервисы`,observedServices:`Наблюдаемые сервисы`,noRoles:`Ролей пока нет`,noServices:`Сервисов пока нет`,manageInCluster:`Управлять в кластере`,rolesAndServices:`Роли и сервисы`,links:`Связи`,fabricMap:`Карта трафика Fabric`,fabricNodeLayer:`Узлы кластера`,observedPeerLinks:`Наблюдаемые связи`,placementIntent:`управляющее назначение`,endpointName:`Название`,publicEndpoint:`Публичный адрес`,endpointType:`Тип входа`,description:`Описание`,routeScope:`Область маршрутов JSON`,endpointNodes:`Назначенные узлы`,assignEndpointNode:`Назначить узел`,selectNode:`Выберите узел`,assignedNodesEmpty:`Узлы пока не назначены`,addressNotSet:`адрес не задан`,descriptionNotSet:`описание не задано`,servicePlacement:`Размещение сервисов`,trafficFlow:`Потоки между узлами`,organizationTestFlag:`Тестирование организации`,organizationId:`ID организации`,saveOrganizationFlag:`Сохранить флаг организации`,noLinks:`Связей пока нет`,recentHeartbeats:`Последние heartbeat`,memory:`Память`,cpu:`Процессор`,processes:`Процессы`},en:{productOwner:`Product Owner`,controlPlane:`Control API`,sideText:`Full platform-owner panel for clusters, nodes, trust, placement, and safe service desired state.`,signInTitle:`Sign in`,signInText:`Enter your credentials.`,bootstrapTitle:`First owner`,bootstrapText:`An empty installation accepts only a signed product activation.`,activationPayload:`Activation manifest JSON`,activationSignature:`Manifest signature`,createOwner:`Create owner`,creatingOwner:`Creating...`,ownerCreated:`Owner created. You can sign in now.`,installationLocked:`Installation is already active`,insecureBootstrapDisabled:`Insecure bootstrap is disabled. Strict product-key activation is required.`,email:`Login`,password:`Password`,backendApi:`Backend API`,useLocalProxy:`Use local /api/v1 proxy`,language:`Language`,deviceLabel:`Device`,rememberMe:`Remember me`,trustDevice:`Trust this device`,signIn:`Sign in`,signingIn:`Signing in...`,logout:`Logout`,profile:`Profile`,refresh:`Refresh`,refreshing:`Refreshing...`,autoRefresh:`Auto refresh`,lastRefresh:`Data refreshed`,activeCluster:`Active cluster`,slugLabel:`Technical code`,slugHelp:`Slug is a stable short technical identifier for URLs, scripts, logs, and integrations. It should generally not change after creation.`,clusterCatalog:`Cluster catalog`,clusterCatalogText:`Real clusters from the Control/API layer. Select the active cluster or expand a card for details.`,makeActive:`Make active`,openSettings:`Open settings`,selected:`Selected`,createCluster:`Create cluster`,clusterDetails:`Details`,consoleTitle:`Platform Owner Console`,boundary:`WEB is presentation only. The HTTP Control API handles policy, releases, and audit; inter-node Fabric transport remains QUIC/UDP.`,noLoginError:`Sign in as a product owner or platform administrator to load the panel.`,accessDenied:`Access to this panel is denied.`,sessionMode:`Session mode`,sessionModeAdmin:`Admin`,sessionModeUser:`User`,sessionRefreshedAt:`Session refreshed`,emptyLiveTitle:`Cluster has no live nodes yet`,emptyLiveText:`These are real values, not placeholders: the selected cluster has no approved node-agent nodes yet. Create a join token, run rap-node-agent, and approve the join request.`,realDataNote:`Only PostgreSQL and Control/API data is shown. Zero values mean the corresponding nodes, roles, or services do not exist yet.`,signedInAs:`Signed in`,actorUser:`Actor user`,testMode:`Testing`,testModeText:`Enables test telemetry and synthetic link observations. This is not production mesh runtime.`,platformTestFlag:`Server testing`,nodeTelemetry:`Node telemetry`,heartbeatHistory:`Heartbeat history`,noTelemetry:`No telemetry yet`,enableTelemetry:`Enable telemetry`,enableSyntheticLinks:`Enable test links`,saveTestFlag:`Save flag`,nodeManagement:`Node management`,nodeScope:`View scope`,currentClusterNodes:`Active cluster nodes`,allNodes:`All platform nodes`,showAllPlatformNodes:`Show all platform nodes`,currentClusterMembership:`Active cluster membership`,clusterMemberships:`Cluster memberships`,notMemberOfActiveCluster:`not a member`,nodeIdentity:`Physical node identity`,activeClusterScope:`Active cluster scope`,activeClusterScopeText:`One physical node may belong to multiple clusters. Roles and desired services below belong only to the selected active cluster.`,capabilityConfirmed:`capability confirmed by heartbeat`,capabilityMissing:`capability not reported by node`,capabilityUnknown:`capability unconfirmed: no heartbeat`,nodeGlobalInventoryText:`Each physical node is shown once. Membership and roles remain cluster-scoped, so the same node may have different assignments in different clusters.`,nodeSearch:`Node search`,groupNodesBy:`Group by`,groupByMembership:`membership`,groupByHealth:`health`,groupByOwnership:`ownership`,groupByClusterCount:`cluster count`,nodeGroups:`Node groups`,nodeGroupTree:`Group tree`,nodeGroupFilter:`Group filter`,allNodeGroups:`All groups`,nodeGroupCreatePanel:`Create group`,nodeGroupName:`Group name`,parentNodeGroup:`Parent group`,rootNodeGroup:`Root`,ungroupedNodes:`Ungrouped`,createNodeGroup:`Create group`,createSubgroup:`Create subgroup`,collapseGroup:`Collapse`,expandGroup:`Expand`,assignNodeGroup:`Move to group`,removeFromNodeGroup:`Remove from group`,connectExistingNode:`Connect to active cluster`,connectExistingNodeTitle:`Connect existing node`,connectExistingNodeText:`This creates or re-enables membership for one concrete physical node in the active cluster. Roles below are assigned only in this cluster.`,connectWithRoles:`Connect with roles`,nodeDetails:`Details`,manageNode:`Configure`,nodeFunctions:`Node functions`,nodeFunctionsText:`One row controls the whole function: role grants permission in the active cluster, desired service requests runtime start, observed state reports node-agent facts.`,rolePermission:`Permission`,permissionGranted:`granted`,permissionDenied:`not allowed`,organizationScopeForEnable:`Organization scope for new enables, optional`,clusterWideRolePlaceholder:`empty = cluster-wide role`,desiredRuntime:`Desired state`,observedRuntime:`Observed`,enableFunction:`Enable function`,disableFunction:`Disable function`,close:`Close`,nodeBriefList:`Compact node list`,noActiveClusterMembership:`Node is not a member of the active cluster`,nodeBriefListHelp:`The list is grouped as the active cluster tree. Full details, management, roles, services, and statistics open from the node row.`,nodeSearchPlaceholder:`name, key, cluster, status`,nodeGroupInventoryText:`Groups are a cluster inventory structure. Moving a node to a group changes only its placement inside the active cluster, not roles or membership.`,nodeGroupCreated:`Node group created.`,noNodesTitle:`No nodes`,noNodesByFilter:`No nodes match the current filter.`,cancel:`Cancel`,alreadyMember:`Already in active cluster`,revokedMembership:`Membership revoked`,addNode:`Add node`,addNodeText:`Connect an existing physical node to the active cluster from the node list: enable platform-wide view and click “Connect to active cluster”.`,joinTokenTitle:`Create new Docker node`,joinTokenText:`First create a one-time install token and Docker install profile. Then run the generated command on the Docker host; the agent submits a request and the platform owner approves it.`,ttlHours:`Lifetime, hours`,ttlHelp:`After this time the token becomes invalid even if unused. For manual enrollment, 1–24 hours is usually enough.`,maxUses:`Maximum uses`,maxUsesHelp:`How many node-agents may use this token. The safest default is one token for one new node.`,tokenPurpose:`Token purpose`,nodeOwnership:`Node ownership type`,suggestedRoles:`Allowed/expected roles`,generatedScope:`Generated scope`,generatedScopeHelp:`JSON is generated automatically from the settings above. Operators should not hand-write it and risk syntax or access-scope mistakes.`,manualApprovalRequired:`Manual request approval is required`,nodeRoles:`Node roles`,desiredServices:`Desired services`,observedServices:`Observed services`,noRoles:`No roles yet`,noServices:`No services yet`,manageInCluster:`Manage in cluster`,rolesAndServices:`Roles and services`,links:`Links`,fabricMap:`Fabric traffic map`,fabricNodeLayer:`Cluster nodes`,observedPeerLinks:`Observed links`,placementIntent:`control/API placement`,endpointName:`Name`,publicEndpoint:`Public endpoint`,endpointType:`Entry type`,description:`Description`,routeScope:`Route scope JSON`,endpointNodes:`Assigned nodes`,assignEndpointNode:`Assign node`,selectNode:`Select node`,assignedNodesEmpty:`No nodes assigned yet`,addressNotSet:`address not set`,descriptionNotSet:`description not set`,servicePlacement:`Service placement`,trafficFlow:`Node traffic flows`,organizationTestFlag:`Organization testing`,organizationId:`Organization ID`,saveOrganizationFlag:`Save organization flag`,noLinks:`No links yet`,recentHeartbeats:`Recent heartbeats`,memory:`Memory`,cpu:`CPU`,processes:`Processes`}};function fe(e){return{userId:e.user.id||e.user.ID||``,email:e.user.email||e.user.Email||``,authSessionId:e.auth_session.id||e.auth_session.ID||``,accessToken:e.tokens.access_token,refreshToken:e.tokens.refresh_token,accessTokenExpiresAt:e.tokens.access_token_expires_at,refreshTokenExpiresAt:e.tokens.refresh_token_expires_at}}async function pe(e){try{return await e.listClusterSummaries(),`admin`}catch{try{return await Promise.all([e.listOrganizations(),e.listResources()]),`user`}catch{return null}}}function me(){let[e,t]=(0,_.useState)(!1),[n,r]=(0,_.useState)(()=>!!le()),[i]=(0,_.useState)(()=>{let e=localStorage.getItem(D.baseUrl)?.trim();return!e||e.startsWith(re)?ne:e}),[a,o]=(0,_.useState)(()=>le()),[s,c]=(0,_.useState)(null),[l,u]=(0,_.useState)(``),[d,f]=(0,_.useState)(()=>localStorage.getItem(D.language)===`en`?`en`:`ru`),[p,m]=(0,_.useState)(a?.userId??localStorage.getItem(D.actorUserId)??``),[h,g]=(0,_.useState)({email:``,password:``,deviceLabel:`Панель владельца платформы`,trustDevice:!0,rememberMe:!0,showPassword:!1}),[v,b]=(0,_.useState)(null),[x,S]=(0,_.useState)({email:``,password:``,activationPayload:``,activationSignature:``}),[C,w]=(0,_.useState)(`command`),[T,ee]=(0,_.useState)(``),[te,oe]=(0,_.useState)([]),[se,k]=(0,_.useState)([]),[me,xe]=(0,_.useState)(null),[j,De]=(0,_.useState)([]),[Oe,ke]=(0,_.useState)([]),[Ae,je]=(0,_.useState)({}),[Me,Ne]=(0,_.useState)([]),[Pe,Fe]=(0,_.useState)([]),[Ie,Le]=(0,_.useState)([]),[M,Re]=(0,_.useState)(null),[He,Ue]=(0,_.useState)({}),[We,Ge]=(0,_.useState)({}),[Ke,qe]=(0,_.useState)({}),[Je,Ye]=(0,_.useState)({}),[Xe,Ze]=(0,_.useState)({}),[nt,ot]=(0,_.useState)({}),[st,ct]=(0,_.useState)({}),[ut,_t]=(0,_.useState)({}),[bt,Ct]=(0,_.useState)([]),[P,wt]=(0,_.useState)([]),[Tt,Et]=(0,_.useState)({}),[Pt,Ft]=(0,_.useState)([]),[It,qt]=(0,_.useState)([]),[Jt,Xt]=(0,_.useState)(null),[$t,en]=(0,_.useState)([]),[dn,fn]=(0,_.useState)(null),[pn,mn]=(0,_.useState)(null),[hn,_n]=(0,_.useState)(null),[vn,yn]=(0,_.useState)(null),[bn,xn]=(0,_.useState)(null),[R,Sn]=(0,_.useState)(null),[Cn,wn]=(0,_.useState)([]),[Tn,z]=(0,_.useState)(!1),[En,Dn]=(0,_.useState)(ie),[jn,Mn]=(0,_.useState)(null),[Nn,Ln]=(0,_.useState)(null),[Rn,zn]=(0,_.useState)([]),[Bn,Vn]=(0,_.useState)([]),[Hn,Un]=(0,_.useState)([]),[Wn,Kn]=(0,_.useState)({}),[qn,$n]=(0,_.useState)({}),[ar,or]=(0,_.useState)(()=>localStorage.getItem(D.vpnDiagnosticDeviceId)||``),[sr,cr]=(0,_.useState)([]),[lr,ur]=(0,_.useState)(null),[dr,fr]=(0,_.useState)(`http://2ip.ru/`),[pr,mr]=(0,_.useState)(null),[hr,gr]=(0,_.useState)([]),[_r,vr]=(0,_.useState)([]),[yr,br]=(0,_.useState)([]),[xr,Sr]=(0,_.useState)({}),[Cr,wr]=(0,_.useState)([]),[Tr,Er]=(0,_.useState)(``),[Dr,Or]=(0,_.useState)(``),[kr,Ar]=(0,_.useState)([]),[jr,Mr]=(0,_.useState)(null),[Nr,Pr]=(0,_.useState)(``),[Fr,Ir]=(0,_.useState)(`poll`),[Lr,Rr]=(0,_.useState)(``),[zr,Br]=(0,_.useState)(null),[Vr,Hr]=(0,_.useState)(!1),[Ur,Wr]=(0,_.useState)(``),[Gr,Kr]=(0,_.useState)(``),[qr,Jr]=(0,_.useState)({slug:``,name:``,region:``}),[Yr,Xr]=(0,_.useState)({name:``,status:`active`,region:``,metadataJson:`{}`}),[Zr,Qr]=(0,_.useState)({name:``,parentGroupId:``}),[$r,ei]=(0,_.useState)({hysteresisPenalty:`150`,promotionMinSamples:`64`,demotionFailureThreshold:`1`,demotionDropThreshold:`1`,demotionSlowThreshold:`1`,demotionRebuildEnabled:!0,demotionFencedEnabled:!0}),[ti,ni]=(0,_.useState)({currentWindowSeconds:`1800`,historyWindowSeconds:`86400`}),[U,W]=(0,_.useState)(ue),[ri,ii]=(0,_.useState)(null),[ai,oi]=(0,_.useState)({authorityState:`authoritative`,mutationMode:`normal`,notes:``}),[si,ci]=(0,_.useState)(``),[li,ui]=(0,_.useState)(`cluster`),[di,fi]=(0,_.useState)(``),[pi,mi]=(0,_.useState)(``),[hi,gi]=(0,_.useState)([]),[_i,vi]=(0,_.useState)(`membership`),[yi,bi]=(0,_.useState)(null),[xi,Si]=(0,_.useState)([]),[Ci,wi]=(0,_.useState)(null),[Ti,Ei]=(0,_.useState)(`details`),[Di,Oi]=(0,_.useState)({}),[ki,Ai]=(0,_.useState)({}),[ji,Mi]=(0,_.useState)({}),[Ni,Pi]=(0,_.useState)({}),[Fi,Ii]=(0,_.useState)(``),[Li,Ri]=(0,_.useState)({telemetry:!0,links:!0}),[zi,Bi]=(0,_.useState)({nodeId:``,serviceType:`entry-node`,desiredState:`enabled`,runtimeMode:`container`,version:``,configJson:`{}`,environmentJson:`{}`}),[G,Vi]=(0,_.useState)({organizationId:``,name:``,protocolFamily:`generic`,desiredState:`disabled`,credentialRef:``,targetEndpointJson:`{}`,allowedNodePolicyJson:`{"mode":"explicit","node_ids":[]}`,routingUsageJson:`[]`,routePolicyJson:`{}`,qosPolicyJson:`{}`,placementPolicyJson:`{}`}),[Hi,Ui]=(0,_.useState)({slug:``,name:``}),[Wi,Gi]=(0,_.useState)({email:``,password:``,platformRole:`user`}),[Ki,qi]=(0,_.useState)({organizationId:``,userId:``,roleId:`org_member`}),[Ji,Yi]=(0,_.useState)(null),[Xi,Zi]=(0,_.useState)({username:``,password:``,domain:``}),[Qi,$i]=(0,_.useState)(``),[ea,ta]=(0,_.useState)(``),[K,na]=(0,_.useState)({organizationId:``,name:``,address:``,protocol:`rdp`,routeMode:`vpn_exit`,entryNode:``,exitNode:``,tags:``,username:``,password:``,domain:``}),[ra,ia]=(0,_.useState)(``),[aa,oa]=(0,_.useState)(``),[sa,ca]=(0,_.useState)(``),la=`rap-android-vpn-latest-release.apk`,[ua,da]=(0,_.useState)(la),q=(0,_.useMemo)(()=>new y({baseUrl:i,actorUserId:p}),[i,p]),fa=(0,_.useMemo)(()=>new y({baseUrl:i,actorUserId:``}),[i]),pa=(0,_.useRef)(0),ma=(0,_.useRef)(!1),J=de[d],ha=te.find(e=>e.id===T)||null,ga=se.find(e=>e.cluster_id===T)||null,_a=(0,_.useMemo)(()=>Yn(i),[i]),va=(0,_.useCallback)((e,t)=>{if(!e)return t;let n=e.trim();return n?/^https?:\/\//i.test(n)||n.startsWith(`/`)?n.startsWith(`/`)?n.substring(1):n:n.startsWith(`downloads/`)?n:`downloads/${n.replace(/^\.\/+/,``).replace(/^\/+/,``)}`:t},[]),ya=va(ua,la),ba=sa?va(sa,ya):ya,xa=sa?ba:ya,Sa=`${/^https?:\/\//i.test(xa)?xa:`${_a}/${xa}`}${aa?`?_v=${encodeURIComponent(aa)}`:``}`,Ca=(0,_.useMemo)(()=>Gt(U),[U]),wa=(0,_.useMemo)(()=>ri?Kt(ri.scope,U):U,[ri,U]),Ta=(0,_.useMemo)(()=>{let e=new Map;for(let t of te)for(let n of Ae[t.id]||[]){let r=e.get(n.id);r?(r.memberships.push({cluster:t,node:n}),(n.last_seen_at||``)>(r.node.last_seen_at||``)&&(r.node=n)):e.set(n.id,{node:n,memberships:[{cluster:t,node:n}]})}return Array.from(e.values()).sort((e,t)=>e.node.name.localeCompare(t.node.name))},[Ae,te]);(0,_.useMemo)(()=>kn(Ta,T,di,_i,d),[Ta,_i,di,d,T]);let Ea=(0,_.useMemo)(()=>Object.fromEntries(Ta.map(e=>[e.node.id,e])),[Ta]),Da=(0,_.useMemo)(()=>{let e=di.trim().toLowerCase(),t=pi?new Set([pi,...Ht(pi,Oe)]):null;return Ta.filter(n=>{let r=n.memberships.some(e=>e.cluster.id===T);if(li!==`all`&&!r)return!1;if(t){let e=n.memberships.find(e=>e.cluster.id===T);if(!e?.node.node_group_id||!t.has(e.node.node_group_id))return!1}return!e||An(n,e)})},[Ta,di,pi,Oe,li,T]),Oa=(0,_.useCallback)((e,t=!1)=>{if(e&&t){localStorage.setItem(D.auth,JSON.stringify(e)),localStorage.setItem(D.actorUserId,e.userId),r(!0);return}r(!1),localStorage.removeItem(D.auth),localStorage.removeItem(D.actorUserId)},[]),ka=(0,_.useCallback)(async()=>{try{let e=`${_a}/downloads/rap-android-vpn-build.json?_cb=${Date.now()}`,t=await fetch(e,{cache:`no-store`});if(!t.ok){ia(``),oa(new Date().toISOString()),ca(``),da(la);return}let n=await t.json();ia(n.version?.name||``),oa(n.published?.timestamp_utc||``),ca(n.release_paths?.versioned||``),da(n.published?.path||n.release_paths?.latest||la)}catch{ia(``),oa(new Date().toISOString()),ca(``),da(la)}},[_a]),Aa=(0,_.useMemo)(()=>Ut(Da,Oe,T,J,new Set(hi)),[hi,Oe,T,J,Da]),ja=(0,_.useMemo)(()=>Cr.filter(e=>e.event_type===`legacy_compatibility_removal.blocked`).slice(0,4),[Cr]),Ma=(0,_.useMemo)(()=>Tr?Cr.filter(e=>jt(e,Tr)):Cr,[Cr,Tr]),Na=(0,_.useCallback)((e,t)=>{let n=Ea[e];n&&(w(`nodes`),ui(`all`),mi(``),fi(n.node.name||n.node.node_key),wi(n),Ei(t))},[Ea]),Pa=(0,_.useCallback)(e=>{let t=Ea[e];Er(e),Or(t?.node.name||t?.node.node_key||e),w(`audit`)},[Ea]),Fa=(0,_.useMemo)(()=>kr.slice(0,8),[kr]);(0,_.useEffect)(()=>{if(e)return;t(!0);let n=le();if(n){if(ce(n.refreshTokenExpiresAt)){localStorage.removeItem(D.auth),localStorage.removeItem(D.actorUserId),r(!1);return}(async()=>{try{let e=fe(await fa.refresh({refreshToken:n.refreshToken}));if(!e.userId||!e.authSessionId)throw Error(`Не удалось восстановить сессию.`);let t=await pe(new y({baseUrl:i,actorUserId:e.userId}));if(!t)throw Error(`Доступ к этой панели запрещен.`);m(e.userId),Oa(e,!0),o(e),u(new Date().toISOString()),g(t=>({...t,email:e.email})),c(t)}catch{localStorage.removeItem(D.auth),localStorage.removeItem(D.actorUserId),r(!1),u(``),o(null),m(``),c(null)}})()}},[fa,e,i,Oa]),(0,_.useEffect)(()=>{let e=!1;return fa.getInstallationStatus().then(t=>{e||b(t)}).catch(t=>{e||Wr(t instanceof Error?t.message:`Не удалось загрузить статус установки.`)}),()=>{e=!0}},[fa]),(0,_.useEffect)(()=>{if(!ha){Xr({name:``,status:`active`,region:``,metadataJson:`{}`});return}Xr({name:ha.name,status:ha.status||`active`,region:ha.region||``,metadataJson:JSON.stringify(ha.metadata||{},null,2)})},[ha]),(0,_.useEffect)(()=>{mi(``),Qr({name:``,parentGroupId:``}),gi([])},[T]),(0,_.useEffect)(()=>{bi(null),Si([])},[T]),(0,_.useEffect)(()=>{localStorage.setItem(D.baseUrl,i),localStorage.setItem(D.language,d),a&&localStorage.setItem(`${D.language}.${a.userId}`,d),(!a||!n)&&(localStorage.removeItem(D.auth),localStorage.removeItem(D.actorUserId))},[i,d,n,a]),(0,_.useEffect)(()=>{if(!a)return;let e=localStorage.getItem(`${D.language}.${a.userId}`);(e===`ru`||e===`en`)&&f(e)},[a?.userId]),(0,_.useEffect)(()=>{a&&Ia()},[a?.userId]),(0,_.useEffect)(()=>{if(!a||s!==`admin`||!T)return;let e=!1,t=()=>{e||Vr||ma.current||document.visibilityState===`hidden`||(ma.current=!0,Ra(T).catch(t=>{e||Wr(t instanceof Error?t.message:`Не удалось автообновить данные панели.`)}).finally(()=>{ma.current=!1}))},n=null;typeof window.EventSource==`function`&&(n=new EventSource(q.clusterEventsURL(T)),n.onopen=()=>{e||Ir(`sse`)},n.onerror=()=>{e||Ir(`poll`)},n.addEventListener(`cluster.changed`,t));let r=window.setInterval(t,n?3e4:1e4);return()=>{e=!0,n?.close(),window.clearInterval(r)}},[q,s,Vr,T,a?.userId]);async function Ia(e=T){if(!p.trim()){Wr(J.noLoginError);return}if(s===`user`){await La();return}Hr(!0),Wr(``),Kr(``);try{let[t,n,r,i,a]=await Promise.all([q.listClusters(),q.listClusterSummaries(),q.listOrganizations(),q.listUsers(),q.listResources()]);oe(t),k(n),gr(r),vr(i),br(a),!Lr&&r[0]?.id&&Rr(r[0].id),qi(e=>({...e,organizationId:e.organizationId||r[0]?.id||``})),na(e=>({...e,organizationId:e.organizationId||r[0]?.id||``}));let o=await Promise.all(r.map(async e=>[e.id,await q.listOrganizationMemberships(e.id)]));Sr(Object.fromEntries(o));let s=await Promise.all(t.map(async e=>[e.id,await q.listNodes(e.id)]));je(Object.fromEntries(s));let c=e||t[0]?.id||``;ee(c),c&&await za(c),Pr(new Date().toISOString())}catch(e){Wr(e instanceof Error?e.message:`Неизвестная ошибка панели управления платформой.`)}finally{Hr(!1)}}async function La(){if(!p.trim()){Wr(`Войдите, чтобы загрузить личный кабинет.`);return}Hr(!0),Wr(``),Kr(``);try{await ka();let[e,t]=await Promise.all([q.listOrganizations(),q.listResources()]);gr(e),br(t),!Lr&&e[0]?.id&&Rr(e[0].id);let n=await Promise.all(e.map(async e=>[e.id,await q.listOrganizationMemberships(e.id)]));Sr(Object.fromEntries(n)),Pr(new Date().toISOString())}catch(e){Wr(e instanceof Error?e.message:`Не удалось загрузить личный кабинет.`)}finally{Hr(!1)}}async function Ra(e){if(!p.trim())return;let[t,n,r,i,a]=await Promise.all([q.listClusterSummaries(),q.listNodes(e),q.listOrganizations(),q.listUsers(),q.listResources()]);k(t),gr(r),vr(i),br(a),je(t=>({...t,[e]:n})),await za(e,{preserveEditableForms:!0}),Pr(new Date().toISOString())}async function za(e,t={}){let n=++pa.current,r=Tn?20:10,i=Tn?En.offset:0,a={reporterNodeId:En.reporterNodeId||void 0,routeId:En.routeId||void 0,serviceClass:En.serviceClass||void 0,generation:En.generation||void 0,feedbackSource:En.feedbackSource||void 0,feedbackChannelId:En.feedbackChannelId||void 0,feedbackViolationStatus:En.feedbackViolationStatus||void 0,limit:r,offset:i,enrichment:Tn?`deep`:`summary`},[o,s,c,l,u,d,f,p,m,h,g,_,v,y,b,x,S,C,w,T,ee,te,E,ne,re,ie]=await Promise.all([q.listNodes(e),q.listNodeGroups(e),q.listJoinRequests(e),q.listJoinTokens(e),q.listReleaseVersions(e,`rap-node-agent`,`dev`),q.getStaleNodeRiskReport(e),q.getClusterAuthority(e),q.listAudit(e),q.getFabricServiceChannelRebuildInvestigationBreadcrumbs(e,{limit:20}),q.listMeshLinks(e),q.listRouteIntents(e),q.listFabricServiceChannelRouteFeedback(e,{includeExpired:!0}),q.listFabricServiceChannelRouteRebuildAttempts(e,a),q.getFabricServiceChannelRouteRebuildHealthSummary(e,{limit:5}),q.listFabricServiceChannelRouteRebuildAlertSilences(e),q.getFabricServiceChannelReadiness(e,{limit:5}),q.getFabricServiceChannelSchemaStatus(e),q.getFabricServiceChannelRebuildSnapshotMaintenanceHealth(e,{limit:50,minAgeSeconds:60,heartbeatThreshold:2}),q.getFabricServiceChannelLeaseMaintenance(e,{limit:20,includeExpired:!0}),q.getFabricServiceChannelAccessTelemetry(e,{limit:20}),q.listFabricServiceChannelRouteRebuildIncidents(e,{limit:5}),q.getFabricServiceChannelRecoveryPolicy(e),q.getFabricServiceChannelBreadcrumbWindowPolicy(e),q.listQoSPolicies(e),q.listVPNConnections(e),q.listFabricTestingFlags()]);if(n!==pa.current)return;De(o),ke(s),Ne(c),Fe(l),Le(u),Re(d),xe(f),t.preserveEditableForms||oi({authorityState:f.authority_state,mutationMode:f.mutation_mode,notes:f.notes||``}),wr(p),Ar(m.events),Mr(m.summary||null),Ct(h),wt(g),Ft(_),qt(v),Xt(y),en(b),fn(x),mn(S),_n(C),xn(w),Sn(T),wn(ee),Mn(te),Ln(E),t.preserveEditableForms||ni({currentWindowSeconds:String(E.current_window_seconds||1800),historyWindowSeconds:String(E.history_window_seconds||86400)}),ei({hysteresisPenalty:String(te.hysteresis_penalty),promotionMinSamples:String(te.promotion_min_samples),demotionFailureThreshold:String(te.demotion_failure_threshold),demotionDropThreshold:String(te.demotion_drop_threshold),demotionSlowThreshold:String(te.demotion_slow_threshold),demotionRebuildEnabled:te.demotion_rebuild_enabled,demotionFencedEnabled:te.demotion_fenced_enabled}),zn(ne),Un(re),Vn(ie);let ae=await q.listVPNClientDiagnosticStatuses(e);if(n!==pa.current)return;cr(ae);let oe=ae.find(e=>e.device_id===ar.trim())||ae[0]||null;ur(oe),!ar.trim()&&oe&&(or(oe.device_id),localStorage.setItem(D.vpnDiagnosticDeviceId,oe.device_id));let se=await Promise.all(o.map(async t=>[t.id,await q.listNodeRoles(e,t.id)]));if(n!==pa.current)return;Ye(Object.fromEntries(se));let O=await Promise.all(o.map(async t=>[t.id,await q.listDesiredWorkloads(e,t.id)]));if(n!==pa.current)return;Ze(Object.fromEntries(O));let k=await Promise.all(o.map(async t=>[t.id,await q.listWorkloadStatuses(e,t.id)]));if(n!==pa.current)return;ot(Object.fromEntries(k));let ce=await Promise.all(o.map(async t=>[t.id,await q.listNodeHeartbeats(e,t.id,60)]));if(n!==pa.current)return;ct(Object.fromEntries(ce));let le=d.nodes.filter(e=>e.recovery_bridge_replay_ready).map(e=>e.node_id);if(le.length>0){let t=await Promise.all(le.map(async t=>[t,await q.getNodeBridgeReplayPlan(e,t)]));if(n!==pa.current)return;Ue(Object.fromEntries(t))}else Ue({});let ue=await Promise.all(o.map(async t=>[t.id,await q.getNodeUpdatePlan(e,t.id,{currentVersion:t.reported_version})]));if(n!==pa.current)return;Ge(Object.fromEntries(ue));let de=await Promise.all(o.map(async t=>[t.id,await q.listNodeUpdateStatuses(e,t.id,80)]));if(n!==pa.current)return;qe(Object.fromEntries(de));let fe=await Promise.all(o.map(async t=>[t.id,await q.listNodeTelemetry(e,t.id,120)]));if(n!==pa.current)return;_t(Object.fromEntries(fe));let pe=await Promise.all(o.map(async t=>[t.id,await q.getNodeSyntheticMeshConfig(e,t.id)]));if(n!==pa.current)return;Et(Object.fromEntries(pe));let me=await Promise.all(re.map(async t=>[t.id,await q.getActiveVPNLease(e,t.id)]));if(n!==pa.current)return;Kn(Object.fromEntries(me));let he=await Promise.all(re.map(async t=>[t.id,await q.getVPNPacketStats(e,t.id)]));n===pa.current&&$n(Object.fromEntries(he))}async function Ba(e=Tn,t=En){if(T){Hr(!0),Wr(``),Kr(``);try{let n=await q.listFabricServiceChannelRouteRebuildAttempts(T,{reporterNodeId:t.reporterNodeId||void 0,routeId:t.routeId||void 0,serviceClass:t.serviceClass||void 0,generation:t.generation||void 0,feedbackSource:t.feedbackSource||void 0,feedbackChannelId:t.feedbackChannelId||void 0,feedbackViolationStatus:t.feedbackViolationStatus||void 0,limit:e?20:10,offset:e?t.offset:0,enrichment:e?`deep`:`summary`});z(e),Dn(t),qt(n),Kr(e?`Deep rebuild ledger loaded.`:`Fast rebuild ledger loaded.`)}catch(e){Wr(e instanceof Error?e.message:`Не удалось загрузить rebuild ledger.`)}finally{Hr(!1)}}}async function Va(){if(!T)return;let[e,t,n,r,i,a,o,s,c,l]=await Promise.all([q.getFabricServiceChannelRouteRebuildHealthSummary(T,{limit:5}),q.listFabricServiceChannelRouteRebuildAlertSilences(T),q.getFabricServiceChannelReadiness(T,{limit:5}),q.getFabricServiceChannelSchemaStatus(T),q.getFabricServiceChannelRebuildSnapshotMaintenanceHealth(T,{limit:50,minAgeSeconds:60,heartbeatThreshold:2}),q.getFabricServiceChannelLeaseMaintenance(T,{limit:20,includeExpired:!0}),q.getFabricServiceChannelAccessTelemetry(T,{limit:20}),q.listFabricServiceChannelRouteRebuildIncidents(T,{limit:5}),q.getFabricServiceChannelRebuildInvestigationBreadcrumbs(T,{limit:20}),q.getFabricServiceChannelBreadcrumbWindowPolicy(T)]);Xt(e),en(t),fn(n),mn(r),_n(i),xn(a),Sn(o),wn(s),Ar(c.events),Mr(c.summary||null),Ln(l),ni({currentWindowSeconds:String(l.current_window_seconds||1800),historyWindowSeconds:String(l.history_window_seconds||86400)})}async function Ha(){if(T)try{Hr(!0);let e=await q.warmupFabricServiceChannelRebuildSnapshots(T,{limit:10,staleAfterSeconds:60});yn(e),await Va(),Kr(`Snapshot warmup: warmed ${e.warmed_count}, fresh ${e.already_fresh_count}, errors ${e.error_count}.`)}catch(e){Wr(e instanceof Error?e.message:`Не удалось прогреть rebuild snapshots.`)}finally{Hr(!1)}}async function Ua(){if(T)try{Hr(!0);let e=await q.cleanupFabricServiceChannelLeases(T,{limit:100});xn(e),Kr(`Service-channel lease cleanup: deleted ${e.deleted_expired_count||0}, active ${e.active_count}, expired ${e.expired_count}.`)}catch(e){Wr(e instanceof Error?e.message:`Не удалось очистить service-channel leases.`)}finally{Hr(!1)}}async function Wa(e){let t={reporterNodeId:e.reporter_node_id,routeId:e.route_id,serviceClass:e.service_class,generation:e.generation||``,feedbackSource:``,feedbackChannelId:e.channel_id||``,feedbackViolationStatus:``,offset:0};await q.recordFabricServiceChannelRouteRebuildInvestigation(T,{reporterNodeId:e.reporter_node_id,routeId:e.route_id,serviceClass:e.service_class,generation:e.generation||``,guardStatus:e.guard_status,incidentId:e.fingerprint});let n=await q.getFabricServiceChannelRebuildInvestigationBreadcrumbs(T,{limit:20});Ar(n.events),Mr(n.summary||null),Dn(t),await Ba(!0,t)}function Ga(e){let t=new Set(e.affected_reporter_node_ids||[]),n=new Set(e.affected_route_ids||[]);return Cn.filter(r=>{let i=!e.feedback_channel_id||r.channel_id===e.feedback_channel_id,a=t.size===0||t.has(r.reporter_node_id),o=n.size===0||n.has(r.route_id);return i&&a&&o})}function Ka(e){let t=F(e.payload)||{},n=I(t,`feedback_source`,``),r=I(t,`feedback_channel_id`,``),i=I(t,`feedback_violation_status`,``),a=I(t,`reporter_node_id`,``),o=I(t,`route_id`,``);return!n&&!r&&!i?null:(Jt?.feedback_breakdowns||[]).find(e=>!(n&&e.feedback_source!==n||r&&e.feedback_channel_id!==r||i&&e.feedback_violation_status!==i||a&&!(e.affected_reporter_node_ids||[]).includes(a)||o&&!(e.affected_route_ids||[]).includes(o)))||null}function qa(e){let t=F(e.payload)||{},n=I(t,`reporter_node_id`,``),r=I(t,`route_id`,e.target_type===`fabric_service_channel_route_rebuild_incident`&&e.target_id||``),i=I(t,`service_class`,``),a=I(t,`generation`,``),o=I(t,`guard_status`,``);return Cn.find(e=>n&&e.reporter_node_id!==n||r&&e.route_id!==r||i&&e.service_class!==i||a&&e.generation!==a||o&&e.guard_status!==o?!1:!!(n||r||i||a||o))||null}async function Ja(e){let t={...ie,feedbackSource:e.feedback_source||``,feedbackChannelId:e.feedback_channel_id||``,feedbackViolationStatus:e.feedback_violation_status||``,offset:0};await q.recordFabricServiceChannelRouteRebuildInvestigation(T,{reporterNodeId:(e.affected_reporter_node_ids||[])[0]||``,routeId:(e.affected_route_ids||[])[0]||``,feedbackSource:e.feedback_source||``,feedbackChannelId:e.feedback_channel_id||``,feedbackViolationStatus:e.feedback_violation_status||``,drilldownSource:`rebuild_health_feedback_breakdown`,reason:`operator opened rebuild-health feedback breakdown ledger`});let n=await q.getFabricServiceChannelRebuildInvestigationBreadcrumbs(T,{limit:20});Ar(n.events),Mr(n.summary||null),w(`fabric`),Dn(t),await Ba(!0,t)}async function Ya(e){await q.silenceFabricServiceChannelRouteRebuildAlert(T,{incidentSource:e.incident_source||``,channelId:e.channel_id||``,reporterNodeId:e.reporter_node_id,routeId:e.route_id,guardStatus:e.guard_status||`unknown`,generation:e.generation||``,reason:`operator acknowledged rebuild incident`,ttlSeconds:21600}),await Va()}async function Xa(e){await q.unsilenceFabricServiceChannelRouteRebuildAlert(T,e.id,`operator removed rebuild alert silence`),await Va()}function Za(){De([]),ke([]),Ne([]),Fe([]),Le([]),Re(null),Ue({}),Ge({}),xe(null),Ye({}),Ze({}),ot({}),ct({}),qe({}),_t({}),Ct([]),wt([]),Et({}),Ft([]),qt([]),Xt(null),en([]),fn(null),mn(null),yn(null),wn([]),z(!1),Dn(ie),zn([]),Vn([]),Un([]),Kn({}),$n({}),cr([]),ur(null),gr([]),vr([]),br([]),Sr({}),wr([]),Ar([]),Mr(null)}async function Y(e,t){Hr(!0),Wr(``),Kr(``);try{await e(),Kr(t),await Ia()}catch(e){Wr(e instanceof Error?e.message:`Действие не выполнено.`)}finally{Hr(!1)}}async function Qa(){if(!T||!M||M.summary.blocked_nodes<1){$i(`Guard smoke сейчас недоступен: в отчете нет blocker-узлов для controlled blocked-check.`),ta(new Date().toISOString());return}Hr(!0),Wr(``),Kr(``),$i(``);try{let e=`0.2.402-guard-smoke-${Date.now()}`;await q.createReleaseVersion(T,{product:`rap-node-agent`,version:e,channel:`stable`,status:`active`,compatibility:{legacy_removal:!0},changelog:`UI smoke check for legacy removal guard`,artifacts:[{os:`linux`,arch:`amd64`,installType:`docker`,kind:`image`,url:`https://example.test/rap-node-agent.tar`,sha256:`sha256-guard-smoke`,sizeBytes:123,metadata:{}}]}),$i(`Smoke unexpectedly succeeded. Guard should have blocked breaking release creation while stale recovery-risk nodes remain.`)}catch(e){$i(e instanceof Error?e.message:`Guard smoke failed with a non-Error response.`)}finally{ta(new Date().toISOString()),Hr(!1)}}async function $a(){if(!T){ur(null);return}let e=await q.listVPNClientDiagnosticStatuses(T);cr(e);let t=ar.trim()||e[0]?.device_id||``;t&&(localStorage.setItem(D.vpnDiagnosticDeviceId,t),or(t));let n=e.find(e=>e.device_id===t)||(t?await q.getVPNClientDiagnosticStatus(T,t):null);ur(n),Kr(n?`Диагностика VPN-клиента обновлена.`:`Диагностика VPN-клиента не найдена.`)}async function eo(e,t){if(!T){Wr(`Выбери кластер перед отправкой команды.`);return}let n=ar.trim();if(!n){Wr(`Укажи Android device id или выбери найденный клиент.`);return}Hr(!0),Wr(``),Kr(``);try{mr(await q.enqueueVPNClientDiagnosticCommand(T,n,e)),Kr(`${t}: команда поставлена в очередь. Клиент заберет ее через диагностический канал.`),window.setTimeout(()=>{$a()},3500)}catch(e){Wr(e instanceof Error?e.message:`Команда VPN-клиенту не отправлена.`)}finally{Hr(!1)}}async function to(){Hr(!0),Wr(``),Kr(``);try{let e=fe(await fa.login({email:h.email,password:h.password,deviceLabel:h.deviceLabel,trustDevice:h.trustDevice}));if(!e.userId||!e.authSessionId)throw Error(`Ответ входа не содержит пользователя или сессию.`);let t=new y({baseUrl:i,actorUserId:e.userId}),n=`admin`;try{await t.listClusterSummaries(),n=`admin`}catch{try{let[e,r]=await Promise.all([t.listOrganizations(),t.listResources()]);gr(e),br(r),e[0]?.id&&Rr(e[0].id);let i=await Promise.all(e.map(async e=>[e.id,await t.listOrganizationMemberships(e.id)]));Sr(Object.fromEntries(i)),n=`user`}catch{try{await fa.revokeAuthSession({userId:e.userId,authSessionId:e.authSessionId,reason:`user_portal_access_denied`})}catch{}throw Error(J.accessDenied)}}r(h.rememberMe),Oa(e,h.rememberMe),o(e),m(e.userId),g(t=>({...t,email:e.email,password:``})),u(new Date().toISOString()),c(n),Kr(`${J.signedInAs}: ${e.email}`)}catch(e){Wr(e instanceof Error?e.message:`Вход не выполнен.`)}finally{Hr(!1)}}async function no(){Hr(!0),Wr(``),Kr(``);try{let e;if(v?.strict_authority){if(!x.activationPayload.trim()||!x.activationSignature.trim())throw Error(J.bootstrapText);e=JSON.parse(x.activationPayload)}b((await fa.bootstrapOwner({email:x.email,password:x.password,activationPayload:e,activationSignature:x.activationSignature})).installation),g({...h,email:x.email,password:x.password}),Kr(J.ownerCreated)}catch(e){Wr(e instanceof Error?e.message:`Создание владельца не выполнено.`)}finally{Hr(!1)}}async function X(){let e=a;if(o(null),r(!1),u(``),Oa(null),c(null),m(``),oe([]),k([]),Za(),je({}),ee(``),e?.userId&&e.authSessionId)try{await fa.revokeAuthSession({userId:e.userId,authSessionId:e.authSessionId,reason:`platform_owner_console_logout`})}catch{}}async function ro(e){ee(e),Za(),Hr(!0),Wr(``),Kr(``);try{await za(e)}catch(e){Wr(e instanceof Error?e.message:`Не удалось загрузить кластер.`)}finally{Hr(!1)}}let io=Me.filter(e=>e.status===`pending`).length,ao=j.filter(e=>e.health_status===`healthy`).length,oo=j.filter(e=>e.health_status!==`healthy`||e.membership_status!==`active`).length,so=Object.values(Je).flat().filter(e=>e.status===`active`).length,co=Bn.find(e=>e.scope_type===`platform`&&!e.scope_id)||null;Bn.find(e=>e.scope_type===`organization`&&e.scope_id===Fi&&(!e.cluster_id||e.cluster_id===T));let lo=Object.values(Tt),uo=lo.filter(e=>e.enabled).length,fo=lo.reduce((e,t)=>e+t.routes.length,0),po=lo.reduce((e,t)=>e+Object.keys(t.peer_endpoints||{}).length,0),mo=lo.reduce((e,t)=>e+Lt(t),0);lo.reduce((e,t)=>e+(t.peer_directory?.length??0),0),lo.reduce((e,t)=>e+(t.recovery_seeds?.length??0),0);let ho=lo.filter(e=>e.production_forwarding).length,go=Be(j,st),_o=P.filter(e=>pt(e)===`active`),vo=P.filter(e=>pt(e)===`expired`),yo=P.filter(e=>pt(e)===`disabled`),bo=Pt.filter(e=>{let t=Date.parse(e.expires_at||``),n=Date.parse(e.retry_cooldown_until||``);return Number.isFinite(t)&&t>Date.now()||Number.isFinite(n)&&n>Date.now()}),xo=bo.filter(e=>e.feedback_status===`fenced`),So=bo.filter(e=>e.feedback_status===`degraded`),Co=bo.filter(e=>e.feedback_status===`healthy`),wo=bo.filter(e=>e.recovery_state===`recovered`||e.recovery_hysteresis_active),To=bo.filter(e=>e.recovery_promoted),Eo=bo.filter(e=>e.recovery_demoted),Do=bo.filter(e=>e.feedback_status===`operator_retry_cooldown`||e.retry_cooldown_until),Oo=lo.flatMap(e=>e.route_path_decisions?.decisions||[]),ko=Oo.filter(e=>e.decision_source===`service_channel_feedback_no_alternate`),Ao=Oo.filter(e=>e.decision_source===`service_channel_feedback_replacement`),jo=Oo.filter(e=>e.rebuild_status),Mo=jo.filter(e=>e.rebuild_status===`applied`),No=It.filter(e=>e.rebuild_status===`applied`),Po=It.filter(e=>e.rebuild_status&&e.rebuild_status!==`applied`),Fo=It.filter(e=>e.guard_severity===`bad`),Io=Oo.filter(e=>(e.score_reasons||[]).includes(`service_channel_recovery_hysteresis`)),Lo=Oo.filter(e=>(e.score_reasons||[]).includes(`service_channel_recovery_promoted`)),Ro=Oo.filter(e=>(e.score_reasons||[]).includes(`service_channel_recovery_demoted`)),zo=v?.bootstrapped===!1,Bo=zo&&!v?.strict_authority&&!v?.insecure_bootstrap_allowed,Vo=s===`admin`?J.sessionModeAdmin:J.sessionModeUser;if(!a)return(0,E.jsxs)(`main`,{className:`loginShell`,children:[v&&(0,E.jsxs)(`section`,{className:`loginCard`,children:[(0,E.jsx)(`h1`,{children:v.bootstrapped?J.installationLocked:J.bootstrapTitle}),(0,E.jsx)(A,{label:`Authority`,value:`${v.authority_mode}/${v.authority_state}`}),(0,E.jsx)(A,{label:`Strict`,value:v.strict_authority?`enabled`:`legacy`}),v.root_fingerprint&&(0,E.jsx)(A,{label:`Root key`,value:B(v.root_fingerprint)})]}),zo?(0,E.jsxs)(`section`,{className:`loginCard`,children:[(0,E.jsx)(`h1`,{children:J.bootstrapTitle}),(0,E.jsx)(`p`,{className:`loginHint`,children:Bo?J.insecureBootstrapDisabled:J.bootstrapText}),(0,E.jsxs)(`label`,{children:[J.email,(0,E.jsx)(`input`,{value:x.email,onChange:e=>S({...x,email:e.target.value}),autoComplete:`username`})]}),(0,E.jsxs)(`label`,{children:[J.password,(0,E.jsx)(`input`,{value:x.password,onChange:e=>S({...x,password:e.target.value}),type:`password`,autoComplete:`new-password`})]}),v?.strict_authority&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsxs)(`label`,{children:[J.activationPayload,(0,E.jsx)(`textarea`,{value:x.activationPayload,onChange:e=>S({...x,activationPayload:e.target.value}),spellCheck:!1})]}),(0,E.jsxs)(`label`,{children:[J.activationSignature,(0,E.jsx)(`input`,{value:x.activationSignature,onChange:e=>S({...x,activationSignature:e.target.value}),spellCheck:!1})]})]}),Ur&&(0,E.jsx)(`div`,{className:`errorPanel`,children:Ur}),Gr&&(0,E.jsx)(`div`,{className:`noticePanel`,children:Gr}),(0,E.jsx)(`button`,{className:`primary wide`,onClick:()=>void no(),disabled:Vr||Bo||!x.email||x.password.length<12||v?.strict_authority&&(!x.activationPayload||!x.activationSignature),children:Vr?J.creatingOwner:J.createOwner})]}):(0,E.jsxs)(`section`,{className:`loginCard`,children:[(0,E.jsx)(`h1`,{children:J.signInTitle}),(0,E.jsxs)(`label`,{children:[J.email,(0,E.jsx)(`input`,{value:h.email,onChange:e=>g({...h,email:e.target.value.trim()}),autoComplete:`username`,autoCapitalize:`none`,autoCorrect:`off`,spellCheck:!1})]}),(0,E.jsxs)(`label`,{children:[J.password,(0,E.jsx)(`input`,{value:h.password,onChange:e=>g({...h,password:e.target.value}),type:h.showPassword?`text`:`password`,autoComplete:`current-password`,autoCapitalize:`none`,autoCorrect:`off`,spellCheck:!1,onKeyDown:e=>{e.key===`Enter`&&to()}})]}),(0,E.jsxs)(`label`,{className:`checkLine`,children:[(0,E.jsx)(`input`,{type:`checkbox`,checked:h.showPassword,onChange:e=>g({...h,showPassword:e.target.checked})}),`Показать пароль`]}),(0,E.jsxs)(`label`,{className:`checkLine`,children:[(0,E.jsx)(`input`,{type:`checkbox`,checked:h.trustDevice,onChange:e=>g({...h,trustDevice:e.target.checked})}),J.trustDevice]}),(0,E.jsxs)(`label`,{className:`checkLine`,children:[(0,E.jsx)(`input`,{type:`checkbox`,checked:h.rememberMe,onChange:e=>g({...h,rememberMe:e.target.checked})}),J.rememberMe]}),Ur&&(0,E.jsx)(`div`,{className:`errorPanel`,children:Ur}),Gr&&(0,E.jsx)(`div`,{className:`noticePanel`,children:Gr}),(0,E.jsx)(`button`,{className:`primary wide`,onClick:()=>void to(),disabled:Vr||!h.email||!h.password,children:Vr?J.signingIn:J.signIn})]})]});if(a&&!s)return(0,E.jsx)(`main`,{className:`loginShell`,children:(0,E.jsx)(`section`,{className:`loginCard`,children:(0,E.jsx)(`p`,{children:Vr?J.lastRefresh:`Восстанавливаем сессию...`})})});if(s===`user`){let e=hr.find(e=>e.id===Lr)||hr[0]||null,t=e?yr.filter(t=>t.organization_id===e.id):yr,n=e?(xr[e.id]||[]).find(e=>e.user_id===a.userId):null,r=t.reduce((e,t)=>(e[t.protocol]=(e[t.protocol]||0)+1,e),{});return(0,E.jsxs)(`main`,{className:`portalShell`,children:[(0,E.jsxs)(`aside`,{className:`portalRail`,children:[(0,E.jsx)(`div`,{className:`brandMark`,children:`RAP`}),(0,E.jsx)(`p`,{className:`sideKicker`,children:`Личный кабинет`}),(0,E.jsx)(`h1`,{children:`Мой доступ`}),(0,E.jsx)(`p`,{className:`sideText`,children:`Установки, доступные серверы и состояние рабочей области пользователя.`}),(0,E.jsx)(A,{label:J.sessionMode,value:`${Vo} • ${l?Qn(l):`н/д`}`}),(0,E.jsx)(A,{label:J.actorUser,value:a.email}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>void X(),disabled:Vr,children:J.logout})]}),(0,E.jsxs)(`section`,{className:`portalWorkspace`,children:[(0,E.jsxs)(`header`,{className:`portalTop`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`p`,{className:`eyebrow`,children:`Secure Access Fabric`}),(0,E.jsx)(`h2`,{children:e?.name||`Личный кабинет`}),(0,E.jsx)(`p`,{className:`muted`,children:a.email})]}),(0,E.jsxs)(`label`,{children:[`Организация`,(0,E.jsx)(`select`,{value:e?.id||``,onChange:e=>Rr(e.target.value),children:hr.map(e=>(0,E.jsx)(`option`,{value:e.id,children:e.name},e.id))})]}),(0,E.jsx)(`button`,{className:`primary`,onClick:()=>void La(),disabled:Vr,children:Vr?J.refreshing:J.refresh})]}),Ur&&(0,E.jsx)(`div`,{className:`errorPanel`,children:Ur}),Gr&&(0,E.jsx)(`div`,{className:`noticePanel`,children:Gr}),(0,E.jsxs)(`section`,{className:`grid three`,children:[(0,E.jsx)(he,{label:`Организации`,value:hr.length,tone:`steel`}),(0,E.jsx)(he,{label:`Серверы`,value:t.length,tone:`green`}),(0,E.jsx)(he,{label:`Установки`,value:2,tone:`amber`})]}),(0,E.jsxs)(`section`,{className:`grid two`,children:[(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:`Установки`}),(0,E.jsx)(`p`,{className:`muted`,children:ra?`Актуальная версия Android: ${ra}`:`Скачивайте актуальные клиенты только отсюда, чтобы не ловить старую сборку.`})]}),(0,E.jsx)(`span`,{className:`status active`,children:`latest`})]}),(0,E.jsxs)(`div`,{className:`portalInstallList`,children:[(0,E.jsxs)(`a`,{className:`installTile primaryInstall`,href:Sa,children:[(0,E.jsx)(`strong`,{children:`Android VPN`}),(0,E.jsx)(`span`,{children:`Последняя сборка RAP HOME VPN для телефона`}),(0,E.jsx)(`small`,{children:sa||xa})]}),(0,E.jsxs)(`a`,{className:`installTile`,href:`${_a}/downloads/rap-windows-rdp-client-latest-win-x64.zip`,children:[(0,E.jsx)(`strong`,{children:`Windows RDP клиент`}),(0,E.jsx)(`span`,{children:`Клиент удаленного рабочего стола, когда нужен доступ к серверам`}),(0,E.jsx)(`small`,{children:`latest win-x64`})]})]})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Профиль`}),(0,E.jsx)(A,{label:`Пользователь`,value:a.email}),(0,E.jsx)(A,{label:`Роль в организации`,value:n?.role_id||`участник`}),(0,E.jsx)(A,{label:`Организация`,value:e?.name||`нет`}),(0,E.jsx)(A,{label:`Последнее обновление`,value:Nr?V(Nr):`нет`})]}),(0,E.jsxs)(`article`,{className:`card span2`,children:[(0,E.jsx)(`div`,{className:`cardHead`,children:(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:`Доступные серверы`}),(0,E.jsx)(`p`,{className:`muted`,children:`Список ресурсов, которые уже разрешены пользователю через организацию.`})]})}),(0,E.jsx)(N,{columns:[`имя`,`адрес`,`протокол`,`секрет`,`передача файлов`],rows:t.map(e=>[e.name,e.address,e.protocol,e.has_secret?`настроен`:`нет`,H(e.file_transfer_mode||`disabled`)])})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Сервисы`}),(0,E.jsx)(N,{columns:[`тип`,`количество`],rows:Object.entries(r).map(([e,t])=>[e,String(t)])})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Что здесь будет дальше`}),(0,E.jsxs)(`div`,{className:`portalRoadmap`,children:[(0,E.jsx)(`span`,{children:`Устройства и доверенные входы`}),(0,E.jsx)(`span`,{children:`Активные VPN-сессии`}),(0,E.jsx)(`span`,{children:`Обновление профиля VPN без ручных ключей`}),(0,E.jsx)(`span`,{children:`Самостоятельная смена пароля`})]})]})]})]})]})}return(0,E.jsxs)(`main`,{className:`consoleShell`,children:[(0,E.jsxs)(`aside`,{className:`sideRail`,children:[(0,E.jsx)(`div`,{className:`brandMark`,children:`SAF`}),(0,E.jsx)(`p`,{className:`sideKicker`,children:J.productOwner}),(0,E.jsx)(`h1`,{children:J.controlPlane}),(0,E.jsx)(`p`,{className:`sideText`,children:J.sideText}),(0,E.jsx)(`nav`,{className:`railNav`,children:O.filter(e=>e.id!==`roles`).map(e=>(0,E.jsx)(`button`,{className:C===e.id?`active`:``,onClick:()=>w(e.id),children:e[d]},e.id))})]}),(0,E.jsxs)(`section`,{className:`workspace`,children:[(0,E.jsxs)(`header`,{className:`topBar`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`p`,{className:`eyebrow`,children:`Secure Access Fabric`}),(0,E.jsx)(`h2`,{children:ha?ha.name:J.consoleTitle}),(0,E.jsx)(`p`,{className:`muted`,children:J.boundary})]}),(0,E.jsxs)(`div`,{className:`clusterPicker`,children:[(0,E.jsxs)(`label`,{children:[J.activeCluster,(0,E.jsx)(`select`,{value:T,onChange:e=>void ro(e.target.value),children:te.map(e=>(0,E.jsx)(`option`,{value:e.id,children:e.name},e.id))})]}),(0,E.jsxs)(`span`,{children:[J.slugLabel,`: `,ha?.slug||`н/д`]})]}),(0,E.jsx)(`button`,{className:`primary`,onClick:()=>void Ia(),disabled:Vr,children:Vr?J.refreshing:J.refresh}),(0,E.jsxs)(`div`,{className:`refreshStatus`,children:[(0,E.jsx)(`strong`,{children:J.autoRefresh}),(0,E.jsx)(`span`,{children:Nr?`${J.lastRefresh}: ${Qn(Nr)} / ${Fr.toUpperCase()}`:Fr.toUpperCase()})]}),(0,E.jsxs)(`div`,{className:`profilePanel`,children:[(0,E.jsx)(`strong`,{children:J.profile}),(0,E.jsx)(`span`,{children:a.email}),(0,E.jsxs)(`span`,{children:[J.sessionMode,`: `,Vo,` | `,J.sessionRefreshedAt,`: `,l?Qn(l):`н/д`]}),(0,E.jsxs)(`label`,{children:[J.language,(0,E.jsxs)(`select`,{value:d,onChange:e=>f(e.target.value),children:[(0,E.jsx)(`option`,{value:`ru`,children:`Русский`}),(0,E.jsx)(`option`,{value:`en`,children:d===`ru`?`Английский`:`English`})]})]}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>void X(),disabled:Vr,children:J.logout})]})]}),Ur&&(0,E.jsx)(`div`,{className:`errorPanel`,children:Ur}),Gr&&(0,E.jsx)(`div`,{className:`noticePanel`,children:Gr}),ha&&j.length===0&&(0,E.jsxs)(`div`,{className:`noticePanel`,children:[(0,E.jsxs)(`strong`,{children:[J.emptyLiveTitle,`.`]}),` `,J.emptyLiveText]}),C===`command`&&(0,E.jsxs)(`section`,{className:`grid five`,children:[(0,E.jsx)(he,{label:`Кластеры`,value:te.length,tone:`steel`}),(0,E.jsx)(he,{label:`Узлы в области`,value:j.length,tone:`green`}),(0,E.jsx)(he,{label:`Здоровые узлы`,value:ao,tone:`green`}),(0,E.jsx)(he,{label:`Ожидают подключения`,value:io,tone:`amber`}),(0,E.jsx)(he,{label:`Рискованные состояния`,value:oo,tone:`red`}),(0,E.jsxs)(`article`,{className:`card span3`,children:[(0,E.jsx)(`h3`,{children:`Общее состояние кластеров`}),(0,E.jsx)(N,{columns:[`кластер`,`authority`,`ключ`,`режим изменений`,`узлы`,`заявки`,`роли`,`последний сигнал`],rows:se.map(e=>[e.name,e.authority_state,B(e.cluster_key_fingerprint),e.mutation_mode,`${e.healthy_node_count}/${e.node_count}`,String(e.pending_join_count),String(e.active_role_assignment_count),V(e.last_node_seen_at)])})]}),(0,E.jsxs)(`article`,{className:`card span2`,children:[(0,E.jsx)(`h3`,{children:`Authority выбранного кластера`}),me?(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Authority`,value:me.authority_state}),(0,E.jsx)(A,{label:`Режим изменений`,value:me.mutation_mode}),(0,E.jsx)(A,{label:`Терм`,value:String(me.term)}),(0,E.jsx)(A,{label:`Cluster key`,value:B(ga?.cluster_key_fingerprint)}),(0,E.jsx)(A,{label:`Обновлено`,value:V(me.updated_at)})]}):(0,E.jsx)(ye,{title:`Нет состояния authority`,text:`Выберите кластер, чтобы загрузить состояние authority.`})]}),(0,E.jsxs)(`article`,{className:`card span2`,children:[(0,E.jsx)(`h3`,{children:`Граница платформы`}),(0,E.jsx)(`p`,{className:`muted`,children:`Эта панель предназначена для владельца продукта / владельца платформы. Панели организаций должны использовать безопасные проекции и не раскрывать mesh internals, peer cache, route cache, секреты или данные других tenants.`})]}),(0,E.jsxs)(`article`,{className:`card span3`,children:[(0,E.jsx)(`h3`,{children:`Текущие сигналы кластера`}),(0,E.jsxs)(`div`,{className:`signalStrip`,children:[(0,E.jsx)(ge,{label:`Активные роли`,value:String(so)}),(0,E.jsx)(ge,{label:`Отчеты сервисов`,value:String(Object.values(nt).filter(e=>e.length>0).length)}),(0,E.jsx)(ge,{label:`Наблюдения связей`,value:String(bt.length)}),(0,E.jsx)(ge,{label:`Synthetic configs`,value:`${uo}/${j.length}`})]})]})]}),C===`clusters`&&(0,E.jsxs)(`section`,{className:`grid two`,children:[(0,E.jsxs)(`article`,{className:`card span2`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:J.clusterCatalog}),(0,E.jsx)(`p`,{className:`muted`,children:J.clusterCatalogText})]}),(0,E.jsx)(`span`,{className:`pill`,children:Gn(te.length,d)})]}),(0,E.jsxs)(`div`,{className:`clusterCatalog`,children:[te.map(e=>{let t=se.find(t=>t.cluster_id===e.id),n=e.id===T;return(0,E.jsxs)(`article`,{className:`clusterCard ${n?`selected`:``}`,children:[(0,E.jsxs)(`div`,{className:`clusterCardMain`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`p`,{className:`eyebrow`,children:e.region||`регион не задан`}),(0,E.jsx)(`h4`,{children:e.name}),(0,E.jsxs)(`p`,{className:`muted`,children:[J.slugLabel,`: `,(0,E.jsx)(`strong`,{children:e.slug})]})]}),(0,E.jsxs)(`div`,{className:`clusterCardActions`,children:[(0,E.jsx)(_e,{value:e.status}),n?(0,E.jsx)(`span`,{className:`pill good`,children:J.selected}):(0,E.jsx)(`button`,{onClick:()=>void ro(e.id),children:J.makeActive}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>{ro(e.id),w(`cluster-settings`)},children:J.openSettings})]})]}),(0,E.jsxs)(`div`,{className:`signalStrip compact`,children:[(0,E.jsx)(ge,{label:`Узлы`,value:t?`${t.healthy_node_count}/${t.node_count}`:`н/д`}),(0,E.jsx)(ge,{label:`Заявки`,value:String(t?.pending_join_count??`н/д`)}),(0,E.jsx)(ge,{label:`Роли`,value:String(t?.active_role_assignment_count??`н/д`)}),(0,E.jsx)(ge,{label:`Последний сигнал`,value:V(t?.last_node_seen_at)})]}),(0,E.jsxs)(`details`,{children:[(0,E.jsx)(`summary`,{children:J.clusterDetails}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`ID`,value:e.id}),(0,E.jsx)(A,{label:J.slugLabel,value:e.slug}),(0,E.jsx)(A,{label:`Статус`,value:H(e.status)}),(0,E.jsx)(A,{label:`Authority`,value:t?`${t.authority_state}/${t.mutation_mode}`:`неизвестно`}),(0,E.jsx)(A,{label:`Создан`,value:V(e.created_at)}),(0,E.jsx)(A,{label:`Обновлен`,value:V(e.updated_at||e.created_at)})]})]})]},e.id)}),te.length===0&&(0,E.jsx)(ye,{title:`Кластеров нет`,text:`Создайте первый кластер, затем подключите стартовый node-agent.`})]})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:J.createCluster}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[J.slugLabel,(0,E.jsx)(`input`,{value:qr.slug,onChange:e=>Jr({...qr,slug:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[`Название`,(0,E.jsx)(`input`,{value:qr.name,onChange:e=>Jr({...qr,name:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[`Регион`,(0,E.jsx)(`input`,{value:qr.region,onChange:e=>Jr({...qr,region:e.target.value})})]})]}),(0,E.jsx)(`p`,{className:`muted`,children:J.slugHelp}),(0,E.jsx)(`button`,{className:`primary`,disabled:!qr.slug||!qr.name,onClick:()=>void Y(async()=>{await q.createCluster({slug:qr.slug,name:qr.name,region:qr.region||null}),Jr({slug:``,name:``,region:``})},`Кластер создан.`),children:J.createCluster})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Что такое технический код?`}),(0,E.jsx)(`p`,{className:`muted`,children:J.slugHelp}),(0,E.jsx)(`p`,{className:`muted`,children:`Для человека основное поле — название. Для системы и операторов — технический код. Он нужен, чтобы сценарии, логи и будущие endpoint-адреса не зависели от переименования кластера.`})]})]}),C===`cluster-settings`&&(0,E.jsxs)(`section`,{className:`grid two`,children:[!ha&&(0,E.jsx)(ye,{title:`Кластер не выбран`,text:`Выберите активный кластер, чтобы открыть настройки.`}),ha&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Identity кластера`}),(0,E.jsx)(`p`,{className:`muted`,children:`Базовые параметры хранятся в PostgreSQL. Slug остается неизменяемым идентификатором для операторов и скриптов.`}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[`ID`,(0,E.jsx)(`input`,{value:ha.id,readOnly:!0})]}),(0,E.jsxs)(`label`,{children:[`Slug`,(0,E.jsx)(`input`,{value:ha.slug,readOnly:!0})]}),(0,E.jsxs)(`label`,{children:[`Название`,(0,E.jsx)(`input`,{value:Yr.name,onChange:e=>Xr({...Yr,name:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[`Статус`,(0,E.jsxs)(`select`,{value:Yr.status,onChange:e=>Xr({...Yr,status:e.target.value}),children:[(0,E.jsx)(`option`,{value:`active`,children:`active, работает`}),(0,E.jsx)(`option`,{value:`disabled`,children:`disabled, отключен`})]})]}),(0,E.jsxs)(`label`,{children:[`Регион`,(0,E.jsx)(`input`,{value:Yr.region,onChange:e=>Xr({...Yr,region:e.target.value}),placeholder:`например ru-msk-1`})]}),(0,E.jsxs)(`label`,{children:[`Обновлен`,(0,E.jsx)(`input`,{value:V(ha.updated_at||ha.created_at),readOnly:!0})]})]}),(0,E.jsxs)(`label`,{className:`wideLabel`,children:[`Metadata JSON`,(0,E.jsx)(`textarea`,{value:Yr.metadataJson,onChange:e=>Xr({...Yr,metadataJson:e.target.value}),rows:8,spellCheck:!1})]}),(0,E.jsx)(`button`,{className:`primary`,disabled:!Yr.name.trim(),onClick:()=>Jn(`Сохранить базовые настройки кластера`)&&void Y(async()=>{let e=$e(Yr.metadataJson||`{}`,`Metadata JSON`);await q.updateCluster(ha.id,{name:Yr.name,status:Yr.status,region:Yr.region||null,metadata:e})},`Настройки кластера сохранены.`),children:`Сохранить настройки кластера`})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Authority и режим изменений`}),(0,E.jsx)(`p`,{className:`muted`,children:`Эта секция защищает кластер от split-brain: minority/read-only сегменты не должны принимать изменения политик.`}),(0,E.jsxs)(`div`,{className:`stateGrid`,children:[(0,E.jsx)(A,{label:`Authority`,value:me?.authority_state||`неизвестно`}),(0,E.jsx)(A,{label:`Mutation mode`,value:me?.mutation_mode||`неизвестно`}),(0,E.jsx)(A,{label:`Term`,value:String(me?.term??`н/д`)}),(0,E.jsx)(A,{label:`Cluster key`,value:B(ga?.cluster_key_fingerprint)}),(0,E.jsx)(A,{label:`Последнее изменение`,value:V(me?.updated_at)})]}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[`Состояние authority`,(0,E.jsxs)(`select`,{value:ai.authorityState,onChange:e=>oi({...ai,authorityState:e.target.value}),children:[(0,E.jsx)(`option`,{value:`authoritative`,children:`authoritative, основной`}),(0,E.jsx)(`option`,{value:`minority`,children:`minority, меньшинство`}),(0,E.jsx)(`option`,{value:`isolated`,children:`isolated, изолирован`}),(0,E.jsx)(`option`,{value:`recovery`,children:`recovery, восстановление`})]})]}),(0,E.jsxs)(`label`,{children:[`Режим изменений`,(0,E.jsxs)(`select`,{value:ai.mutationMode,onChange:e=>oi({...ai,mutationMode:e.target.value}),children:[(0,E.jsx)(`option`,{value:`normal`,children:`normal, обычный`}),(0,E.jsx)(`option`,{value:`read_only`,children:`read_only, только чтение`}),(0,E.jsx)(`option`,{value:`recovery_override`,children:`recovery_override, восстановление`})]})]}),(0,E.jsxs)(`label`,{children:[`Примечание`,(0,E.jsx)(`input`,{value:ai.notes,onChange:e=>oi({...ai,notes:e.target.value})})]})]}),(0,E.jsx)(`button`,{disabled:!T,onClick:()=>Jn(`Изменить authority state кластера`)&&void Y(()=>q.updateClusterAuthority(T,{authorityState:ai.authorityState,mutationMode:ai.mutationMode,notes:ai.notes}),`Authority кластера обновлен.`),children:`Обновить authority`})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Safety / quorum`}),(0,E.jsxs)(`div`,{className:`stateGrid`,children:[(0,E.jsx)(A,{label:`Узлы`,value:String(ga?.node_count??j.length)}),(0,E.jsx)(A,{label:`Healthy`,value:String(ga?.healthy_node_count??ao)}),(0,E.jsx)(A,{label:`Pending join`,value:String(ga?.pending_join_count??Me.filter(e=>e.status===`pending`).length)}),(0,E.jsx)(A,{label:`Последний узел`,value:V(ga?.last_node_seen_at)})]}),(0,E.jsx)(`p`,{className:`muted`,children:`Минимальный размер, quorum policy и split-brain rules пока не имеют отдельного runtime-переключателя. Сейчас защита выполняется через authority/mutation mode, explicit node approval и аудит.`})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Telemetry / testing`}),(0,E.jsxs)(`div`,{className:`stateGrid`,children:[(0,E.jsx)(A,{label:`Telemetry flag`,value:co?.telemetry_enabled?`включен`:`выключен`}),(0,E.jsx)(A,{label:`Synthetic links`,value:co?.synthetic_links_enabled?`включены`:`выключены`}),(0,E.jsx)(A,{label:`Хранение истории, часов`,value:String(co?.history_retention_hours??`н/д`)})]}),(0,E.jsx)(`p`,{className:`muted`,children:`Это тестовый контур наблюдаемости: heartbeat/telemetry реальные, а связи Fabric сейчас synthetic. Production mesh traffic здесь пока не отображается.`})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Storage / updates`}),(0,E.jsxs)(`div`,{className:`stateGrid`,children:[(0,E.jsx)(A,{label:`Version Storage`,value:`архитектура зафиксирована, runtime не реализован`}),(0,E.jsx)(A,{label:`Update cache`,value:`${Wt(`update-cache`,Je).length} узл.`}),(0,E.jsx)(A,{label:`File/config cache`,value:`${Wt(`file-storage-cache`,Je).length} узл.`}),(0,E.jsx)(A,{label:`Legacy removal`,value:M?.legacy_removal_allowed?`разрешено`:M?`заблокировано`:`н/д`}),(0,E.jsx)(A,{label:`Stale nodes`,value:M?`${M.summary.stale_nodes} stale / ${M.summary.blocked_nodes} blockers`:`н/д`})]}),(0,E.jsx)(`p`,{className:`muted`,children:`Version Storage и 409/HTTP ответы относятся к Control API. Межузловой runtime transport Fabric остается QUIC/UDP-only.`}),M&&(0,E.jsxs)(`div`,{className:`stack compact`,children:[(0,E.jsxs)(`div`,{className:`membershipList`,children:[(0,E.jsx)(`span`,{className:`pill ${M.legacy_removal_allowed?`good`:`bad`}`,children:M.legacy_removal_allowed?`legacy cleanup allowed`:`legacy cleanup blocked`}),(0,E.jsxs)(`span`,{className:`pill`,children:[`nodes `,M.summary.total_nodes]}),(0,E.jsxs)(`span`,{className:`pill ${M.summary.stale_nodes>0?`warn`:`good`}`,children:[`stale `,M.summary.stale_nodes]}),(0,E.jsxs)(`span`,{className:`pill ${M.summary.blocked_nodes>0?`bad`:`good`}`,children:[`blockers `,M.summary.blocked_nodes]}),M.bridge_hold_required&&(0,E.jsx)(`span`,{className:`pill bad`,children:`bridge hold active`}),(M.blocked_operations||[]).map(e=>(0,E.jsx)(`span`,{className:`pill bad`,children:e},e)),(M.summary.artifact_gap_nodes||0)>0&&(0,E.jsxs)(`span`,{className:`pill bad`,children:[`artifact gap `,M.summary.artifact_gap_nodes]}),(M.summary.unknown_profile_nodes||0)>0&&(0,E.jsxs)(`span`,{className:`pill warn`,children:[`profile unknown `,M.summary.unknown_profile_nodes]}),(M.summary.waiting_update_status_nodes||0)>0&&(0,E.jsxs)(`span`,{className:`pill warn`,children:[`waiting status `,M.summary.waiting_update_status_nodes]}),(M.summary.unknown_version_nodes||0)>0&&(0,E.jsxs)(`span`,{className:`pill warn`,children:[`version unknown `,M.summary.unknown_version_nodes]}),(M.summary.legacy_recovery_contract_nodes||0)>0&&(0,E.jsxs)(`span`,{className:`pill bad`,children:[`legacy recovery contract `,M.summary.legacy_recovery_contract_nodes]}),(M.summary.recovery_bridge_required_nodes||0)>0&&(0,E.jsxs)(`span`,{className:`pill bad`,children:[`recovery bridge `,M.summary.recovery_bridge_required_nodes]}),(M.summary.recovery_bridge_replay_ready_nodes||0)>0&&(0,E.jsxs)(`span`,{className:`pill warn`,children:[`bridge replay ready `,M.summary.recovery_bridge_replay_ready_nodes]}),(M.summary.waiting_recovery_heartbeat_nodes||0)>0&&(0,E.jsxs)(`span`,{className:`pill info`,children:[`waiting heartbeat `,M.summary.waiting_recovery_heartbeat_nodes]})]}),M.summary.blocked_nodes>0?(0,E.jsxs)(`div`,{className:`stack compact`,children:[M.bridge_hold_required&&(0,E.jsx)(`p`,{className:`muted`,children:(()=>{let e=(M.bridge_hold_node_ids||[]).length||M.summary.recovery_bridge_required_nodes||0,t=M.bridge_hold_reasons||[];return(0,E.jsxs)(E.Fragment,{children:[`Recovery bridge hold active: compatibility overlap must remain enabled for`,` `,e,` node(s)`,t.length>0?` until ${t.join(`, `)} is cleared.`:`.`]})})()}),M.nodes.filter(e=>e.blocked).slice(0,4).map(e=>{let t=He[e.node_id],n=e.products.map(e=>{let t=e.compatible_artifact_found?e.matching_release_version||`ok`:`missing`;return`${e.product}: ${t}`}).join(` | `);return(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Blocker`,value:`${e.name} (${H(e.health_status)})`}),(0,E.jsx)(A,{label:`Last seen`,value:V(e.last_seen_at)}),(0,E.jsx)(A,{label:`Recovery readiness`,value:At(e)}),(0,E.jsx)(A,{label:`Recovery bridge`,value:e.recovery_bridge_required?`required`:`not required`}),(0,E.jsx)(A,{label:`Bridge replay`,value:e.recovery_bridge_replay_ready?`ready`:`not ready`}),(0,E.jsx)(A,{label:`Bridge hold`,value:e.recovery_bridge_required?`active: compatibility overlap must stay enabled`:`not active`}),(0,E.jsx)(A,{label:`Bridge actions`,value:(e.recovery_bridge_actions||[]).length>0?(e.recovery_bridge_actions||[]).join(`, `):`none`}),t&&t.products&&t.products.length>0&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(A,{label:`Replay plan`,value:`${t.products.length} product(s), ${t.bridge_hold_required?`hold active`:`hold cleared`}`}),t.products.map(t=>(0,E.jsx)(A,{label:`Replay ${t.product}`,value:`${t.update_plan.action} -> ${t.update_plan.target_version||`none`} / ${t.recovery_bridge_mode||`default`} / ${t.last_status_reason||`no reason`}`},`${e.node_id}-${t.product}`)),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>{gn(JSON.stringify(t,null,2)),Kr(`Bridge replay plan copied: ${e.name}`)},children:`Copy replay plan`}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>{let n=t.products?.map(e=>e.update_plan.artifact?.url||``).filter(e=>e.length>0)||[];gn(n.join(` +`)),Kr(n.length>0?`Replay artifact URL copied: ${e.name}`:`Replay artifact URL is not available yet: ${e.name}`)},children:`Copy artifact URL`}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>{gn((t.products||[]).map(e=>{let t=e.update_plan;return[`product=${e.product}`,`action=${t.action}`,`target=${t.target_version||`none`}`,`mode=${e.recovery_bridge_mode||`default`}`,`reason=${e.last_status_reason||t.reason||`unknown`}`,`artifact=${t.artifact?.url||`n/a`}`].join(` | `)}).join(` +`)||`node=${e.name} | no replay products`),Kr(`Bridge replay summary copied: ${e.name}`)},children:`Copy replay summary`})]}),(0,E.jsx)(Ce,{title:`Raw bridge replay plan: ${e.name}`,value:t})]}),(0,E.jsx)(A,{label:`Risks`,value:e.risks.join(`, `)||`none`}),(0,E.jsx)(A,{label:`Recovery artifacts`,value:n||`н/д`}),(0,E.jsx)(A,{label:`Действие`,value:kt(e)}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>Na(e.node_id,`details`),children:`Последние статусы`}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>Na(e.node_id,`manage`),children:`Policy / listener`}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>Pa(e.node_id),children:`Показать update trail`})]})]},e.node_id)})]}):(0,E.jsx)(`p`,{className:`muted`,children:`Сейчас старых узлов-блокеров нет: compatibility overlap можно снимать только после отдельного решения оператора.`}),ja.length>0&&(0,E.jsxs)(`div`,{className:`stack compact`,children:[(0,E.jsx)(`p`,{className:`muted`,children:`Последние заблокированные попытки rollout`}),ja.map(e=>{let t=F(e.payload)||{},n=I(t,`blocked_operation`,``),r=Ot(t,`stale_nodes`),i=Ot(t,`blocked_nodes`),a=`${H(e.target_type)}${e.target_id?`:${B(e.target_id)}`:``}`,o=[Number.isFinite(r)?`stale ${r}`:``,Number.isFinite(i)?`blockers ${i}`:``].filter(Boolean).join(` / `);return(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Когда`,value:V(e.created_at)}),(0,E.jsx)(A,{label:`Операция`,value:n||e.event_type}),(0,E.jsx)(A,{label:`Цель`,value:a}),(0,E.jsx)(A,{label:`Причина`,value:o||`guard active`})]},e.id)})]}),(0,E.jsxs)(`div`,{className:`stack compact`,children:[(0,E.jsx)(`p`,{className:`muted`,children:"Operator smoke: этот action намеренно пытается создать breaking release через HTTP Control API и должен получить blocked `409`, пока stale recovery-risk узлы ещё есть. Это проверка guard и frontend error formatting, а не transport Fabric."}),(0,E.jsxs)(`div`,{className:`membershipList`,children:[(0,E.jsx)(`span`,{className:`pill`,children:`smoke`}),Qi?Qi.includes(`unexpectedly succeeded`)?(0,E.jsx)(`span`,{className:`pill bad`,children:`unexpected success`}):(0,E.jsxs)(`span`,{className:`pill good`,children:[`expected blocked 409`,ea?` · ${Zn(ea)}`:``]}):(0,E.jsx)(`span`,{className:`pill info`,children:`not checked`})]}),(0,E.jsx)(`div`,{className:`actions`,children:(0,E.jsx)(`button`,{className:`ghost`,disabled:!T||M.summary.blocked_nodes<1||Vr,onClick:()=>Jn(`Проверить legacy-removal guard через blocked release smoke`)&&void Qa(),children:`Проверить blocked 409`})}),Qi&&(0,E.jsxs)(`div`,{className:`noticePanel ${Qi.includes(`unexpectedly succeeded`)?`badPanel`:`warnPanel`}`,children:[(0,E.jsx)(`strong`,{children:`Guard smoke`}),(0,E.jsx)(`div`,{children:Qi}),ea&&(0,E.jsxs)(`div`,{className:`muted`,children:[`Проверено: `,V(ea)]})]})]})]})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Control/API access`}),(0,E.jsxs)(`div`,{className:`stateGrid`,children:[(0,E.jsx)(A,{label:`Entry nodes`,value:`${Wt(`entry-node`,Je).length} узл.`}),(0,E.jsx)(A,{label:`Relay nodes`,value:`${Wt(`relay-node`,Je).length} узл.`}),(0,E.jsx)(A,{label:`Core mesh`,value:`${Wt(`core-mesh`,Je).length} узл.`})]}),(0,E.jsx)(`p`,{className:`muted`,children:`Это слой входа в Control/API, а не transport fabric. Панель кластера не переезжает автоматически на storage-узел: admin/runtime access назначается отдельной ролью на ingress/admin-capable узле.`})]})]})]}),C===`nodes`&&(0,E.jsxs)(`section`,{className:`grid two`,children:[(0,E.jsxs)(`article`,{className:`card span2`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:J.nodeManagement}),(0,E.jsx)(`p`,{className:`muted`,children:`Единый краткий список узлов. По умолчанию показан активный кластер; включите общий режим, чтобы увидеть весь инвентарь платформы.`})]}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsxs)(`label`,{className:`checkLine`,children:[(0,E.jsx)(`input`,{type:`checkbox`,checked:li===`all`,onChange:e=>ui(e.target.checked?`all`:`cluster`)}),J.showAllPlatformNodes]}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>{ui(`all`),fi(``)},children:J.showAllPlatformNodes})]})]}),(0,E.jsxs)(`div`,{className:`signalStrip compact`,children:[(0,E.jsx)(ge,{label:`Узлы активного кластера`,value:String(j.length)}),(0,E.jsx)(ge,{label:`Все узлы`,value:String(Ta.length)}),(0,E.jsx)(ge,{label:`Заявки`,value:String(io)}),(0,E.jsx)(ge,{label:`Активные роли`,value:String(so)})]}),(0,E.jsx)(`p`,{className:`muted`,children:J.addNodeText})]}),(0,E.jsxs)(`article`,{className:`card span2`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:J.nodeBriefList}),(0,E.jsx)(`p`,{className:`muted`,children:J.nodeBriefListHelp})]}),(0,E.jsx)(`span`,{className:`pill`,children:Da.length})]}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[J.nodeSearch,(0,E.jsx)(`input`,{value:di,onChange:e=>fi(e.target.value),placeholder:J.nodeSearchPlaceholder})]}),(0,E.jsxs)(`label`,{children:[J.nodeGroupFilter,(0,E.jsxs)(`select`,{value:pi,onChange:e=>mi(e.target.value),children:[(0,E.jsx)(`option`,{value:``,children:J.allNodeGroups}),Oe.map(e=>(0,E.jsx)(`option`,{value:e.id,children:Bt(e,Oe)},e.id))]})]})]}),(0,E.jsx)(`p`,{className:`muted`,children:J.nodeGroupInventoryText}),(0,E.jsx)(`h4`,{children:J.nodeGroupCreatePanel}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[J.nodeGroupName,(0,E.jsx)(`input`,{value:Zr.name,onChange:e=>Qr({...Zr,name:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[J.parentNodeGroup,(0,E.jsxs)(`select`,{value:Zr.parentGroupId,onChange:e=>Qr({...Zr,parentGroupId:e.target.value}),children:[(0,E.jsx)(`option`,{value:``,children:J.rootNodeGroup}),Oe.map(e=>(0,E.jsx)(`option`,{value:e.id,children:Bt(e,Oe)},e.id))]})]}),(0,E.jsxs)(`label`,{children:[J.createNodeGroup,(0,E.jsx)(`button`,{className:`primary`,disabled:!Zr.name.trim(),onClick:()=>void Y(async()=>{await q.createNodeGroup(T,{name:Zr.name,parentGroupId:Zr.parentGroupId||null}),Qr({name:``,parentGroupId:``})},J.nodeGroupCreated),children:J.createNodeGroup})]})]}),(0,E.jsxs)(`div`,{className:`nodeList`,children:[Aa.map(e=>{if(e.kind===`group`){let t=hi.includes(e.key);return(0,E.jsxs)(`div`,{className:`nodeListGroup`,style:{paddingLeft:`${e.depth*18}px`},children:[(0,E.jsxs)(`div`,{className:`nodeListMain`,children:[(0,E.jsx)(`strong`,{children:e.label}),e.groupId&&(0,E.jsx)(`span`,{children:Vt(e.groupId,Oe)})]}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`span`,{className:`pill`,children:e.count}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>gi(On(hi,e.key)),children:t?J.expandGroup:J.collapseGroup}),e.groupId&&(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>Qr({name:``,parentGroupId:e.groupId||``}),children:J.createSubgroup})]})]},e.key)}let t=e.entry,n=t.memberships.find(e=>e.cluster.id===T),r=n?.node||t.node,i=Dt(r,st[r.id]||[],bt),a=at(r,We[r.id],Ie),o=gt(Ke[r.id]||[]),s=n?.node.membership_status===`active`,c=n?.node.membership_status===`revoked`;return(0,E.jsxs)(`div`,{className:`nodeListRow`,style:{marginLeft:`${e.depth*18}px`},children:[(0,E.jsxs)(`div`,{className:`nodeListMain`,children:[(0,E.jsx)(`strong`,{children:r.name}),(0,E.jsx)(`span`,{children:r.node_key}),(0,E.jsx)(`small`,{className:`muted`,children:i.address})]}),(0,E.jsx)(_e,{value:r.health_status}),(0,E.jsx)(we,{runtime:i}),(0,E.jsxs)(`div`,{className:`nodeEndpointCell`,children:[(0,E.jsx)(`strong`,{children:r.reported_version||`версия неизвестна`}),(0,E.jsx)(`small`,{children:a.targetLabel})]}),(0,E.jsx)(_e,{value:a.status}),(0,E.jsxs)(`div`,{className:`nodeEndpointCell`,children:[(0,E.jsx)(`strong`,{className:`pill ${o.tone}`,children:o.label}),(0,E.jsx)(`small`,{children:o.detail})]}),(0,E.jsx)(`span`,{className:`muted`,children:V(r.last_seen_at)}),n?(0,E.jsx)(_e,{value:n.node.membership_status}):(0,E.jsx)(`span`,{className:`muted`,children:J.notMemberOfActiveCluster}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`button`,{onClick:()=>{wi(t),Ei(`details`)},children:J.nodeDetails}),s?(0,E.jsxs)(E.Fragment,{children:[(0,E.jsx)(`button`,{className:`primary`,onClick:()=>{wi(t),Ei(`manage`)},children:J.manageNode}),(0,E.jsx)(`button`,{className:`danger`,onClick:()=>Jn(`Удалить узел ${r.name} из кластера`)&&void Y(()=>q.deleteClusterNode(T,r.id,`Удалено из списка узлов панели владельца платформы.`),`Узел удален из кластера.`),children:`Удалить`})]}):c?(0,E.jsx)(`span`,{className:`muted`,children:J.revokedMembership}):(0,E.jsx)(`button`,{className:`primary`,onClick:()=>{bi(t),Si([])},children:J.connectExistingNode})]})]},e.key)}),Aa.length===0&&(0,E.jsx)(ye,{title:J.noNodesTitle,text:J.noNodesByFilter})]})]}),yi&&(0,E.jsx)(`div`,{className:`modalBackdrop`,role:`presentation`,children:(0,E.jsxs)(`div`,{className:`modalCard`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`attach-node-title`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{id:`attach-node-title`,children:J.connectExistingNodeTitle}),(0,E.jsx)(`p`,{className:`muted`,children:J.connectExistingNodeText})]}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>bi(null),children:J.cancel})]}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Узел`,value:yi.node.name}),(0,E.jsx)(A,{label:`Node key`,value:yi.node.node_key}),(0,E.jsx)(A,{label:J.activeCluster,value:ha?.name||T})]}),(0,E.jsx)(`div`,{className:`checkGrid`,children:ae.map(e=>(0,E.jsxs)(`label`,{className:`checkLine`,children:[(0,E.jsx)(`input`,{type:`checkbox`,checked:xi.includes(e),onChange:()=>Si(On(xi,e))}),rt(e)]},e))}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`button`,{className:`primary`,onClick:()=>void Y(async()=>{await q.attachExistingNode(T,yi.node.id,xi),bi(null),Si([]),ui(`cluster`)},`Узел подключен к активному кластеру.`),children:J.connectWithRoles}),(0,E.jsx)(`button`,{onClick:()=>bi(null),children:J.cancel})]})]})}),Ci&&(()=>{let e=Ci.memberships.find(e=>e.cluster.id===T),t=e?.node||Ci.node,n=e?(st[t.id]||[])[0]:void 0,r=e?(Je[t.id]||[]).filter(e=>e.status===`active`):[],i=e&&Xe[t.id]||[],a=e&&nt[t.id]||[];return(0,E.jsx)(`div`,{className:`modalBackdrop`,role:`presentation`,children:(0,E.jsxs)(`div`,{className:`modalCard wide`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`node-info-title`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsxs)(`h3`,{id:`node-info-title`,children:[Ti===`manage`?J.manageNode:J.nodeDetails,`: `,t.name]}),(0,E.jsx)(`p`,{className:`muted`,children:t.node_key})]}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>{wi(null),Ei(`details`)},children:J.close})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:J.nodeIdentity}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Node ID`,value:B(t.id)}),(0,E.jsx)(A,{label:`Ключ узла`,value:t.node_key}),(0,E.jsx)(A,{label:`Тип владения`,value:H(t.ownership_type)}),(0,E.jsx)(A,{label:`Owner org`,value:B(t.owner_organization_id)}),(0,E.jsx)(A,{label:`Регистрация`,value:H(t.registration_status)}),(0,E.jsx)(A,{label:`Здоровье`,value:H(t.health_status)}),(0,E.jsx)(A,{label:`Версия`,value:t.reported_version||`неизвестно`}),(0,E.jsx)(A,{label:`Последний сигнал`,value:V(t.last_seen_at)})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:J.clusterMemberships}),(0,E.jsx)(`div`,{className:`membershipList`,children:Ci.memberships.map(e=>(0,E.jsxs)(`span`,{className:e.cluster.id===T?`pill good`:`pill`,children:[e.cluster.name,`: `,H(e.node.membership_status)]},e.cluster.id))})]}),e?(0,E.jsxs)(E.Fragment,{children:[(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:J.activeClusterScope}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Участие`,value:H(t.membership_status)}),(0,E.jsx)(A,{label:`Сегмент`,value:H(t.partition_state)}),(0,E.jsx)(A,{label:`Группа`,value:t.node_group_name||J.ungroupedNodes}),(0,E.jsx)(A,{label:`Ролей`,value:String(r.length)}),(0,E.jsx)(A,{label:`Desired-сервисов`,value:String(i.length)}),(0,E.jsx)(A,{label:`Observed-сервисов`,value:String(a.length)})]})]}),Ti===`details`&&(0,E.jsx)(Se,{node:t,memberships:Ci.memberships,activeRoles:r,desiredWorkloads:i,observedWorkloads:a,heartbeats:st[t.id]||[],telemetry:ut[t.id]||[],updatePlan:We[t.id],updateStatuses:Ke[t.id]||[],meshLinks:bt.filter(e=>e.source_node_id===t.id||e.target_node_id===t.id),syntheticConfig:Tt[t.id],allNodes:j,onSetUpdatePolicy:(e,t,n)=>void Y(async()=>{await q.upsertNodeUpdatePolicy(T,e.id,{product:t,channel:`dev`,targetVersion:n,strategy:`rolling`,enabled:!0,rollbackAllowed:!0,healthWindowSeconds:180})},n?`${t} поставлен в target ${n}.`:`${t} будет следовать latest dev.`),labels:J}),Ti===`manage`&&(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:J.nodeFunctions}),(0,E.jsx)(`p`,{className:`muted`,children:J.nodeFunctionsText}),(0,E.jsxs)(`label`,{className:`wideLabel`,children:[J.organizationScopeForEnable,(0,E.jsx)(`input`,{value:si,onChange:e=>ci(e.target.value),placeholder:J.clusterWideRolePlaceholder})]}),(0,E.jsx)(`div`,{className:`functionList`,children:ae.map(e=>{let o=r.find(t=>t.role===e),s=i.find(t=>t.service_type===e),c=a.find(t=>t.service_type===e),l=Pn(e,n),u=s?.desired_state||`not_configured`,f=c?.reported_state||`missing`,p=!!o&&u===`enabled`;return(0,E.jsxs)(`div`,{className:`functionRow`,children:[(0,E.jsxs)(`div`,{className:`nodeListMain`,children:[(0,E.jsx)(`strong`,{children:rt(e)}),(0,E.jsx)(`span`,{children:In(e,n,d)})]}),(0,E.jsx)(ve,{label:J.rolePermission,value:o?J.permissionGranted:J.permissionDenied,tone:o?`info`:``}),(0,E.jsx)(ve,{label:J.desiredRuntime,value:H(u),tone:u===`enabled`?`good`:``}),(0,E.jsx)(ve,{label:J.observedRuntime,value:H(f),tone:f===`running`?`good`:f===`missing`?`warn`:``}),(0,E.jsx)(`span`,{className:`pill ${l}`,children:Fn(e,n,J)}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`button`,{className:p?``:`primary`,disabled:p,onClick:()=>void Y(async()=>{o||await q.setRoleStatus(T,t.id,e,`active`,si||void 0),await q.setDesiredWorkload(T,t.id,e,{desiredState:`enabled`,runtimeMode:`container`,config:{},environment:{}})},`${e}: функция включена.`),children:J.enableFunction}),(0,E.jsx)(`button`,{disabled:!o&&u!==`enabled`,onClick:()=>void Y(async()=>{await q.setDesiredWorkload(T,t.id,e,{desiredState:`disabled`,runtimeMode:s?.runtime_mode||`container`,config:s?.config||{},environment:s?.environment||{}}),o&&await q.setRoleStatus(T,t.id,e,`disabled`,o.organization_id||void 0)},`${e}: функция выключена.`),children:J.disableFunction})]})]},e)})}),(()=>{let e=i.find(e=>e.service_type===`mesh-listener`)?.config||{},n=ji[t.id]||{listenAddr:String(e.listen_addr||``),mode:String(e.listen_port_mode||`auto`),autoRange:`${Number(e.auto_port_start||19131)}-${Number(e.auto_port_end||19231)}`,advertiseEndpoint:String(e.advertise_endpoint||``),endpointCandidates:Mt(e),advertiseTransport:String(e.advertise_transport||`direct_quic`),connectivity:String(e.connectivity_mode||`private_lan`),nat:String(e.nat_type||`none`),region:String(e.region||``)},r=e=>Mi({...ji,[t.id]:{...n,...e}});return(0,E.jsxs)(`section`,{className:`nodePanel nestedPanel`,children:[(0,E.jsx)(`h4`,{children:`Mesh listener`}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[`Listen addr`,(0,E.jsx)(`input`,{value:n.listenAddr,onChange:e=>r({listenAddr:e.target.value}),placeholder:`0.0.0.0:19131 или :19131`})]}),(0,E.jsxs)(`label`,{children:[`Port mode`,(0,E.jsxs)(`select`,{value:n.mode,onChange:e=>r({mode:e.target.value}),children:[(0,E.jsx)(`option`,{value:`auto`,children:`auto`}),(0,E.jsx)(`option`,{value:`manual`,children:`manual`}),(0,E.jsx)(`option`,{value:`disabled`,children:`disabled`})]})]}),(0,E.jsxs)(`label`,{children:[`Auto ports`,(0,E.jsx)(`input`,{value:n.autoRange,onChange:e=>r({autoRange:e.target.value}),placeholder:`19131-19231`})]}),(0,E.jsxs)(`label`,{children:[`Advertise endpoint`,(0,E.jsx)(`input`,{value:n.advertiseEndpoint,onChange:e=>r({advertiseEndpoint:e.target.value}),placeholder:`quic://192.168.200.85:18080`})]}),(0,E.jsxs)(`label`,{className:`wideLabel`,children:[`Endpoint candidates`,(0,E.jsx)(`textarea`,{value:n.endpointCandidates,onChange:e=>r({endpointCandidates:e.target.value}),rows:5,placeholder:`quic://192.168.200.85:18080 reachability=private connectivity=private_lan interface=lan +quic://94.141.118.222:19199 reachability=public connectivity=direct provider=isp1 maps_to=192.168.200.85:18080`}),(0,E.jsx)(`small`,{children:`Одна строка = один адрес узла. Можно указать provider, interface, maps_to, priority, reachability, connectivity, nat.`})]}),(0,E.jsxs)(`label`,{children:[`Advertise transport`,(0,E.jsxs)(`select`,{value:n.advertiseTransport,onChange:e=>r({advertiseTransport:e.target.value}),children:[(0,E.jsx)(`option`,{value:`direct_quic`,children:`direct_quic`}),(0,E.jsx)(`option`,{value:`relay_quic`,children:`relay_quic`}),(0,E.jsx)(`option`,{value:`outbound_reverse`,children:`outbound_reverse`})]})]}),(0,E.jsxs)(`label`,{children:[`Connectivity`,(0,E.jsxs)(`select`,{value:n.connectivity,onChange:e=>r({connectivity:e.target.value}),children:[(0,E.jsx)(`option`,{value:`private_lan`,children:`private_lan`}),(0,E.jsx)(`option`,{value:`direct`,children:`direct`}),(0,E.jsx)(`option`,{value:`outbound_only`,children:`outbound_only`}),(0,E.jsx)(`option`,{value:`relay_required`,children:`relay_required`})]})]}),(0,E.jsxs)(`label`,{children:[`NAT`,(0,E.jsxs)(`select`,{value:n.nat,onChange:e=>r({nat:e.target.value}),children:[(0,E.jsx)(`option`,{value:`none`,children:`none`}),(0,E.jsx)(`option`,{value:`unknown`,children:`unknown`}),(0,E.jsx)(`option`,{value:`port_restricted`,children:`port_restricted`}),(0,E.jsx)(`option`,{value:`symmetric`,children:`symmetric`})]})]}),(0,E.jsxs)(`label`,{children:[`Region/site`,(0,E.jsx)(`input`,{value:n.region,onChange:e=>r({region:e.target.value}),placeholder:`dc1, office, docker-test`})]})]}),(0,E.jsx)(`div`,{className:`actions`,children:(0,E.jsx)(`button`,{className:`primary`,onClick:()=>void Y(async()=>{let[e,r]=n.autoRange.split(`-`).map(e=>Number(e.trim())),i=Number.isFinite(e)?e:19131,a=Number.isFinite(r)?r:i,o=Nt(n,t.id),s=o.map(e=>String(e.address||``)).filter(Boolean),c=n.advertiseEndpoint.trim().replace(/\/$/,``)||s[0]||null;await q.setDesiredWorkload(T,t.id,`mesh-listener`,{desiredState:n.mode===`disabled`?`disabled`:`enabled`,version:`listener-${Date.now()}`,runtimeMode:`container`,config:{listen_addr:n.listenAddr,listen_port_mode:n.mode,auto_port_start:i,auto_port_end:a,advertise_endpoint:c,advertise_endpoints:s,endpoint_candidates:o,advertise_transport:n.advertiseTransport||`direct_quic`,connectivity_mode:n.connectivity,nat_type:n.nat,region:n.region||null},environment:{}})},`Mesh listener config обновлен.`),children:`Применить listener`})})]})})(),(0,E.jsx)(`div`,{className:`actions`,children:(0,E.jsxs)(`select`,{value:t.node_group_id||``,onChange:e=>void Y(()=>q.assignNodeGroup(T,t.id,e.target.value||null),e.target.value?`Узел перемещен в группу.`:`Узел убран из группы.`),children:[(0,E.jsx)(`option`,{value:``,children:J.ungroupedNodes}),Oe.map(e=>(0,E.jsx)(`option`,{value:e.id,children:Bt(e,Oe)},e.id))]})}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`button`,{onClick:()=>Jn(`Отключить участие узла ${t.name}`)&&void Y(()=>q.disableMembership(T,t.id,`Отключено из панели владельца платформы.`),`Участие узла отключено.`),children:`Отключить участие`}),(0,E.jsx)(`button`,{className:`danger`,onClick:()=>Jn(`Отозвать identity узла ${t.name}`)&&void Y(()=>q.revokeNodeIdentity(T,t.id,`Отозвано из панели владельца платформы.`),`Identity узла отозван.`),children:`Отозвать identity`})]})]})]}):(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:J.noActiveClusterMembership}),(0,E.jsx)(`div`,{className:`actions`,children:(0,E.jsx)(`button`,{className:`primary`,onClick:()=>{bi(Ci),Si([]),wi(null)},children:J.connectExistingNode})})]})]})})})(),!1]}),C===`enrollment`&&(0,E.jsxs)(`section`,{className:`grid two`,children:[(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:J.joinTokenTitle}),(0,E.jsx)(`p`,{className:`muted`,children:J.joinTokenText}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[J.ttlHours,(0,E.jsx)(`input`,{type:`number`,min:1,max:720,value:U.ttlHours,onChange:e=>W({...U,ttlHours:Number(e.target.value)})}),(0,E.jsx)(`small`,{children:J.ttlHelp})]}),(0,E.jsxs)(`label`,{children:[J.maxUses,(0,E.jsx)(`input`,{type:`number`,min:1,max:100,value:U.maxUses,onChange:e=>W({...U,maxUses:Number(e.target.value)})}),(0,E.jsx)(`small`,{children:J.maxUsesHelp})]}),(0,E.jsxs)(`label`,{children:[J.nodeOwnership,(0,E.jsxs)(`select`,{value:U.ownershipType,onChange:e=>W({...U,ownershipType:e.target.value}),children:[(0,E.jsx)(`option`,{value:`platform_managed`,children:`platform_managed, управляется платформой`}),(0,E.jsx)(`option`,{value:`customer_managed`,children:`customer_managed, управляется клиентом`})]})]}),(0,E.jsxs)(`label`,{children:[J.tokenPurpose,(0,E.jsx)(`input`,{value:U.purpose,onChange:e=>W({...U,purpose:e.target.value}),placeholder:`например: стартовый entry-node в ru-msk-1`})]}),(0,E.jsxs)(`label`,{children:[`Имя нового узла`,(0,E.jsx)(`input`,{value:U.nodeName,onChange:e=>W({...U,nodeName:e.target.value}),placeholder:rn(U,ha)}),(0,E.jsx)(`small`,{children:`Если оставить пустым, панель подставит имя автоматически.`})]}),(0,E.jsxs)(`label`,{children:[`Группа узла`,(0,E.jsxs)(`select`,{value:U.nodeGroupId,onChange:e=>W({...U,nodeGroupId:e.target.value}),children:[(0,E.jsx)(`option`,{value:``,children:`Без группы`}),Oe.map(e=>(0,E.jsx)(`option`,{value:e.id,children:Bt(e,Oe)},e.id))]})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Install profile`}),(0,E.jsx)(`p`,{className:`muted`,children:`Эти поля попадут в install profile. Для Windows без админ-прав будет создан user startup task, с админ-правами - system startup task.`}),(0,E.jsx)(`div`,{className:`segmented`,children:[[`docker`,`Docker Linux`],[`linux_binary`,`Ubuntu service`],[`windows_service`,`Windows`]].map(([e,t])=>(0,E.jsx)(`button`,{type:`button`,className:U.installMode===e?`active`:``,onClick:()=>W({...U,installMode:e}),children:t},e))}),(0,E.jsx)(`div`,{className:`segmented`,children:[[`private_lan`,`LAN`],[`direct`,`Public`],[`nat_forward`,`NAT`],[`outbound_only`,`Outbound`]].map(([e,t])=>(0,E.jsx)(`button`,{type:`button`,className:tn(U)===e?`active`:``,onClick:()=>W(nn(U,e)),children:t},e))}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[`Control-plane endpoint`,(0,E.jsx)(`input`,{value:U.controlPlaneEndpoint,onChange:e=>W({...U,controlPlaneEndpoint:e.target.value}),placeholder:Zt()})]}),(0,E.jsxs)(`label`,{children:[U.installMode===`windows_service`?`Windows node-agent artifact`:U.installMode===`linux_binary`?`Linux node-agent artifact`:`Docker image`,(0,E.jsx)(`input`,{value:U.dockerImage,onChange:e=>W({...U,dockerImage:e.target.value})})]}),U.installMode===`windows_service`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsxs)(`label`,{children:[`Windows startup`,(0,E.jsxs)(`select`,{value:U.windowsStartupMode,onChange:e=>W({...U,windowsStartupMode:e.target.value}),children:[(0,E.jsx)(`option`,{value:`auto`,children:`auto: system task, fallback user task`}),(0,E.jsx)(`option`,{value:`system-task`,children:`system task, admin required`}),(0,E.jsx)(`option`,{value:`user-task`,children:`user task, no admin`}),(0,E.jsx)(`option`,{value:`none`,children:`none`})]})]}),(0,E.jsxs)(`label`,{children:[`Install dir`,(0,E.jsx)(`input`,{value:U.windowsInstallDir,onChange:e=>W({...U,windowsInstallDir:e.target.value}),placeholder:`C:\\\\Program Files\\\\RAP\\\\node-name`})]}),(0,E.jsxs)(`label`,{children:[`Windows node-agent SHA256`,(0,E.jsx)(`input`,{value:U.windowsNodeAgentSHA256,onChange:e=>W({...U,windowsNodeAgentSHA256:e.target.value}),placeholder:`опционально, но желательно для production`})]})]}),U.installMode===`linux_binary`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsxs)(`label`,{children:[`Linux install dir`,(0,E.jsx)(`input`,{value:U.linuxInstallDir,onChange:e=>W({...U,linuxInstallDir:e.target.value}),placeholder:`/opt/rap/node-name`})]}),(0,E.jsxs)(`label`,{children:[`Linux node-agent SHA256`,(0,E.jsx)(`input`,{value:U.linuxNodeAgentSHA256,onChange:e=>W({...U,linuxNodeAgentSHA256:e.target.value}),placeholder:`опционально, но желательно для production`})]})]}),U.installMode===`docker`&&(0,E.jsxs)(`label`,{children:[`Container name`,(0,E.jsx)(`input`,{value:U.dockerContainerName,onChange:e=>W({...U,dockerContainerName:e.target.value}),placeholder:an(U,ha)})]}),(0,E.jsxs)(`label`,{children:[`Artifact endpoints`,(0,E.jsx)(`input`,{value:U.artifactEndpoints,onChange:e=>W({...U,artifactEndpoints:e.target.value}),placeholder:Qt()}),(0,E.jsx)(`small`,{children:`Через запятую: public/LAN/cache узлы, где host-agent сможет скачать image tar до входа в mesh.`})]}),U.installMode===`docker`&&(0,E.jsxs)(`label`,{children:[`Docker image tar SHA256`,(0,E.jsx)(`input`,{value:U.dockerImageArtifactSHA256,onChange:e=>W({...U,dockerImageArtifactSHA256:e.target.value}),placeholder:`опционально, но желательно для production`})]}),U.installMode===`docker`&&(0,E.jsxs)(`label`,{children:[`Docker network`,(0,E.jsxs)(`select`,{value:U.dockerNetwork,onChange:e=>W({...U,dockerNetwork:e.target.value}),children:[(0,E.jsx)(`option`,{value:`host`,children:`host`}),(0,E.jsx)(`option`,{value:`bridge`,children:`bridge`})]})]}),(0,E.jsxs)(`label`,{children:[`Listen addr`,(0,E.jsx)(`input`,{value:U.meshListenAddr,onChange:e=>W({...U,meshListenAddr:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[`Listen mode`,(0,E.jsxs)(`select`,{value:U.meshListenPortMode,onChange:e=>W({...U,meshListenPortMode:e.target.value}),children:[(0,E.jsx)(`option`,{value:`auto`,children:`auto`}),(0,E.jsx)(`option`,{value:`manual`,children:`manual`}),(0,E.jsx)(`option`,{value:`disabled`,children:`disabled`})]})]}),(0,E.jsxs)(`label`,{children:[`Auto ports`,(0,E.jsx)(`input`,{value:`${U.meshListenAutoPortStart}-${U.meshListenAutoPortEnd}`,onChange:e=>{let[t,n]=e.target.value.split(`-`).map(e=>Number(e.trim()));W({...U,meshListenAutoPortStart:Number.isFinite(t)?t:U.meshListenAutoPortStart,meshListenAutoPortEnd:Number.isFinite(n)?n:U.meshListenAutoPortEnd})}})]}),(0,E.jsxs)(`label`,{children:[`Advertise endpoint`,(0,E.jsx)(`input`,{value:U.meshAdvertiseEndpoint,onChange:e=>W({...U,meshAdvertiseEndpoint:e.target.value}),placeholder:`quic://192.168.200.85:18080`})]}),(0,E.jsxs)(`label`,{className:`wideLabel`,children:[`Endpoint candidates`,(0,E.jsx)(`textarea`,{value:U.meshAdvertiseEndpoints,onChange:e=>W({...U,meshAdvertiseEndpoints:e.target.value}),rows:5,placeholder:`quic://192.168.200.85:18080 reachability=private connectivity=private_lan nat=none interface=lan priority=1 +quic://94.141.118.222:19199 reachability=public connectivity=direct nat=port_restricted provider=isp1 maps_to=192.168.200.85:18080 priority=2`}),(0,E.jsx)(`small`,{children:`Сюда вносим все реальные адреса узла: LAN, разные адаптеры, публичные UDP NAT-пробросы разных провайдеров.`})]}),(0,E.jsxs)(`label`,{children:[`Advertise transport`,(0,E.jsxs)(`select`,{value:U.meshAdvertiseTransport,onChange:e=>W({...U,meshAdvertiseTransport:e.target.value}),children:[(0,E.jsx)(`option`,{value:`direct_quic`,children:`direct_quic`}),(0,E.jsx)(`option`,{value:`relay_quic`,children:`relay_quic`}),(0,E.jsx)(`option`,{value:`outbound_reverse`,children:`outbound_reverse`})]})]}),(0,E.jsxs)(`label`,{children:[`Connectivity`,(0,E.jsxs)(`select`,{value:U.meshConnectivityMode,onChange:e=>W({...U,meshConnectivityMode:e.target.value}),children:[(0,E.jsx)(`option`,{value:`direct`,children:`direct`}),(0,E.jsx)(`option`,{value:`private_lan`,children:`private_lan`}),(0,E.jsx)(`option`,{value:`outbound_only`,children:`outbound_only`}),(0,E.jsx)(`option`,{value:`relay_required`,children:`relay_required`})]})]}),(0,E.jsxs)(`label`,{children:[`NAT`,(0,E.jsxs)(`select`,{value:U.meshNATType,onChange:e=>W({...U,meshNATType:e.target.value}),children:[(0,E.jsx)(`option`,{value:`none`,children:`none`}),(0,E.jsx)(`option`,{value:`unknown`,children:`unknown`}),(0,E.jsx)(`option`,{value:`full_cone`,children:`full_cone`}),(0,E.jsx)(`option`,{value:`port_restricted`,children:`port_restricted`}),(0,E.jsx)(`option`,{value:`symmetric`,children:`symmetric`})]})]}),(0,E.jsxs)(`label`,{children:[`Region/site`,(0,E.jsx)(`input`,{value:U.meshRegion,onChange:e=>W({...U,meshRegion:e.target.value})})]}),U.installMode===`docker`&&(0,E.jsxs)(`label`,{className:`checkLine`,children:[(0,E.jsx)(`input`,{type:`checkbox`,checked:U.pullImage,onChange:e=>W({...U,pullImage:e.target.checked})}),`Pull image`]}),(0,E.jsxs)(`label`,{className:`checkLine`,children:[(0,E.jsx)(`input`,{type:`checkbox`,checked:U.replace,onChange:e=>W({...U,replace:e.target.checked})}),`Replace existing install`]})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:J.suggestedRoles}),(0,E.jsx)(`p`,{className:`muted`,children:`Роли записываются в install token и автоматически назначаются узлу при approval. После создания token изменение чекбоксов не меняет уже выданный token.`}),(0,E.jsx)(`div`,{className:`checkGrid`,children:ae.map(e=>(0,E.jsxs)(`label`,{className:`checkLine`,children:[(0,E.jsx)(`input`,{type:`checkbox`,checked:U.roles.includes(e),onChange:()=>W({...U,roles:On(U.roles,e)})}),rt(e)]},e))})]}),(0,E.jsxs)(`details`,{children:[(0,E.jsx)(`summary`,{children:J.generatedScope}),(0,E.jsx)(`p`,{className:`muted`,children:J.generatedScopeHelp}),(0,E.jsx)(`pre`,{className:`codePreview`,children:JSON.stringify(Ca,null,2)})]}),(0,E.jsxs)(`p`,{className:`muted`,children:[J.manualApprovalRequired,`.`]}),(0,E.jsx)(`button`,{className:`primary`,disabled:!T,onClick:()=>void Y(async()=>{ii(await q.createJoinToken(T,{ttlHours:U.ttlHours,maxUses:U.maxUses,scope:Ca}))},`Join token создан.`),children:`Создать install token`}),ri&&(0,E.jsxs)(`div`,{className:`secretOnce`,children:[(0,E.jsx)(`strong`,{children:`Исходный token, возвращается один раз`}),(0,E.jsx)(`code`,{children:ri.token}),(0,E.jsxs)(`span`,{className:`muted`,children:[`Authority key: `,B(ri.authority_signature?.key_fingerprint)]}),(0,E.jsx)(`strong`,{children:`Scope выданного token`}),(0,E.jsx)(`pre`,{className:`codePreview`,children:JSON.stringify(ri.scope,null,2)}),(0,E.jsx)(`strong`,{children:`Docker host-agent install`}),(0,E.jsx)(`pre`,{className:`codePreview`,children:on(ri,ha,wa)}),(0,E.jsx)(`strong`,{children:`Profile-based Docker install`}),(0,E.jsx)(`pre`,{className:`codePreview`,children:sn(ri,ha,wa)}),(0,E.jsx)(`strong`,{children:`Profile-based Ubuntu service install`}),(0,E.jsx)(`pre`,{className:`codePreview`,children:cn(ri,ha,wa)}),(0,E.jsx)(`strong`,{children:`Profile-based Windows PowerShell install`}),(0,E.jsx)(`pre`,{className:`codePreview`,children:ln(ri,ha,wa)}),(0,E.jsx)(`strong`,{children:`Profile-based Windows CMD install`}),(0,E.jsx)(`pre`,{className:`codePreview`,children:un(ri,ha,wa)})]})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Как добавить узел`}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsxs)(`div`,{className:`stateLine`,children:[(0,E.jsx)(`span`,{children:`1`}),(0,E.jsx)(`strong`,{children:`Заполните Docker install profile слева.`})]}),(0,E.jsxs)(`div`,{className:`stateLine`,children:[(0,E.jsx)(`span`,{children:`2`}),(0,E.jsx)(`strong`,{children:`Нажмите “Создать install token”.`})]}),(0,E.jsxs)(`div`,{className:`stateLine`,children:[(0,E.jsx)(`span`,{children:`3`}),(0,E.jsx)(`strong`,{children:`Скопируйте “Profile-based Docker install” и выполните на Docker-хосте.`})]}),(0,E.jsxs)(`div`,{className:`stateLine`,children:[(0,E.jsx)(`span`,{children:`4`}),(0,E.jsx)(`strong`,{children:`Подтвердите join request в этой же вкладке.`})]})]})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Install tokens`}),(0,E.jsx)(N,{columns:[`scope`,`status`,`uses`,`expires`,`created`,`action`],rows:Pe.map(e=>[vt(e),H(e.status),`${e.used_count}/${e.max_uses}`,V(e.expires_at),V(e.created_at),e.status===`active`?(0,E.jsx)(`button`,{className:`danger`,onClick:()=>Jn(`Отозвать install token ${B(e.id)}`)&&void Y(()=>q.revokeJoinToken(T,e.id),`Install token отозван.`),children:`Отозвать`}):(0,E.jsx)(`span`,{className:`muted`,children:H(e.status)})])})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Заявки на подключение`}),(0,E.jsxs)(`div`,{className:`stack`,children:[Me.map(e=>(0,E.jsxs)(`div`,{className:`requestCard`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`strong`,{children:e.node_name}),(0,E.jsx)(`p`,{children:e.node_fingerprint}),(0,E.jsx)(_e,{value:e.status}),e.approval_signature?.key_fingerprint&&(0,E.jsxs)(`small`,{className:`muted`,children:[`approval key `,B(e.approval_signature.key_fingerprint)]})]}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`button`,{disabled:e.status!==`pending`,onClick:()=>void Y(()=>q.approveJoinRequest(T,e.id),`Заявка одобрена.`),children:`Одобрить`}),(0,E.jsx)(`button`,{disabled:e.status!==`pending`,onClick:()=>void Y(()=>q.rejectJoinRequest(T,e.id,`Отклонено из панели владельца платформы.`),`Заявка отклонена.`),children:`Отклонить`})]})]},e.id)),Me.length===0&&(0,E.jsx)(ye,{title:`Нет заявок`,text:`Новые подключения node-agent появятся здесь.`})]})]})]}),C===`roles`&&(0,E.jsxs)(`section`,{className:`stack`,children:[(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Область ролей`}),(0,E.jsx)(`p`,{className:`muted`,children:`Capabilities — технические факты. Роли — явные разрешения. Область организации может ограничивать сервисные роли.`}),(0,E.jsxs)(`label`,{children:[`UUID организации для новых назначений ролей, опционально`,(0,E.jsx)(`input`,{value:si,onChange:e=>ci(e.target.value),placeholder:`пусто = роль на весь кластер`})]})]}),j.map(e=>(0,E.jsxs)(`article`,{className:`card roleRow`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:e.name}),(0,E.jsx)(`p`,{children:tt(Je[e.id]||[])})]}),(0,E.jsxs)(`select`,{defaultValue:``,onChange:t=>{let n=t.target.value;t.currentTarget.value=``,n&&Y(()=>q.assignRole(T,e.id,n,si||void 0),`${n} назначена узлу ${e.name}.`)},children:[(0,E.jsx)(`option`,{value:``,children:`Назначить роль...`}),ae.map(e=>(0,E.jsx)(`option`,{value:e,children:rt(e)},e))]})]},e.id))]}),C===`workloads`&&(0,E.jsxs)(`section`,{className:`grid two`,children:[(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Желаемое состояние сервиса`}),(0,E.jsx)(`p`,{className:`muted`,children:`Здесь задается только желаемое состояние. Runtime-исполнение остается под контролем node-agent и политик.`}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[`Узел`,(0,E.jsxs)(`select`,{value:zi.nodeId,onChange:e=>Bi({...zi,nodeId:e.target.value}),children:[(0,E.jsx)(`option`,{value:``,children:`Выберите узел...`}),j.map(e=>(0,E.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,E.jsxs)(`label`,{children:[`Сервис`,(0,E.jsx)(`select`,{value:zi.serviceType,onChange:e=>Bi({...zi,serviceType:e.target.value}),children:ae.map(e=>(0,E.jsx)(`option`,{value:e,children:rt(e)},e))})]}),(0,E.jsxs)(`label`,{children:[`Желаемое состояние`,(0,E.jsxs)(`select`,{value:zi.desiredState,onChange:e=>Bi({...zi,desiredState:e.target.value}),children:[(0,E.jsx)(`option`,{value:`enabled`,children:`включено`}),(0,E.jsx)(`option`,{value:`disabled`,children:`выключено`})]})]}),(0,E.jsxs)(`label`,{children:[`Режим runtime`,(0,E.jsxs)(`select`,{value:zi.runtimeMode,onChange:e=>Bi({...zi,runtimeMode:e.target.value}),children:[(0,E.jsx)(`option`,{value:`container`,children:`контейнер`}),(0,E.jsx)(`option`,{value:`native`,children:`нативно`})]})]}),(0,E.jsxs)(`label`,{children:[`Версия`,(0,E.jsx)(`input`,{value:zi.version,onChange:e=>Bi({...zi,version:e.target.value})})]})]}),(0,E.jsxs)(`label`,{children:[`Config JSON`,(0,E.jsx)(`textarea`,{value:zi.configJson,onChange:e=>Bi({...zi,configJson:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[`Environment JSON`,(0,E.jsx)(`textarea`,{value:zi.environmentJson,onChange:e=>Bi({...zi,environmentJson:e.target.value})})]}),(0,E.jsx)(`button`,{className:`primary`,disabled:!zi.nodeId||!T,onClick:()=>void Y(()=>q.setDesiredWorkload(T,zi.nodeId,zi.serviceType,{desiredState:zi.desiredState,runtimeMode:zi.runtimeMode,version:zi.version,config:$e(zi.configJson,`config сервиса`),environment:$e(zi.environmentJson,`environment сервиса`)}),`Желаемое состояние сервиса обновлено.`),children:`Задать желаемое состояние`})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Отчеты сервисов`}),(0,E.jsx)(`div`,{className:`stack`,children:j.map(e=>(0,E.jsxs)(`div`,{className:`workloadBlock`,children:[(0,E.jsx)(`strong`,{children:e.name}),(nt[e.id]||[]).length===0?(0,E.jsx)(`p`,{className:`muted`,children:`Статус пока не получен.`}):(0,E.jsx)(N,{columns:[`сервис`,`состояние`,`runtime`,`наблюдение`],rows:(nt[e.id]||[]).map(e=>[e.service_type,e.reported_state,e.runtime_mode,V(e.observed_at)])})]},e.id))})]})]}),C===`fabric`&&(0,E.jsxs)(`section`,{className:`fabricTransportView`,children:[(0,E.jsxs)(`article`,{className:`card fabricMapCard`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:`Транспортный слой Fabric`}),(0,E.jsx)(`p`,{className:`muted`,children:`Карта показывает реальные свежие QUIC-соседства и проверенные relay/route-health маршруты. Прямые связи рисуются сплошной линией, relay и route-health отделены пунктиром, чтобы не смешивать физического соседа и достижимый маршрут.`})]}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[(0,E.jsxs)(`span`,{className:`pill good`,children:[`direct `,bt.length]}),(0,E.jsx)(`span`,{className:`pill`,children:go.label}),(0,E.jsx)(_e,{value:co?.synthetic_links_enabled?`enabled`:`disabled`})]})]}),(0,E.jsx)(Ee,{nodes:j,links:bt,heartbeatsByNode:st,rolesByNode:Je,workloadsByNode:nt,labels:J,emptyText:J.noLinks}),(0,E.jsxs)(`details`,{className:`sectionBlock fabricDiagnostics`,children:[(0,E.jsx)(`summary`,{children:`Диагностика transport/runtime receivers`}),(0,E.jsxs)(`div`,{className:`signalStrip compact`,children:[(0,E.jsx)(ge,{label:`Synthetic configs`,value:`${uo}/${j.length}`}),(0,E.jsx)(ge,{label:`Routes`,value:String(fo)}),(0,E.jsx)(ge,{label:`Endpoints / candidates`,value:`${po}/${mo}`}),(0,E.jsx)(ge,{label:`Scoped production`,value:ho===0?`false`:`true:${ho}`})]}),(0,E.jsx)(N,{columns:[`узел`,`status`,`reason`,`trusted keys`,`service classes`,`QUIC addr`,`ошибка`],rows:j.map(e=>{let t=F(st[e.id]?.[0]?.metadata?.web_ingress_runtime_receiver_report),n=ze(st[e.id]?.[0]),r=t?Yt(t.service_classes):[],i=Ve(Je[e.id]||[]),a=i.length>0&&!i.every(e=>r.includes(e));return[e.name,(0,E.jsx)(`span`,{className:`pill ${n===`ready`?`good`:n===`degraded`?`warn`:n===`blocked`?`bad`:``}`,children:n}),t?I(t,`reason`,`н/д`):`no report`,t?String(Ot(t,`trusted_key_count`)):`0`,(0,E.jsx)(`span`,{className:a?`pill warn`:``,children:t&&r.join(`, `)||`н/д`}),t?I(t,`quic_fabric_listen_addr`,`н/д`):`н/д`,a?`expected: ${i.join(`, `)}`:t&&(I(t,`quic_fabric_error`,``)||I(t,`error`,``))||`—`]})})]})]}),(0,E.jsxs)(`article`,{className:`card span2`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:`Synthetic mesh config`}),(0,E.jsx)(`p`,{className:`muted`,children:`Node-scoped config from the Control/API layer. Endpoint candidates and scoring inputs are visible to the platform owner only; production forwarding for service traffic must remain disabled here.`})]}),(0,E.jsxs)(`span`,{className:ho===0?`pill good`:`pill bad`,children:[`production_forwarding=`,ho===0?`false`:`true`]})]}),(0,E.jsx)(N,{columns:[`узел`,`config`,`routes`,`peer endpoints`,`candidates`,`peer dir`,`recovery seeds`,`rendezvous leases`,`relay policy`,`path decisions`,`authority`,`scoped production`],rows:j.map(e=>{let t=Tt[e.id];return[e.name,t?t.enabled?`enabled`:`disabled`:`не загружен`,String(t?.routes.length??0),String(Object.keys(t?.peer_endpoints||{}).length),String(t?Lt(t):0),String(t?.peer_directory?.length??0),String(t?.recovery_seeds?.length??0),String(t?.rendezvous_leases?.length??0),Rt(t),zt(t),t?.authority_required?B(t.authority_signature?.key_fingerprint):`не требуется`,t?.production_forwarding?`true`:`false`]})}),(0,E.jsx)(`p`,{className:`muted`,children:`Health-aware scoring не выбирает service route и не открывает service-соединения. C17Z19 показывает управляющий route/path decisions, route generation status, synthetic route-health effective path и relay feedback scoring, но не переносит RDP/VPN/file/video/service payload.`})]}),(0,E.jsxs)(`article`,{className:`card span2`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:`Route intents lifecycle`}),(0,E.jsx)(`p`,{className:`muted`,children:`Operator view for temporary fabric routes. Expired and disabled intents are not emitted into node-scoped synthetic config.`})]}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[(0,E.jsxs)(`span`,{className:`pill good`,children:[`active `,_o.length]}),(0,E.jsxs)(`span`,{className:vo.length>0?`pill warn`:`pill`,children:[`expired `,vo.length]}),(0,E.jsxs)(`span`,{className:`pill`,children:[`disabled `,yo.length]})]})]}),(0,E.jsx)(N,{columns:[`route`,`life`,`service`,`priority`,`source`,`destination`,`expires`,`updated`,`actions`],rows:P.slice(0,120).map(e=>{let t=pt(e);return[B(e.id),(0,E.jsx)(`span`,{className:`pill ${mt(e)}`,children:t}),e.service_class,String(e.priority),ht(e.source_selector||{}),ht(e.destination_selector||{}),e.policy_expires_at?V(e.policy_expires_at):`нет`,V(e.updated_at),(0,E.jsxs)(`div`,{className:`inlineActions`,children:[t===`active`?(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>q.expireRouteIntent(T,e.id,`operator expired stale route intent`),`Route intent expired.`),children:`expire`}):(0,E.jsx)(`span`,{className:`muted`,children:`expire`}),t===`disabled`?(0,E.jsx)(`span`,{className:`muted`,children:`disable`}):(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>q.disableRouteIntent(T,e.id,`operator disabled route intent`),`Route intent disabled.`),children:`disable`})]})]})}),P.length===0&&(0,E.jsx)(ye,{title:`Route intents отсутствуют`,text:`Нет настроенных fabric route intents для текущего кластера.`})]}),(0,E.jsxs)(`article`,{className:`card span2`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:`Service-channel route feedback`}),(0,E.jsx)(`p`,{className:`muted`,children:`Cluster-level runtime feedback from the shared fabric channel. Fenced and no-alternate cases affect route selection for any service class.`})]}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[(0,E.jsxs)(`span`,{className:xo.length>0?`pill bad`:`pill good`,children:[`fenced `,xo.length]}),(0,E.jsxs)(`span`,{className:So.length>0?`pill warn`:`pill`,children:[`degraded `,So.length]}),(0,E.jsxs)(`span`,{className:Do.length>0?`pill warn`:`pill`,children:[`retry `,Do.length]}),(0,E.jsxs)(`span`,{className:wo.length>0?`pill warn`:`pill`,children:[`recovered `,wo.length]}),(0,E.jsxs)(`span`,{className:To.length>0?`pill good`:`pill`,children:[`promoted `,To.length]}),(0,E.jsxs)(`span`,{className:Eo.length>0?`pill bad`:`pill`,children:[`demoted `,Eo.length]}),(0,E.jsxs)(`span`,{className:`pill good`,children:[`healthy `,Co.length]}),(0,E.jsxs)(`span`,{className:ko.length>0?`pill bad`:`pill`,children:[`no alternate `,ko.length]}),(0,E.jsxs)(`span`,{className:Io.length>0?`pill warn`:`pill`,children:[`hysteresis `,Io.length]}),(0,E.jsxs)(`span`,{className:Lo.length>0?`pill good`:`pill`,children:[`promoted paths `,Lo.length]}),(0,E.jsxs)(`span`,{className:Ro.length>0?`pill bad`:`pill`,children:[`demoted paths `,Ro.length]}),(0,E.jsxs)(`span`,{className:(jn?.fingerprint||``).length>0?`pill good`:`pill warn`,children:[`policy fp `,jn?.fingerprint?B(jn.fingerprint):`нет`]}),(0,E.jsxs)(`span`,{className:jo.length>Mo.length?`pill warn`:`pill good`,children:[`rebuild `,Mo.length,`/`,jo.length]}),(0,E.jsxs)(`span`,{className:Po.length>0?`pill warn`:`pill good`,children:[`ledger `,No.length,`/`,It.length]}),(0,E.jsxs)(`span`,{className:Fo.length>0?`pill bad`:`pill good`,children:[`guard `,Fo.length]}),(0,E.jsx)(`span`,{className:Tn?`pill info`:`pill`,children:Tn?`deep ledger`:`fast ledger`})]})]}),ko.length>0&&(0,E.jsx)(`div`,{className:`noticePanel`,children:`Есть service-channel route без unfenced alternate. Для production-сервиса это означает деградацию: fabric не нашел безопасную замену и будет ждать нового маршрута или операторского решения.`}),pn&&(0,E.jsxs)(`div`,{className:`noticePanel ${pn.status===`blocked`?`badPanel`:`goodPanel`}`,children:[(0,E.jsxs)(`div`,{className:`cardHead compact`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h4`,{children:`Fabric schema preflight`}),(0,E.jsx)(`p`,{className:`muted`,children:`Backend/runtime compatibility check for manual deploys before diagnostics or service channels depend on new DB fields.`})]}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[(0,E.jsx)(`span`,{className:`pill ${pn.status===`blocked`?`bad`:`good`}`,children:H(pn.status)}),(0,E.jsxs)(`span`,{className:pn.missing_check_count>0?`pill bad`:`pill good`,children:[pn.passed_check_count,`/`,pn.required_check_count]}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>void Ha(),disabled:Vr,children:`warm snapshots`})]})]}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`reason`,value:H(pn.reason)}),(0,E.jsx)(A,{label:`required`,value:pn.required_migration}),(0,E.jsx)(A,{label:`missing`,value:(pn.missing_checks||[]).map(e=>e.check_id).slice(0,4).join(`, `)||`нет`}),(0,E.jsx)(A,{label:`action`,value:pn.recommended_operator_action||`schema is compatible`}),vn&&(0,E.jsx)(A,{label:`warmup`,value:`warmed ${vn.warmed_count}, fresh ${vn.already_fresh_count}, missing ${vn.missing_snapshot_count}, stale ${vn.stale_snapshot_count}, deferred ${vn.deferred_stale_count}, errors ${vn.error_count}`})]})]}),hn&&(0,E.jsxs)(`div`,{className:`noticePanel ${hn.status===`degraded`?`warnPanel`:`goodPanel`}`,children:[(0,E.jsxs)(`div`,{className:`cardHead compact`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h4`,{children:`Snapshot maintenance`}),(0,E.jsx)(`p`,{className:`muted`,children:`Auto-warmup visibility for rebuild snapshot cache after node heartbeats.`})]}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[(0,E.jsx)(`span`,{className:`pill ${hn.status===`degraded`?`warn`:`good`}`,children:H(hn.status)}),(0,E.jsxs)(`span`,{className:hn.overdue_missing_snapshot_count>0?`pill bad`:`pill good`,children:[`overdue `,hn.overdue_missing_snapshot_count]}),(0,E.jsxs)(`span`,{className:hn.auto_warmup_error_count>0?`pill bad`:`pill good`,children:[`auto errors `,hn.auto_warmup_error_count]})]})]}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`reason`,value:H(hn.reason)}),(0,E.jsx)(A,{label:`snapshots`,value:`valid ${hn.valid_snapshot_count}, missing ${hn.missing_snapshot_count}, attempts ${hn.recent_attempt_count}`}),(0,E.jsx)(A,{label:`auto-warmup`,value:`events ${hn.auto_warmup_event_count}, warmed ${hn.auto_warmup_warmed_count}, fresh ${hn.auto_warmup_already_fresh_count}, latest ${V(hn.latest_auto_warmup_at)}`}),(0,E.jsx)(A,{label:`guard`,value:`age ${hn.min_age_seconds}s, heartbeats ${hn.heartbeat_threshold}`}),(0,E.jsx)(A,{label:`action`,value:hn.recommended_operator_action||`snapshot maintenance is current`})]}),(hn.nodes||[]).length>0&&(0,E.jsx)(N,{columns:[`node`,`snapshots`,`heartbeat`,`auto-warmup`,`latest`],rows:(hn.nodes||[]).slice(0,6).map(e=>[L(j,e.node_id),(0,E.jsxs)(`span`,{className:e.overdue_missing_snapshot_count>0?`pill bad`:e.missing_snapshot_count>0?`pill warn`:`pill good`,children:[e.valid_snapshot_count,`/`,e.recent_attempt_count,` overdue `,e.overdue_missing_snapshot_count]}),e.heartbeat_after_attempt_count,`${e.auto_warmup_warmed_count}/${e.auto_warmup_event_count} errors ${e.auto_warmup_error_count}`,V(e.latest_auto_warmup_at||e.last_heartbeat_at)])})]}),bn&&(0,E.jsxs)(`div`,{className:`noticePanel ${bn.status===`degraded`?`warnPanel`:`goodPanel`}`,children:[(0,E.jsxs)(`div`,{className:`cardHead compact`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h4`,{children:`Service-channel leases`}),(0,E.jsx)(`p`,{className:`muted`,children:`Durable compatibility lease records for introspection after backend restarts.`})]}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[(0,E.jsx)(`span`,{className:`pill ${bn.status===`degraded`?`warn`:`good`}`,children:H(bn.status)}),(0,E.jsxs)(`span`,{className:`pill good`,children:[`active `,bn.active_count]}),(0,E.jsxs)(`span`,{className:bn.expired_count>0?`pill warn`:`pill`,children:[`expired `,bn.expired_count]}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>void Ua(),disabled:Vr,children:`cleanup`})]})]}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`reason`,value:H(bn.reason)}),(0,E.jsx)(A,{label:`scanned`,value:`${bn.scanned_count}/${bn.window_limit}`}),(0,E.jsx)(A,{label:`deleted`,value:String(bn.deleted_expired_count||0)}),(0,E.jsx)(A,{label:`action`,value:bn.recommended_operator_action||`lease maintenance is current`})]}),(bn.leases||[]).length>0&&(0,E.jsx)(N,{columns:[`expires`,`resource`,`entry`,`exit`,`route`,`data plane`,`state`],rows:(bn.leases||[]).slice(0,8).map(e=>[V(e.expires_at),e.resource_id||B(e.channel_id),L(j,e.selected_entry_node_id||``),L(j,e.selected_exit_node_id||``),e.primary_route_id?`${B(e.primary_route_id)} / ${H(e.primary_route_status||``)}`:`backend fallback`,`${H(e.data_plane?.working_data_transport||`unknown`)} / ${H(e.data_plane?.backend_relay_policy||`unknown`)}`,(0,E.jsx)(`span`,{className:`pill ${e.expired||e.force_backend_fallback?`warn`:`good`}`,children:e.expired?`expired`:e.force_backend_fallback?`fallback`:H(e.status)})])})]}),R&&(0,E.jsxs)(`div`,{className:`noticePanel ${R.status===`degraded`?`warnPanel`:`goodPanel`}`,children:[(0,E.jsxs)(`div`,{className:`cardHead compact`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h4`,{children:`Service-channel access`}),(0,E.jsx)(`p`,{className:`muted`,children:`Live accepted_by visibility from node telemetry and heartbeat metadata.`})]}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[(0,E.jsx)(`span`,{className:`pill ${R.status===`degraded`?`warn`:`good`}`,children:H(R.status)}),(0,E.jsxs)(`span`,{className:`pill good`,children:[`accepted `,R.total_accepted]}),(0,E.jsxs)(`span`,{className:R.backend_fallback_count>0?`pill warn`:`pill`,children:[`backend `,R.backend_fallback_count]}),(0,E.jsxs)(`span`,{className:(R.backend_fallback_blocked_count||0)>0?`pill bad`:`pill`,children:[`blocked `,R.backend_fallback_blocked_count||0]}),(0,E.jsxs)(`span`,{className:`pill ${R.last_working_data_transport===`fabric_service_channel`?`good`:R.data_plane_contract_count?`warn`:``}`,children:[`data-plane `,R.data_plane_contract_count||0]}),(0,E.jsxs)(`span`,{className:`pill ${R.last_backend_relay_policy===`disabled`?`good`:R.last_backend_relay_policy===`degraded_fallback_only`?`info`:``}`,children:[`relay `,H(R.last_backend_relay_policy||`unknown`)]}),(0,E.jsxs)(`span`,{className:R.degraded_fallback_channel_count>0||R.degraded_route_count>0?`pill warn`:`pill good`,children:[`channels `,R.active_channel_count]}),(0,E.jsxs)(`span`,{className:R.no_safe_recovery_decision_count?`pill warn`:R.route_decision_channel_count?`pill info`:`pill`,children:[`decisions `,R.route_decision_channel_count||0,R.replacement_decision_count?` / repl ${R.replacement_decision_count}`:``,R.applied_rebuild_decision_count?` / applied ${R.applied_rebuild_decision_count}`:``,R.recovery_decision_count?` / recovery ${R.recovery_decision_count}`:``,R.no_safe_recovery_decision_count?` / no-safe ${R.no_safe_recovery_decision_count}`:``]}),(0,E.jsx)(`span`,{className:`pill ${nr(R.flow_health_status,R.flow_dropped)}`,children:er(R.traffic_class_counts)}),(0,E.jsxs)(`span`,{className:`pill ${nr(R.flow_health_status,R.flow_dropped)}`,children:[`flow `,H(R.flow_health_status||`healthy`)]}),(0,E.jsxs)(`span`,{className:`pill ${R.adaptive_backpressure_active?`info`:`good`}`,children:[`windows `,tr(R.recommended_parallel_windows)]})]})]}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`reason`,value:H(R.reason)}),(0,E.jsx)(A,{label:`reporting nodes`,value:`${R.reporting_node_count}/${R.node_count}`}),(0,E.jsx)(A,{label:`accepted by`,value:`signed ${R.signed_accepted}, introspection ${R.introspection_accepted}, legacy ${R.legacy_unsigned_accepted}`}),(0,E.jsx)(A,{label:`data plane`,value:`${R.data_plane_contract_count||0} contracts, mode ${H(R.last_data_plane_mode||`unknown`)}, working ${H(R.last_working_data_transport||`unknown`)}, steady ${H(R.last_steady_state_transport||`unknown`)}, relay ${H(R.last_backend_relay_policy||`unknown`)}, flows ${H(R.last_logical_flow_mode||`unknown`)}, blocked ${R.backend_fallback_blocked_count||0}, route failures ${R.fabric_route_send_failure_count||0}`}),(0,E.jsx)(A,{label:`data-plane violation`,value:R.last_data_plane_violation_status?`${H(R.last_data_plane_violation_status)} / ${R.last_data_plane_violation_reason||`n/a`}`:`none`}),(0,E.jsx)(A,{label:`active channels`,value:`${R.active_channel_count||0}, fallback ${R.degraded_fallback_channel_count||0}, correlated routes ${R.correlated_route_count||0}, degraded routes ${R.degraded_route_count||0}`}),(0,E.jsx)(A,{label:`route decisions`,value:`channels ${R.route_decision_channel_count||0}, replacement ${R.replacement_decision_count||0}, applied ${R.applied_rebuild_decision_count||0}, recovery ${R.recovery_decision_count||0}, no-safe ${R.no_safe_recovery_decision_count||0}`}),(0,E.jsx)(A,{label:`flow QoS`,value:`${H(R.flow_health_status||`healthy`)} / ${H(R.flow_health_reason||`flow_health_ready`)}, ${er(R.traffic_class_counts)}, flows ${R.flow_channel_count||0}, in-flight ${R.flow_max_in_flight||0}, dropped ${R.flow_dropped||0}`}),(0,E.jsx)(A,{label:`adaptive windows`,value:`${R.adaptive_backpressure_active?H(R.adaptive_backpressure_reason||`adaptive`):`off`}, ${tr(R.recommended_parallel_windows)}, policy ${R.adaptive_policy_fingerprint?B(R.adaptive_policy_fingerprint):`n/a`}`}),(0,E.jsx)(A,{label:`latest accepted`,value:V(R.latest_accepted_at)}),(0,E.jsx)(A,{label:`action`,value:R.recommended_operator_action||`access telemetry is current`})]}),(R.active_channels||[]).length>0&&(0,E.jsx)(N,{columns:[`resource`,`entry -> exit`,`route`,`decision`,`entry access`,`data plane`,`flow health`,`windows`,`flow QoS`,`route quality`,`remediation`,`guard`,`execution`,`expires`],rows:(R.active_channels||[]).slice(0,10).map(e=>[e.resource_id||B(e.channel_id),`${L(j,e.selected_entry_node_id||``)} -> ${L(j,e.selected_exit_node_id||``)}`,e.primary_route_id?`${B(e.primary_route_id)} / ${H(e.primary_route_status||``)}`:`backend fallback`,(0,E.jsx)(`span`,{className:`pill ${ir(e.route_decision_source,e.route_decision_rebuild_status,e.route_decision_score_reasons)}`,children:e.route_decision_source?`${H(e.route_decision_source)}${e.route_decision_route_id?` ${B(e.route_decision_route_id)}`:``}${e.route_decision_replacement_route_id?` -> ${B(e.route_decision_replacement_route_id)}`:``}${e.route_decision_rebuild_status?` / ${H(e.route_decision_rebuild_status)}`:``}`:`n/a`}),`accepted ${e.entry_node_total_accepted}, introspection ${e.entry_node_introspection_accepted}, backend ${e.entry_node_backend_fallback_count}`,(0,E.jsx)(`span`,{className:`pill ${e.entry_node_last_working_data_transport===`fabric_service_channel`?`good`:e.entry_node_data_plane_contract_count?`warn`:``}`,children:`${e.entry_node_data_plane_contract_count||0} / ${H(e.entry_node_last_working_data_transport||`unknown`)} / ${H(e.entry_node_last_backend_relay_policy||`unknown`)}${e.entry_node_backend_fallback_blocked_count?` / blocked ${e.entry_node_backend_fallback_blocked_count}`:``}`}),(0,E.jsxs)(`span`,{className:`pill ${nr(e.entry_node_flow_health_status,e.entry_node_flow_dropped)}`,children:[H(e.entry_node_flow_health_status||`healthy`),e.entry_node_flow_health_reason?` / ${H(e.entry_node_flow_health_reason)}`:``]}),(0,E.jsx)(`span`,{className:`pill ${e.entry_node_adaptive_backpressure_active?`info`:`good`}`,children:tr(e.entry_node_recommended_parallel_windows)}),(0,E.jsxs)(`span`,{className:`pill ${nr(e.entry_node_flow_health_status,e.entry_node_flow_dropped)}`,children:[er(e.entry_node_traffic_class_counts),` / flows ${e.entry_node_flow_channel_count||0} / in ${e.entry_node_flow_max_in_flight||0}`]}),(0,E.jsx)(`span`,{className:`pill ${e.force_backend_fallback||e.route_feedback_status===`degraded`||e.route_feedback_status===`fenced`?`warn`:e.route_feedback_status?`good`:``}`,children:e.force_backend_fallback?`backend fallback`:e.route_feedback_status?`${H(e.route_feedback_status)} / ${e.last_send_duration_ms||0}ms / q ${e.route_quality_window_sample_count||0}`:`no route feedback`}),(0,E.jsx)(`span`,{className:`pill ${e.remediation_action===`none`?`good`:e.remediation_action===`prefer_alternate_route`?`warn`:e.remediation_action?`bad`:``}`,children:e.remediation_action?`${e.remediation_command?`cmd `:``}${H(e.remediation_action)}${e.remediation_command?.replacement_route_id?` -> ${B(e.remediation_command.replacement_route_id)}`:e.remediation_route_id?` -> ${B(e.remediation_route_id)}`:``}`:`n/a`}),(0,E.jsxs)(`span`,{className:`pill ${e.remediation_guard_status===`rejected`?`bad`:e.pool_policy_fingerprint?`good`:``}`,children:[e.remediation_guard_status?H(e.remediation_guard_status):e.pool_policy_fingerprint?`pool policy`:`n/a`,e.remediation_guard_reason?` / ${H(e.remediation_guard_reason)}`:``]}),(0,E.jsxs)(`span`,{className:`pill ${rr(e.remediation_execution_status)}`,children:[e.remediation_execution_status?H(e.remediation_execution_status):`n/a`,e.remediation_execution_generation?` / ${B(e.remediation_execution_generation)}`:``,e.remediation_execution_reason?` / ${H(e.remediation_execution_reason)}`:``]}),V(e.expires_at)])}),(R.nodes||[]).length>0&&(0,E.jsx)(N,{columns:[`node`,`accepted`,`signed`,`introspection`,`legacy`,`backend`,`data plane`,`flow health`,`windows`,`flow QoS`,`latest`],rows:(R.nodes||[]).slice(0,10).map(e=>[L(j,e.node_id)||e.node_name||B(e.node_id),e.total_accepted,e.signed_accepted,e.introspection_accepted,e.legacy_unsigned_accepted,(0,E.jsx)(`span`,{className:e.backend_fallback_count>0?`pill warn`:`pill`,children:e.backend_fallback_count}),(0,E.jsx)(`span`,{className:`pill ${e.last_working_data_transport===`fabric_service_channel`?`good`:e.data_plane_contract_count?`warn`:``}`,children:`${e.data_plane_contract_count||0} / ${H(e.last_working_data_transport||`unknown`)} / ${H(e.last_backend_relay_policy||`unknown`)}${e.backend_fallback_blocked_count?` / blocked ${e.backend_fallback_blocked_count}`:``}`}),(0,E.jsxs)(`span`,{className:`pill ${nr(e.flow_health_status,e.flow_dropped)}`,children:[H(e.flow_health_status||`healthy`),e.flow_health_reason?` / ${H(e.flow_health_reason)}`:``]}),(0,E.jsx)(`span`,{className:`pill ${e.adaptive_backpressure_active?`info`:`good`}`,children:tr(e.recommended_parallel_windows)}),(0,E.jsxs)(`span`,{className:`pill ${nr(e.flow_health_status,e.flow_dropped)}`,children:[er(e.traffic_class_counts),` / flows ${e.flow_channel_count||0} / in ${e.flow_max_in_flight||0}`]}),V(e.last_accepted_at||e.observed_at)])})]}),dn&&(0,E.jsxs)(`div`,{className:`noticePanel ${dn.status===`blocked`?`badPanel`:dn.status===`degraded`?`warnPanel`:`goodPanel`}`,children:[(0,E.jsxs)(`div`,{className:`cardHead compact`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h4`,{children:`Fabric service-channel readiness`}),(0,E.jsx)(`p`,{className:`muted`,children:`Verdict for production service-channel use. Working service payloads should not depend on this fabric while the gate is blocked.`})]}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[(0,E.jsx)(`span`,{className:`pill ${dn.status===`blocked`?`bad`:dn.status===`degraded`?`warn`:`good`}`,children:H(dn.status)}),(0,E.jsxs)(`span`,{className:dn.active_alert_count>0?`pill bad`:`pill`,children:[`active `,dn.active_alert_count]}),(0,E.jsxs)(`span`,{className:dn.resurfaced_count>0?`pill bad`:`pill`,children:[`resurfaced `,dn.resurfaced_count]})]})]}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`reason`,value:H(dn.reason)}),(0,E.jsx)(A,{label:`blocking`,value:(dn.blocking_reasons||[]).map(H).join(`, `)||`нет`}),(0,E.jsx)(A,{label:`degraded`,value:(dn.degraded_reasons||[]).map(H).join(`, `)||`нет`}),(0,E.jsx)(A,{label:`missing/post`,value:`transition ${dn.missing_transition_count}, route-gen ${dn.missing_route_generation_count}, traffic ${dn.missing_post_rebuild_traffic_count}`})]})]}),Cn.length>0&&(0,E.jsxs)(`div`,{className:`subPanel`,children:[(0,E.jsxs)(`div`,{className:`cardHead compact`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h4`,{children:`Rebuild incidents`}),(0,E.jsx)(`p`,{className:`muted`,children:`Grouped recent rebuild attempts by reporter, route, service, generation, and guard. Open an incident to load the exact deep ledger slice.`})]}),(0,E.jsx)(`span`,{className:`pill`,children:Cn.length})]}),(0,E.jsx)(N,{columns:[`last`,`source`,`reporter`,`route`,`service`,`guard`,`count`,`replacement`,`action`],rows:Cn.slice(0,10).map(e=>[V(e.last_seen_at),e.incident_source?H(e.incident_source):`ledger`,L(j,e.reporter_node_id),B(e.route_id),e.service_class,(0,E.jsxs)(`span`,{className:`pill ${e.alert_resurfaced||e.guard_severity===`bad`?`bad`:e.guard_severity===`warn`?`warn`:`good`}`,children:[H(e.guard_status),e.alert_silenced?` / silenced`:e.alert_resurfaced?` / resurfaced`:``]}),String(e.attempt_count),e.latest_replacement_route_id?B(e.latest_replacement_route_id):`нет`,(0,E.jsxs)(`div`,{className:`inlineActions`,children:[(0,E.jsxs)(`span`,{children:[H(e.recommended_operator_action||`inspect`),e.alert_resurfaced&&e.alert_resurfaced_cause?` (${H(e.alert_resurfaced_cause)})`:``,e.alert_resurfaced&&e.alert_resurfaced_previous_generation?` from ${B(e.alert_resurfaced_previous_generation)} until ${V(e.alert_resurfaced_previous_until)}`:``]}),(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>Wa(e),`Deep rebuild investigation opened.`),children:`open deep`}),e.alert_silenced?(0,E.jsx)(`span`,{className:`muted`,children:`silenced`}):(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>Ya(e),`Rebuild incident silenced for 6 hours.`),children:`silence 6h`})]})])})]}),(Nn||Fa.length>0)&&(0,E.jsxs)(`div`,{className:`subPanel`,children:[(0,E.jsxs)(`div`,{className:`cardHead compact`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h4`,{children:`Recent investigations`}),(0,E.jsx)(`p`,{className:`muted`,children:`Recent operator drilldowns opened from rebuild incidents or feedback breakdown rows.`})]}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[(0,E.jsx)(`span`,{className:`pill info`,children:jr?.total_count||Fa.length}),(0,E.jsxs)(`span`,{className:`pill good`,children:[`linked `,jr?.correlated_count||0]}),(0,E.jsxs)(`span`,{className:(jr?.not_visible_count||0)>0?`pill warn`:`pill`,children:[`not visible `,jr?.not_visible_count||0]}),Object.entries(jr?.counts_by_breadcrumb_status||{}).map(([e,t])=>(0,E.jsxs)(`span`,{className:e===`current`?`pill good`:e===`stale`?`pill warn`:`pill bad`,children:[H(e),` `,t]},e)),Object.entries(jr?.counts_by_current_diagnostic_status||{}).slice(0,3).map(([e,t])=>(0,E.jsxs)(`span`,{className:e===`breakdown_active`||e===`incident_visible`?`pill good`:e===`not_visible`?`pill warn`:`pill`,children:[H(e),` `,t]},e))]})]}),Nn&&(0,E.jsxs)(`div`,{className:`inlineForm`,children:[(0,E.jsxs)(`label`,{children:[`current window, sec`,(0,E.jsx)(`input`,{type:`number`,min:`60`,value:ti.currentWindowSeconds,onChange:e=>ni(t=>({...t,currentWindowSeconds:e.target.value}))})]}),(0,E.jsxs)(`label`,{children:[`history window, sec`,(0,E.jsx)(`input`,{type:`number`,min:`60`,value:ti.historyWindowSeconds,onChange:e=>ni(t=>({...t,historyWindowSeconds:e.target.value}))})]}),(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(async()=>{Ln(await q.updateFabricServiceChannelBreadcrumbWindowPolicy(T,{currentWindowSeconds:Number(ti.currentWindowSeconds),historyWindowSeconds:Number(ti.historyWindowSeconds)}));let e=await q.getFabricServiceChannelRebuildInvestigationBreadcrumbs(T,{limit:20});Ar(e.events),Mr(e.summary||null)},`Breadcrumb window policy updated.`),children:`apply windows`}),(0,E.jsxs)(`span`,{className:`muted`,children:[`source `,Nn.source,`, fp `,B(Nn.fingerprint||``)]})]}),jr&&(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`latest`,value:V(jr.latest_at)}),(0,E.jsx)(A,{label:`windows`,value:`${Nn?.current_window_seconds||`n/a`}s current / ${Nn?.history_window_seconds||`n/a`}s history`}),(0,E.jsx)(A,{label:`sources`,value:Object.entries(jr.counts_by_feedback_source||{}).slice(0,3).map(([e,t])=>`${H(e)} ${t}`).join(`, `)||`нет`}),(0,E.jsx)(A,{label:`violations`,value:Object.entries(jr.counts_by_feedback_violation_status||{}).slice(0,3).map(([e,t])=>`${H(e)} ${t}`).join(`, `)||`нет`})]}),(0,E.jsx)(N,{columns:[`time`,`freshness`,`source`,`feedback`,`target`,`current`,`actor`,`reason`],rows:Fa.map(e=>{let t=F(e.payload)||{},n=I(t,`feedback_channel_id`,``),r=I(t,`feedback_violation_status`,``),i=I(t,`feedback_source`,``),a=I(t,`reporter_node_id`,``),o=I(t,`route_id`,``),s=I(t,`drilldown_source`,``),c=e.correlation_hints?.current_diagnostic_status||``,l=e.correlation_hints?.breadcrumb_status||`current`,u=e.correlation_hints?.breadcrumb_age_seconds,d=e.correlation_hints?.feedback_breakdown||Ka(e),f=e.correlation_hints?.rebuild_incident||qa(e);return[V(e.created_at),(0,E.jsxs)(`div`,{className:`stackedText`,children:[(0,E.jsx)(`span`,{className:l===`current`?`pill good`:l===`stale`?`pill warn`:`pill bad`,children:H(l)}),(0,E.jsx)(`span`,{className:`muted`,children:Xn(u)})]}),(0,E.jsxs)(`div`,{className:`stackedText`,children:[(0,E.jsx)(`span`,{children:e.event_type.includes(`feedback_breakdown`)?`feedback breakdown`:`incident`}),(0,E.jsx)(`span`,{className:`muted`,children:H(s||e.target_type)})]}),i||n||r?(0,E.jsxs)(`div`,{className:`stackedText`,children:[(0,E.jsx)(`span`,{children:H(i||`feedback`)}),(0,E.jsx)(`span`,{className:`muted`,children:n?`ch ${B(n)}`:`any channel`}),(0,E.jsx)(`span`,{className:`muted`,children:H(r||`any violation`)})]}):(0,E.jsx)(`span`,{className:`muted`,children:`нет`}),(0,E.jsxs)(`div`,{className:`stackedText`,children:[(0,E.jsx)(`span`,{children:a?L(j,a):`any reporter`}),(0,E.jsx)(`span`,{className:`muted`,children:o?B(o):e.target_id?B(e.target_id):`any route`})]}),d?(0,E.jsxs)(`div`,{className:`inlineActions`,children:[(0,E.jsx)(`span`,{className:d.active_bad_count?`pill bad`:d.active_warn_count?`pill warn`:`pill good`,children:H(c||`breakdown_active`)}),(0,E.jsxs)(`span`,{className:`muted`,children:[`bad `,d.active_bad_count||0,` / warn `,d.active_warn_count||0]}),(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>Ja(d),`Rebuild ledger opened for current feedback breakdown.`),children:`open`})]}):f?(0,E.jsxs)(`div`,{className:`inlineActions`,children:[(0,E.jsx)(`span`,{className:`pill ${f.guard_severity===`bad`?`bad`:f.guard_severity===`warn`?`warn`:`good`}`,children:H(c||`incident_visible`)}),(0,E.jsx)(`span`,{className:`muted`,children:H(f.guard_status)}),(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>Wa(f),`Deep rebuild investigation opened for current incident.`),children:`open`})]}):(0,E.jsx)(`span`,{className:`muted`,children:H(c||`not_visible`)}),e.actor_user_id?B(e.actor_user_id):`system`,I(t,`reason`,`operator opened investigation`)]})})]}),$t.length>0&&(0,E.jsxs)(`div`,{className:`subPanel`,children:[(0,E.jsxs)(`div`,{className:`cardHead compact`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h4`,{children:`Active rebuild silences`}),(0,E.jsx)(`p`,{className:`muted`,children:`Operator acknowledgements currently suppressing rebuild/access-decision alerts. Remove a silence to let the incident become active again.`})]}),(0,E.jsx)(`span`,{className:`pill info`,children:$t.length})]}),(0,E.jsx)(N,{columns:[`until`,`source`,`channel`,`reporter`,`route`,`guard`,`reason`,`action`],rows:$t.slice(0,10).map(e=>[V(e.expires_at),e.incident_source?H(e.incident_source):`ledger`,e.channel_id?B(e.channel_id):`нет`,L(j,e.reporter_node_id),B(e.display_route_id||e.route_id),H(e.guard_status),e.reason||`acknowledged`,(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>Xa(e),`Rebuild alert silence removed.`),children:`unsilence`})])})]}),Jt&&(0,E.jsxs)(`div`,{className:`subPanel`,children:[(0,E.jsxs)(`div`,{className:`cardHead compact`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h4`,{children:`Rebuild health`}),(0,E.jsxs)(`p`,{className:`muted`,children:[`Сводка по последним `,Jt.total_attempts,` rebuild попыткам. Данные помогают быстро увидеть, где backend уже принял решение, но node-agent или post-rebuild traffic не подтвердили результат.`]})]}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[(0,E.jsxs)(`span`,{className:`pill good`,children:[`ok `,Jt.good_count]}),(0,E.jsxs)(`span`,{className:Jt.active_warn_count>0?`pill warn`:`pill`,children:[`warn `,Jt.active_warn_count,`/`,Jt.warn_count]}),(0,E.jsxs)(`span`,{className:Jt.active_bad_count>0?`pill bad`:`pill`,children:[`bad `,Jt.active_bad_count,`/`,Jt.bad_count]}),(0,E.jsxs)(`span`,{className:Jt.resurfaced_count>0?`pill bad`:`pill`,children:[`resurfaced `,Jt.resurfaced_count]}),(0,E.jsxs)(`span`,{className:Jt.silenced_count>0?`pill info`:`pill`,children:[`silenced `,Jt.silenced_count]}),(0,E.jsxs)(`span`,{className:`pill`,children:[`applied `,Jt.applied_count]}),(0,E.jsxs)(`span`,{className:Jt.access_no_safe_count?`pill bad`:Jt.access_route_decision_count?`pill info`:`pill`,children:[`access `,Jt.access_route_decision_count||0,Jt.access_no_safe_count?` / no-safe ${Jt.access_no_safe_count}`:``]})]})]}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`observed`,value:V(Jt.observed_at)}),(0,E.jsx)(A,{label:`affected nodes`,value:(Jt.affected_reporter_node_ids||[]).map(e=>L(j,e)).join(`, `)||`нет`}),(0,E.jsx)(A,{label:`affected routes`,value:(Jt.affected_route_ids||[]).map(B).join(`, `)||`нет`}),(0,E.jsx)(A,{label:`action`,value:H(Jt.recommended_operator_action||`no_operator_action_required`)})]}),(Jt.feedback_breakdowns||[]).length>0&&(0,E.jsx)(N,{columns:[`feedback`,`active`,`total`,`affected`,`incidents`,`latest`,`action`],rows:(Jt.feedback_breakdowns||[]).slice(0,8).map(e=>{let t=Ga(e);return[(0,E.jsxs)(`div`,{className:`stackedText`,children:[(0,E.jsx)(`span`,{children:H(e.feedback_source||`feedback`)}),(0,E.jsx)(`span`,{className:`muted`,children:e.feedback_channel_id?`ch ${B(e.feedback_channel_id)}`:`any channel`}),(0,E.jsx)(`span`,{className:`muted`,children:H(e.feedback_violation_status||`unknown`)})]}),(0,E.jsxs)(`span`,{className:e.active_bad_count?`pill bad`:e.active_warn_count?`pill warn`:`pill`,children:[`bad `,e.active_bad_count||0,` / warn `,e.active_warn_count||0]}),`total ${e.total_count} / bad ${e.bad_count||0} / warn ${e.warn_count||0} / silenced ${e.silenced_count||0}`,(0,E.jsxs)(`div`,{className:`stackedText`,children:[(0,E.jsx)(`span`,{children:(e.affected_reporter_node_ids||[]).map(e=>L(j,e)).join(`, `)||`нет узлов`}),(0,E.jsx)(`span`,{className:`muted`,children:(e.affected_route_ids||[]).map(B).join(`, `)||`нет routes`})]}),t.length>0?(0,E.jsxs)(`div`,{className:`stackedText`,children:[(0,E.jsx)(`span`,{className:`pill warn`,children:t.length}),(0,E.jsx)(`span`,{className:`muted`,children:t.slice(0,2).map(e=>H(e.guard_status)).join(`, `)})]}):(0,E.jsx)(`span`,{className:`muted`,children:`нет`}),V(e.latest_observed_at),(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>Ja(e),`Rebuild ledger opened for feedback breakdown.`),children:`open ledger`})]})}),(Jt.most_recent_bad_attempts||[]).length>0&&(0,E.jsx)(N,{columns:[`time`,`reporter`,`route`,`guard`,`reason`],rows:(Jt.most_recent_bad_attempts||[]).slice(0,5).map(e=>[V(e.updated_at),L(j,e.reporter_node_id),B(e.route_id),(0,E.jsx)(`span`,{className:`pill bad`,children:H(e.guard_status||`bad`)}),(0,E.jsxs)(`div`,{className:`inlineActions`,children:[(0,E.jsx)(`span`,{children:H(e.guard_reason||`unknown`)}),(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>q.silenceFabricServiceChannelRouteRebuildAlert(T,{reporterNodeId:e.reporter_node_id,routeId:e.route_id,guardStatus:e.guard_status||`unknown`,generation:e.generation||``,reason:`operator acknowledged known rebuild alert`,ttlSeconds:21600}),`Rebuild alert silenced for this route generation.`),children:`silence 6h`})]})])}),(Jt.resurfaced_attempts||[]).length>0&&(0,E.jsx)(N,{columns:[`time`,`reporter`,`route`,`guard`,`previous`,`action`],rows:(Jt.resurfaced_attempts||[]).slice(0,5).map(e=>[V(e.updated_at),L(j,e.reporter_node_id),B(e.route_id),(0,E.jsx)(`span`,{className:`pill bad`,children:H(e.guard_status||`bad`)}),`${B(e.alert_resurfaced_previous_generation)} until ${V(e.alert_resurfaced_previous_until)}`,(0,E.jsxs)(`div`,{className:`inlineActions`,children:[(0,E.jsx)(`span`,{children:H(e.guard_reason||`unknown`)}),(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>q.silenceFabricServiceChannelRouteRebuildAlert(T,{reporterNodeId:e.reporter_node_id,routeId:e.route_id,guardStatus:e.guard_status||`unknown`,generation:e.generation||``,reason:`operator acknowledged resurfaced rebuild alert`,ttlSeconds:21600}),`Resurfaced rebuild alert silenced for this generation.`),children:`silence 6h`})]})])})]}),jn&&(0,E.jsxs)(`div`,{className:`inlineForm`,children:[(0,E.jsxs)(`label`,{children:[`penalty`,(0,E.jsx)(`input`,{type:`number`,min:`0`,value:$r.hysteresisPenalty,onChange:e=>ei(t=>({...t,hysteresisPenalty:e.target.value}))})]}),(0,E.jsxs)(`label`,{children:[`promote samples`,(0,E.jsx)(`input`,{type:`number`,min:`1`,value:$r.promotionMinSamples,onChange:e=>ei(t=>({...t,promotionMinSamples:e.target.value}))})]}),(0,E.jsxs)(`label`,{children:[`fail`,(0,E.jsx)(`input`,{type:`number`,min:`1`,value:$r.demotionFailureThreshold,onChange:e=>ei(t=>({...t,demotionFailureThreshold:e.target.value}))})]}),(0,E.jsxs)(`label`,{children:[`drop`,(0,E.jsx)(`input`,{type:`number`,min:`1`,value:$r.demotionDropThreshold,onChange:e=>ei(t=>({...t,demotionDropThreshold:e.target.value}))})]}),(0,E.jsxs)(`label`,{children:[`slow`,(0,E.jsx)(`input`,{type:`number`,min:`1`,value:$r.demotionSlowThreshold,onChange:e=>ei(t=>({...t,demotionSlowThreshold:e.target.value}))})]}),(0,E.jsxs)(`label`,{className:`checkLine`,children:[(0,E.jsx)(`input`,{type:`checkbox`,checked:$r.demotionRebuildEnabled,onChange:e=>ei(t=>({...t,demotionRebuildEnabled:e.target.checked}))}),`rebuild`]}),(0,E.jsxs)(`label`,{className:`checkLine`,children:[(0,E.jsx)(`input`,{type:`checkbox`,checked:$r.demotionFencedEnabled,onChange:e=>ei(t=>({...t,demotionFencedEnabled:e.target.checked}))}),`fenced`]}),(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(async()=>{Mn(await q.updateFabricServiceChannelRecoveryPolicy(T,{hysteresisPenalty:Number($r.hysteresisPenalty),promotionMinSamples:Number($r.promotionMinSamples),demotionFailureThreshold:Number($r.demotionFailureThreshold),demotionDropThreshold:Number($r.demotionDropThreshold),demotionSlowThreshold:Number($r.demotionSlowThreshold),demotionRebuildEnabled:$r.demotionRebuildEnabled,demotionFencedEnabled:$r.demotionFencedEnabled}))},`Recovery policy updated.`),children:`apply policy`}),(0,E.jsxs)(`span`,{className:`muted`,children:[`source `,jn.source]})]}),(0,E.jsx)(N,{columns:[`route`,`reporter`,`service`,`status`,`recovery`,`score`,`reasons`,`failures`,`retry/cooldown`,`expires`,`action`],rows:bo.slice(0,80).map(e=>[B(e.route_id),L(j,e.reporter_node_id),e.service_class,(0,E.jsx)(`span`,{className:`pill ${dt(e.feedback_status)}`,children:H(e.feedback_status)}),e.recovery_state?(0,E.jsxs)(`span`,{className:`pill ${ft(e.recovery_state)}`,children:[e.recovery_demoted?`demoted ${e.recovery_reason?H(e.recovery_reason):``}`:e.recovery_promoted?`promoted`:H(e.recovery_state),e.recovery_hysteresis_penalty?` -${e.recovery_hysteresis_penalty}`:``]}):e.stale_policy||e.stale_generation?(0,E.jsx)(`span`,{className:`pill warn`,children:H(e.stale_reason||`stale`)}):e.provenance_missing?(0,E.jsx)(`span`,{className:`pill warn`,children:`provenance missing`}):`нет`,String(e.score_adjustment),(e.reasons||[]).join(`, `)||`нет`,String(e.consecutive_failures||0),e.retry_cooldown_until?V(e.retry_cooldown_until):`нет`,V(e.expires_at),e.feedback_status===`healthy`||e.feedback_status===`operator_retry_cooldown`?(0,E.jsx)(`span`,{className:`muted`,children:`нет`}):(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>q.expireFabricServiceChannelRouteFeedback(T,{routeId:e.route_id,reporterNodeId:e.reporter_node_id,serviceClass:e.service_class,reason:`operator expired stale service-channel feedback`}),`Service-channel feedback expired.`),children:`expire`})])}),bo.length===0&&(0,E.jsx)(ye,{title:`Feedback отсутствует`,text:`Нет свежих route feedback наблюдений от fabric service-channel runtime.`}),(0,E.jsx)(N,{columns:[`local node`,`route`,`replacement`,`rebuild`,`attempt`,`feedback`,`source`,`destination`,`decision`,`score`,`expires`],rows:[...Ao,...ko,...jo].filter((e,t,n)=>n.findIndex(t=>t.decision_id===e.decision_id)===t).slice(0,80).map(e=>[L(j,e.local_node_id),B(e.route_id),e.replacement_route_id?B(e.replacement_route_id):`нет`,e.rebuild_status||`нет`,e.rebuild_attempt==null?`н/д`:String(e.rebuild_attempt),e.feedback_observation_id?(0,E.jsxs)(`div`,{className:`stackedText`,children:[(0,E.jsx)(`span`,{children:H(e.feedback_source||`feedback`)}),(0,E.jsxs)(`span`,{className:`muted`,children:[B(e.feedback_observation_id),` `,e.feedback_channel_id?`ch ${B(e.feedback_channel_id)}`:``]}),(0,E.jsx)(`span`,{className:`muted`,children:H(e.feedback_violation_status||``)})]}):`нет`,L(j,e.source_node_id),L(j,e.destination_node_id),e.decision_source,e.path_score==null?`н/д`:String(e.path_score),V(e.expires_at)])}),(0,E.jsxs)(`div`,{className:`inlineForm`,children:[(0,E.jsxs)(`label`,{children:[`reporter`,(0,E.jsxs)(`select`,{value:En.reporterNodeId,onChange:e=>Dn(t=>({...t,reporterNodeId:e.target.value,offset:0})),children:[(0,E.jsx)(`option`,{value:``,children:`all`}),j.map(e=>(0,E.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,E.jsxs)(`label`,{children:[`route`,(0,E.jsx)(`input`,{value:En.routeId,onChange:e=>Dn(t=>({...t,routeId:e.target.value.trim(),offset:0})),placeholder:`route id`})]}),(0,E.jsxs)(`label`,{children:[`generation`,(0,E.jsx)(`input`,{value:En.generation,onChange:e=>Dn(t=>({...t,generation:e.target.value.trim(),offset:0})),placeholder:`route generation`})]}),(0,E.jsxs)(`label`,{children:[`service`,(0,E.jsx)(`input`,{value:En.serviceClass,onChange:e=>Dn(t=>({...t,serviceClass:e.target.value.trim(),offset:0})),placeholder:`vpn_packets`})]}),(0,E.jsxs)(`label`,{children:[`feedback source`,(0,E.jsx)(`input`,{value:En.feedbackSource,onChange:e=>Dn(t=>({...t,feedbackSource:e.target.value.trim(),offset:0})),placeholder:`fabric_service_channel_access_report`})]}),(0,E.jsxs)(`label`,{children:[`channel`,(0,E.jsx)(`input`,{value:En.feedbackChannelId,onChange:e=>Dn(t=>({...t,feedbackChannelId:e.target.value.trim(),offset:0})),placeholder:`feedback channel id`})]}),(0,E.jsxs)(`label`,{children:[`violation`,(0,E.jsx)(`input`,{value:En.feedbackViolationStatus,onChange:e=>Dn(t=>({...t,feedbackViolationStatus:e.target.value.trim(),offset:0})),placeholder:`fabric_route_send_failed_backend_fallback_blocked`})]}),(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Ba(Tn,{...En,offset:0}),children:`apply`}),(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>{let e={...ie};Dn(e),Ba(!1,e)},children:`clear`})]}),(0,E.jsx)(N,{columns:[`time`,`reporter`,`route`,`replacement`,`feedback`,`guard`,`outcome`,`backend`,`agent`,`route-gen`,`traffic`,`policy`,`hops`],rows:It.slice(0,80).map(e=>[V(e.updated_at),L(j,e.reporter_node_id),B(e.route_id),e.replacement_route_id?B(e.replacement_route_id):`нет`,e.feedback_observation_id?(0,E.jsxs)(`div`,{className:`stackedText`,children:[(0,E.jsx)(`span`,{children:H(e.feedback_source||`feedback`)}),(0,E.jsxs)(`span`,{className:`muted`,children:[B(e.feedback_observation_id),` `,e.feedback_channel_id?`ch ${B(e.feedback_channel_id)}`:``]}),(0,E.jsx)(`span`,{className:`muted`,children:H(e.feedback_violation_status||``)})]}):e.feedback_status?H(e.feedback_status):`нет`,Tn?(0,E.jsxs)(`span`,{className:`pill ${e.guard_severity===`bad`?`bad`:e.guard_severity===`warn`?`warn`:`good`}`,children:[H(e.guard_status||`unknown`),e.alert_silenced?` / silenced`:e.alert_resurfaced?` / resurfaced`:``]}):(0,E.jsx)(`span`,{className:`pill info`,children:`summary`}),H(e.outcome),(0,E.jsx)(`span`,{className:`pill ${e.rebuild_status===`applied`?`good`:`warn`}`,children:H(e.rebuild_status)}),Tn?e.node_transition_matched?(0,E.jsx)(`span`,{className:`pill ${e.node_transition_status===`applied_rebuild`?`good`:`warn`}`,children:H(e.node_transition_status||`matched`)}):(0,E.jsx)(`span`,{className:`pill warn`,children:`not seen`}):(0,E.jsx)(`span`,{className:`pill info`,children:`deep only`}),Tn?e.node_route_generation_matched?(0,E.jsx)(`span`,{className:`pill good`,children:H(e.node_route_generation_status||`seen`)}):(0,E.jsx)(`span`,{className:`pill warn`,children:`not seen`}):(0,E.jsx)(`span`,{className:`pill info`,children:`deep only`}),Tn?e.post_rebuild_selected_route_id?`${B(e.post_rebuild_selected_route_id)} packets ${e.post_rebuild_send_flow_packets||e.post_rebuild_send_packets||0} drop ${e.post_rebuild_send_flow_dropped||0}`:`нет`:`deep only`,e.policy_fingerprint?B(e.policy_fingerprint):`нет`,`${(e.old_hops||[]).map(e=>L(j,e)).join(` -> `)||`нет`} => ${(e.replacement_hops||[]).map(e=>L(j,e)).join(` -> `)||`нет`}`])}),(0,E.jsxs)(`div`,{className:`inlineActions`,children:[(0,E.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Ba(!Tn,{...En,offset:0}),children:Tn?`fast ledger`:`deep ledger`}),(0,E.jsx)(`button`,{type:`button`,className:`ghost`,disabled:!Tn||En.offset<=0,onClick:()=>void Ba(!0,{...En,offset:Math.max(0,En.offset-20)}),children:`prev`}),(0,E.jsx)(`button`,{type:`button`,className:`ghost`,disabled:!Tn||It.length<20,onClick:()=>void Ba(!0,{...En,offset:En.offset+20}),children:`next`}),(0,E.jsxs)(`span`,{className:`pill`,children:[`offset `,Tn?En.offset:0]}),(0,E.jsx)(`span`,{className:`muted`,children:`Deep ledger correlates heartbeat timeline and can be slower; default refresh stays fast.`})]}),It.length===0&&(0,E.jsx)(ye,{title:`Rebuild ledger пуст`,text:`Пока нет долговечной истории service-channel route rebuild решений.`})]}),(0,E.jsxs)(`article`,{className:`card span2`,children:[(0,E.jsx)(`h3`,{children:J.servicePlacement}),(0,E.jsx)(N,{columns:[`узел`,`runtime`,`адрес`,`здоровье`,`роли`,`желаемые / reported сервисы`,`последний heartbeat`],rows:j.map(e=>{let t=Dt(e,st[e.id]||[],bt);return[e.name,(0,E.jsx)(we,{runtime:t}),t.address,e.health_status,tt(Je[e.id]||[]),it(nt[e.id]||[]),V((st[e.id]||[])[0]?.observed_at||e.last_seen_at)]})})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:J.trafficFlow}),(0,E.jsx)(N,{columns:[`источник`,`цель`,`тип`,`route/path`,`статус`,`задержка`,`качество`,`наблюдение`],rows:yt(bt).filter(e=>e.source_node_id!==e.target_node_id).map(e=>{let t=j.find(t=>t.id===e.source_node_id),n=j.find(t=>t.id===e.target_node_id);return[(0,E.jsx)(Te,{node:t,fallback:L(j,e.source_node_id),heartbeatsByNode:st,meshLinks:bt}),(0,E.jsx)(Te,{node:n,fallback:L(j,e.target_node_id),heartbeatsByNode:st,meshLinks:bt}),xt(e),St(e,j),e.link_status,e.latency_ms==null?`н/д`:`${e.latency_ms} мс`,e.quality_score==null?`н/д`:`${e.quality_score}/100`,V(e.observed_at)]})})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Политики QoS`}),(0,E.jsx)(N,{columns:[`класс`,`приоритет`,`надежность`,`политика сброса`],rows:Rn.map(e=>[e.service_class,String(e.priority),e.reliability_mode,e.drop_policy])})]})]}),C===`vpn`&&(0,E.jsxs)(`section`,{className:`grid two`,children:[(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Создать желаемое состояние VPN-подключения`}),(0,E.jsx)(`p`,{className:`muted`,children:`Только Control/API слой. Здесь не выполняются TUN/TAP, маршруты, DNS, firewall, QoS или packet forwarding.`}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[`ID организации`,(0,E.jsx)(`input`,{value:G.organizationId,onChange:e=>Vi({...G,organizationId:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[`Название`,(0,E.jsx)(`input`,{value:G.name,onChange:e=>Vi({...G,name:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[`Протокол`,(0,E.jsxs)(`select`,{value:G.protocolFamily,onChange:e=>Vi({...G,protocolFamily:e.target.value}),children:[(0,E.jsx)(`option`,{value:`generic`,children:`generic`}),(0,E.jsx)(`option`,{value:`wireguard`,children:`wireguard`}),(0,E.jsx)(`option`,{value:`ipsec`,children:`ipsec`}),(0,E.jsx)(`option`,{value:`openvpn`,children:`openvpn`})]})]}),(0,E.jsxs)(`label`,{children:[`Желаемое состояние`,(0,E.jsxs)(`select`,{value:G.desiredState,onChange:e=>Vi({...G,desiredState:e.target.value}),children:[(0,E.jsx)(`option`,{value:`disabled`,children:`выключено`}),(0,E.jsx)(`option`,{value:`enabled`,children:`включено`})]})]}),(0,E.jsxs)(`label`,{children:[`Ссылка на credential`,(0,E.jsx)(`input`,{value:G.credentialRef,onChange:e=>Vi({...G,credentialRef:e.target.value})})]})]}),(0,E.jsxs)(`label`,{children:[`Целевой endpoint JSON`,(0,E.jsx)(`textarea`,{value:G.targetEndpointJson,onChange:e=>Vi({...G,targetEndpointJson:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[`Политика разрешенных узлов JSON`,(0,E.jsx)(`textarea`,{value:G.allowedNodePolicyJson,onChange:e=>Vi({...G,allowedNodePolicyJson:e.target.value})})]}),(0,E.jsxs)(`details`,{children:[(0,E.jsx)(`summary`,{children:`Расширенные routing / QoS / placement JSON`}),(0,E.jsxs)(`label`,{children:[`Использование маршрутизации JSON`,(0,E.jsx)(`textarea`,{value:G.routingUsageJson,onChange:e=>Vi({...G,routingUsageJson:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[`Политика маршрута JSON`,(0,E.jsx)(`textarea`,{value:G.routePolicyJson,onChange:e=>Vi({...G,routePolicyJson:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[`Политика QoS JSON`,(0,E.jsx)(`textarea`,{value:G.qosPolicyJson,onChange:e=>Vi({...G,qosPolicyJson:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[`Политика размещения JSON`,(0,E.jsx)(`textarea`,{value:G.placementPolicyJson,onChange:e=>Vi({...G,placementPolicyJson:e.target.value})})]})]}),(0,E.jsx)(`button`,{className:`primary`,disabled:!T||!G.organizationId||!G.name,onClick:()=>void Y(()=>q.createVPNConnection(T,{organizationId:G.organizationId,name:G.name,protocolFamily:G.protocolFamily,credentialRef:G.credentialRef||null,desiredState:G.desiredState,targetEndpoint:$e(G.targetEndpointJson,`target endpoint`),allowedNodePolicy:$e(G.allowedNodePolicyJson,`allowed node policy`),routingUsage:et(G.routingUsageJson,`routing usage`),routePolicy:$e(G.routePolicyJson,`route policy`),qosPolicy:$e(G.qosPolicyJson,`qos policy`),placementPolicy:$e(G.placementPolicyJson,`placement policy`)}),`Желаемое состояние VPN создано.`),children:`Создать желаемое состояние VPN`})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:`VPN-подключения`}),(0,E.jsx)(`p`,{className:`muted`,children:`Cluster-managed состояние, gateway packet stats и диагностика Android-клиента.`})]}),(0,E.jsxs)(`div`,{className:`actions compactActions`,children:[(0,E.jsx)(`button`,{onClick:()=>void Y(async()=>{Kr(`Истекшие VPN lease: ${(await q.expireStaleVPNLeases(T)).length}.`)},`Stale VPN lease проверены.`),children:`Проверить stale lease`}),(0,E.jsx)(`button`,{onClick:()=>void $a(),children:`Обновить клиент`})]})]}),(0,E.jsxs)(`div`,{className:`inlineForm`,children:[(0,E.jsxs)(`label`,{children:[`Android device id`,(0,E.jsx)(`input`,{value:ar,placeholder:`0315f630-...`,onChange:e=>or(e.target.value),onBlur:()=>localStorage.setItem(D.vpnDiagnosticDeviceId,ar.trim())})]}),sr.length>0&&(0,E.jsxs)(`label`,{children:[`Найденные клиенты`,(0,E.jsx)(`select`,{value:ar,onChange:e=>{let t=e.target.value;or(t),localStorage.setItem(D.vpnDiagnosticDeviceId,t),ur(sr.find(e=>e.device_id===t)||null)},children:sr.map(e=>{let t=F(e.payload)||{};return(0,E.jsxs)(`option`,{value:e.device_id,children:[B(e.device_id),` / `,I(t,`app_version`,`н/д`),` / `,V(e.observed_at)]},e.device_id)})})]})]}),(0,E.jsxs)(`div`,{className:`diagnosticCommandPanel`,children:[(0,E.jsxs)(`label`,{children:[`URL для теста`,(0,E.jsx)(`input`,{value:dr,onChange:e=>fr(e.target.value)})]}),(0,E.jsxs)(`div`,{className:`actions compactActions`,children:[(0,E.jsx)(`button`,{onClick:()=>void eo({type:`refresh_profile`},`Профиль`),children:`Обновить профиль`}),(0,E.jsx)(`button`,{onClick:()=>void eo({type:`start_vpn`},`VPN`),children:`Старт VPN`}),(0,E.jsx)(`button`,{onClick:()=>void eo({type:`stop_vpn`},`VPN`),children:`Стоп VPN`}),(0,E.jsx)(`button`,{onClick:()=>void eo({type:`vpn_stats`},`Stats`),children:`Stats`}),(0,E.jsx)(`button`,{onClick:()=>void eo({type:`vpn_http_get`,url:dr},`VPN HTTP`),children:`VPN HTTP`}),(0,E.jsx)(`button`,{onClick:()=>void eo({type:`open_url`,url:dr},`Открыть URL`),children:`Открыть URL`}),(0,E.jsx)(`button`,{className:`primary`,onClick:()=>void eo({type:`full_vpn_test`,url:dr,watch_seconds:45},`Полный VPN test`),children:`Полный тест`})]}),pr&&(0,E.jsxs)(`p`,{className:`muted`,children:[`Последняя команда: `,I(pr.payload,`type`,`н/д`),` / `,V(pr.created_at)]})]}),be(lr),(0,E.jsxs)(`div`,{className:`stack`,children:[Hn.map(e=>{let t=F(e.metadata?.client_config),n=F(t?.vpn_fabric_route),r=Yt(n?.entry_pool_node_ids||e.placement_policy?.entry_node_ids),i=Yt(n?.exit_pool_node_ids||e.placement_policy?.exit_node_ids),a=String(n?.selected_entry_node_id||r[0]||``),o=String(n?.selected_exit_node_id||Wn[e.id]?.owner_node_id||e.placement_policy?.exit_node_id||i[0]||``),s=qn[e.id]||{};return(0,E.jsxs)(`div`,{className:`vpnCard`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`strong`,{children:e.name}),(0,E.jsxs)(`p`,{className:`muted`,children:[e.protocol_family,` / `,e.mode,` / организация `,B(e.organization_id)]}),(0,E.jsx)(_e,{value:e.desired_state}),(0,E.jsx)(_e,{value:e.status}),(0,E.jsx)(`span`,{className:`pill ${t?.packet_forwarding?`good`:`warn`}`,children:t?.packet_forwarding?`gateway packet relay active`:`gateway packet relay inactive`}),(0,E.jsxs)(`span`,{className:`pill`,children:[String(n?.preferred_data_plane||`backend_relay`),` / fallback `,String(n?.fallback_data_plane||`н/д`)]})]}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Секрет`,value:e.credential_ref?`задан`:`не задан`}),(0,E.jsx)(A,{label:`Активный lease`,value:Wn[e.id]?B(Wn[e.id]?.owner_node_id):`нет`}),(0,E.jsx)(A,{label:`Fabric route`,value:`${a?L(j,a):`entry auto`} -> ${o?L(j,o):`exit auto`}`}),(0,E.jsx)(A,{label:`Entry pool`,value:r.map(e=>L(j,e)).join(`, `)||`н/д`}),(0,E.jsx)(A,{label:`Exit pool`,value:i.map(e=>L(j,e)).join(`, `)||`н/д`}),(0,E.jsx)(A,{label:`Runtime`,value:String(t?.runtime_status||`н/д`)}),(0,E.jsx)(A,{label:`Gateway`,value:String(t?.gateway_assignment_status||`н/д`)}),(0,E.jsx)(A,{label:`Client -> gateway`,value:lt(s.client_to_gateway)}),(0,E.jsx)(A,{label:`Gateway -> client`,value:lt(s.gateway_to_client)}),(0,E.jsx)(A,{label:`Обновлено`,value:V(e.updated_at)})]}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`button`,{disabled:e.desired_state===`enabled`,onClick:()=>void Y(()=>q.updateVPNConnectionDesiredState(T,e.id,`enabled`),`Желаемое состояние VPN включено.`),children:`Включить`}),(0,E.jsx)(`button`,{disabled:e.desired_state===`disabled`,onClick:()=>void Y(()=>q.updateVPNConnectionDesiredState(T,e.id,`disabled`),`Желаемое состояние VPN выключено.`),children:`Выключить`})]})]},e.id)}),Hn.length===0&&(0,E.jsx)(ye,{title:`Нет желаемого состояния VPN`,text:`Control-plane записи C18 появятся здесь.`})]})]})]}),C===`org-safe`&&(0,E.jsxs)(`section`,{className:`grid two`,children:[(0,E.jsxs)(`article`,{className:`card span2`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:`Организации и пользователи`}),(0,E.jsx)(`p`,{className:`muted`,children:`Операционный слой для владельца платформы: tenant scope, роли участников и безопасная сводка без раскрытия core mesh.`})]}),(0,E.jsx)(`span`,{className:`pill`,children:hr.length})]}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[`Slug`,(0,E.jsx)(`input`,{value:Hi.slug,onChange:e=>Ui({...Hi,slug:e.target.value}),placeholder:`home`})]}),(0,E.jsxs)(`label`,{children:[`Название`,(0,E.jsx)(`input`,{value:Hi.name,onChange:e=>Ui({...Hi,name:e.target.value}),placeholder:`HOME`})]})]}),(0,E.jsx)(`div`,{className:`actions`,children:(0,E.jsx)(`button`,{className:`primary`,disabled:!Hi.slug.trim()||!Hi.name.trim(),onClick:()=>void Y(async()=>{let e=await q.createOrganization(Hi);Ui({slug:``,name:``}),Rr(e.id),qi(t=>({...t,organizationId:e.id})),na(t=>({...t,organizationId:e.id}))},`Организация создана.`),children:`Создать организацию`})}),(0,E.jsx)(N,{columns:[`организация`,`slug`,`статус`,`ресурсы`,`участники`,`действие`],rows:hr.map(e=>{let t=yr.filter(t=>t.organization_id===e.id),n=xr[e.id]||[];return[e.name,e.slug,(0,E.jsx)(_e,{value:e.status}),String(t.length),String(n.length),(0,E.jsx)(`div`,{className:`actions`,children:(0,E.jsx)(`button`,{onClick:()=>void Y(async()=>{Rr(e.id),Br(await q.getOrganizationAdminSummary(e.id))},`Сводка организации загружена.`),children:`Открыть`})},e.id)]})})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Пользователь`}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[`Email / логин`,(0,E.jsx)(`input`,{value:Wi.email,onChange:e=>Gi({...Wi,email:e.target.value}),placeholder:`user@example.com`})]}),(0,E.jsxs)(`label`,{children:[`Пароль`,(0,E.jsx)(`input`,{type:`password`,value:Wi.password,onChange:e=>Gi({...Wi,password:e.target.value}),placeholder:`минимум 8 символов`})]}),(0,E.jsxs)(`label`,{children:[`Роль платформы`,(0,E.jsxs)(`select`,{value:Wi.platformRole,onChange:e=>Gi({...Wi,platformRole:e.target.value}),children:[(0,E.jsx)(`option`,{value:`user`,children:`user`}),(0,E.jsx)(`option`,{value:`platform_admin`,children:`platform_admin`}),(0,E.jsx)(`option`,{value:`platform_recovery_admin`,children:`platform_recovery_admin`})]})]})]}),(0,E.jsx)(`div`,{className:`actions`,children:(0,E.jsx)(`button`,{disabled:!Wi.email.trim()||Wi.password.length<8,onClick:()=>void Y(async()=>{let e=await q.createUser(Wi);vr(await q.listUsers()),Gi({email:``,password:``,platformRole:`user`}),qi(t=>({...t,userId:e.id}))},`Пользователь создан.`),children:`Создать пользователя`})}),(0,E.jsx)(N,{columns:[`пользователь`,`роль платформы`,`id`],rows:_r.map(e=>[e.email,(0,E.jsx)(_e,{value:e.platform_role||`user`}),(0,E.jsx)(`code`,{children:e.id})])})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Участник организации`}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[`Организация`,(0,E.jsxs)(`select`,{value:Ki.organizationId,onChange:e=>qi({...Ki,organizationId:e.target.value}),children:[(0,E.jsx)(`option`,{value:``,children:`Выберите организацию`}),hr.map(e=>(0,E.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,E.jsxs)(`label`,{children:[`Пользователь`,(0,E.jsxs)(`select`,{value:Ki.userId,onChange:e=>qi({...Ki,userId:e.target.value}),children:[(0,E.jsx)(`option`,{value:``,children:`Выберите пользователя`}),_r.map(e=>(0,E.jsx)(`option`,{value:e.id,children:e.email},e.id))]})]}),(0,E.jsxs)(`label`,{children:[`Роль`,(0,E.jsxs)(`select`,{value:Ki.roleId,onChange:e=>qi({...Ki,roleId:e.target.value}),children:[(0,E.jsx)(`option`,{value:`org_owner`,children:`org_owner`}),(0,E.jsx)(`option`,{value:`org_admin`,children:`org_admin`}),(0,E.jsx)(`option`,{value:`org_operator`,children:`org_operator`}),(0,E.jsx)(`option`,{value:`org_member`,children:`org_member`}),(0,E.jsx)(`option`,{value:`org_viewer`,children:`org_viewer`})]})]})]}),(0,E.jsx)(`div`,{className:`actions`,children:(0,E.jsx)(`button`,{disabled:!Ki.organizationId||!Ki.userId.trim(),onClick:()=>void Y(()=>q.addOrganizationMembership(Ki.organizationId,{userId:Ki.userId,roleId:Ki.roleId}),`Участник организации сохранен.`),children:`Сохранить участника`})})]}),(0,E.jsxs)(`article`,{className:`card`,children:[(0,E.jsx)(`h3`,{children:`Безопасная сводка`}),(0,E.jsxs)(`div`,{className:`inlineForm`,children:[(0,E.jsxs)(`select`,{value:Lr,onChange:e=>Rr(e.target.value),children:[(0,E.jsx)(`option`,{value:``,children:`Выберите организацию`}),hr.map(e=>(0,E.jsx)(`option`,{value:e.id,children:e.name},e.id))]}),(0,E.jsx)(`button`,{disabled:!Lr,onClick:()=>void Y(async()=>{Br(await q.getOrganizationAdminSummary(Lr))},`Сводка организации загружена.`),children:`Обновить`})]}),zr?(0,E.jsxs)(`div`,{className:`stack`,children:[(0,E.jsx)(he,{label:`Ресурсы`,value:zr.resource_count,tone:`steel`}),(0,E.jsx)(he,{label:`Активные сессии`,value:zr.active_session_count,tone:`green`}),(0,E.jsx)(A,{label:`Topology exposure`,value:zr.topology_exposure}),(0,E.jsx)(N,{columns:[`контур`,`состояние`],rows:Object.entries(zr.connector_status||{}).map(([e,t])=>[e,typeof t==`string`?H(t):JSON.stringify(t)])}),(0,E.jsx)(N,{columns:[`протокол`,`количество`],rows:zr.service_endpoints.map(e=>[e.protocol,String(e.count)])})]}):(0,E.jsx)(ye,{title:`Сводка не выбрана`,text:`Выберите организацию, чтобы проверить tenant-safe состояние.`})]})]}),C===`servers`&&(0,E.jsx)(`section`,{className:`grid two`,children:(0,E.jsxs)(`article`,{className:`card span2`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:`Справочник серверов`}),(0,E.jsx)(`p`,{className:`muted`,children:`Единый каталог целей для RDP/VPN: адрес сервера, организация, протокол и предпочтительный вход/выход маршрута.`})]}),(0,E.jsx)(`span`,{className:`pill`,children:yr.length})]}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[`Организация`,(0,E.jsxs)(`select`,{value:K.organizationId,onChange:e=>na({...K,organizationId:e.target.value}),children:[(0,E.jsx)(`option`,{value:``,children:`Выберите организацию`}),hr.map(e=>(0,E.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,E.jsxs)(`label`,{children:[`Имя сервера`,(0,E.jsx)(`input`,{value:K.name,onChange:e=>na({...K,name:e.target.value}),placeholder:`Office RDP`})]}),(0,E.jsxs)(`label`,{children:[`Адрес`,(0,E.jsx)(`input`,{value:K.address,onChange:e=>na({...K,address:e.target.value}),placeholder:`192.168.1.10:3389`})]}),(0,E.jsxs)(`label`,{children:[`Протокол`,(0,E.jsxs)(`select`,{value:K.protocol,onChange:e=>na({...K,protocol:e.target.value}),children:[(0,E.jsx)(`option`,{value:`rdp`,children:`RDP`}),(0,E.jsx)(`option`,{value:`vpn`,children:`VPN`}),(0,E.jsx)(`option`,{value:`ssh`,children:`SSH`}),(0,E.jsx)(`option`,{value:`http`,children:`HTTP`})]})]}),(0,E.jsxs)(`label`,{children:[`Вход`,(0,E.jsxs)(`select`,{value:K.entryNode,onChange:e=>na({...K,entryNode:e.target.value}),children:[(0,E.jsx)(`option`,{value:``,children:`Автоматически`}),j.map(e=>(0,E.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,E.jsxs)(`label`,{children:[`Выход`,(0,E.jsxs)(`select`,{value:K.exitNode,onChange:e=>na({...K,exitNode:e.target.value}),children:[(0,E.jsx)(`option`,{value:``,children:`Автоматически`}),j.map(e=>(0,E.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,E.jsxs)(`label`,{children:[`Теги`,(0,E.jsx)(`input`,{value:K.tags,onChange:e=>na({...K,tags:e.target.value}),placeholder:`home, accounting`})]}),(0,E.jsxs)(`label`,{children:[`RDP пользователь`,(0,E.jsx)(`input`,{value:K.username,onChange:e=>na({...K,username:e.target.value}),placeholder:`user или DOMAIN\\\\user`})]}),(0,E.jsxs)(`label`,{children:[`RDP пароль`,(0,E.jsx)(`input`,{type:`password`,value:K.password,onChange:e=>na({...K,password:e.target.value}),placeholder:`хранится как secret`})]}),(0,E.jsxs)(`label`,{children:[`Домен`,(0,E.jsx)(`input`,{value:K.domain,onChange:e=>na({...K,domain:e.target.value}),placeholder:`опционально`})]})]}),(0,E.jsx)(`div`,{className:`actions`,children:(0,E.jsx)(`button`,{className:`primary`,disabled:!K.organizationId||!K.name.trim()||!K.address.trim(),onClick:()=>void Y(async()=>{let e=[`rdp`,`vnc`,`ssh`].includes(K.protocol)?`rap-secret://org/${K.organizationId}/resources/${crypto.randomUUID()}/primary`:null,t=await q.createResource({organizationId:K.organizationId,name:K.name,address:K.address,protocol:K.protocol,secretRef:e,certificateVerificationMode:K.protocol===`rdp`?`ignore`:`strict`,clipboardMode:K.protocol===`rdp`?`bidirectional`:`disabled`,fileTransferMode:K.protocol===`rdp`?`bidirectional`:`disabled`,metadata:{route_mode:K.routeMode,preferred_entry_node_id:K.entryNode||null,preferred_exit_node_id:K.exitNode||null,tags:K.tags.split(`,`).map(e=>e.trim()).filter(Boolean)}});[`rdp`,`vnc`,`ssh`].includes(K.protocol)&&(K.username.trim()||K.password)&&await q.upsertResourceSecret(t.id,{username:K.username.trim(),password:K.password,domain:K.domain.trim()}),na({...K,name:``,address:``,tags:``,username:``,password:``,domain:``})},`Сервер добавлен в справочник.`),children:`Добавить сервер`})}),(0,E.jsx)(N,{columns:[`сервер`,`адрес`,`протокол`,`секрет`,`организация`,`маршрут`,`создано`,`действия`],rows:yr.map(e=>{let t=e.metadata||{},n=hr.find(t=>t.id===e.organization_id);return[e.name,e.address,e.protocol,e.has_secret?`сохранен`:e.secret_ref?`нужен payload`:`нет`,n?.name||B(e.organization_id),`${B(String(t.preferred_entry_node_id||``))||`auto`} -> ${B(String(t.preferred_exit_node_id||``))||`auto`}`,V(e.created_at),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>{Yi(e),Zi({username:``,password:``,domain:``})},children:`Обновить secret`})]})}),Ji&&(0,E.jsx)(`div`,{className:`modalBackdrop`,role:`presentation`,children:(0,E.jsxs)(`div`,{className:`modalCard`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`resource-secret-title`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{id:`resource-secret-title`,children:`Учетные данные RDP`}),(0,E.jsxs)(`p`,{className:`muted`,children:[Ji.name,` · `,Ji.address]})]}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>Yi(null),children:`Закрыть`})]}),(0,E.jsxs)(Qe,{children:[(0,E.jsxs)(`label`,{children:[`Пользователь`,(0,E.jsx)(`input`,{value:Xi.username,onChange:e=>Zi({...Xi,username:e.target.value}),placeholder:`user или DOMAIN\\\\user`})]}),(0,E.jsxs)(`label`,{children:[`Пароль`,(0,E.jsx)(`input`,{type:`password`,value:Xi.password,onChange:e=>Zi({...Xi,password:e.target.value})})]}),(0,E.jsxs)(`label`,{children:[`Домен`,(0,E.jsx)(`input`,{value:Xi.domain,onChange:e=>Zi({...Xi,domain:e.target.value}),placeholder:`опционально`})]})]}),(0,E.jsx)(`p`,{className:`muted`,children:`Пароль сохраняется как encrypted resource secret. В metadata ресурса он не попадет.`}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`button`,{className:`primary`,disabled:!Xi.username.trim()||!Xi.password,onClick:()=>void Y(async()=>{await q.upsertResourceSecret(Ji.id,{username:Xi.username.trim(),password:Xi.password,domain:Xi.domain.trim()}),Yi(null),Zi({username:``,password:``,domain:``})},`Secret ресурса обновлен.`),children:`Сохранить secret`}),(0,E.jsx)(`button`,{onClick:()=>Yi(null),children:`Отмена`})]})]})})]})}),C===`audit`&&(0,E.jsxs)(`section`,{className:`card`,children:[(0,E.jsxs)(`div`,{className:`cardHead`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsx)(`h3`,{children:`Аудит кластера`}),Tr&&(0,E.jsxs)(`p`,{className:`muted`,children:[`Фильтр по узлу: `,(0,E.jsx)(`strong`,{children:Dr||B(Tr)})]})]}),Tr&&(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>{Er(``),Or(``)},children:`Сбросить фильтр`})]}),(0,E.jsx)(N,{columns:[`событие`,`цель`,`actor`,`создано`],rows:Ma.map(e=>[e.event_type,`${e.target_type}${e.target_id?`:${B(e.target_id)}`:``}`,e.actor_user_id?B(e.actor_user_id):`system`,V(e.created_at)])})]})]})]})}function he({label:e,value:t,tone:n}){return(0,E.jsxs)(`article`,{className:`metric ${n}`,children:[(0,E.jsx)(`span`,{children:e}),(0,E.jsx)(`strong`,{children:t})]})}function ge({label:e,value:t}){return(0,E.jsxs)(`div`,{className:`signal`,children:[(0,E.jsx)(`span`,{children:e}),(0,E.jsx)(`strong`,{children:t})]})}function _e({value:e}){return(0,E.jsx)(`span`,{className:`status ${e.replace(/_/g,`-`)}`,children:H(e)})}function ve({label:e,value:t,tone:n}){return(0,E.jsxs)(`span`,{className:`functionState ${n||``}`,children:[(0,E.jsx)(`small`,{children:e}),(0,E.jsx)(`strong`,{children:t})]})}function A({label:e,value:t}){return(0,E.jsxs)(`div`,{className:`stateLine`,children:[(0,E.jsx)(`span`,{children:e}),(0,E.jsx)(`strong`,{children:t})]})}function ye({title:e,text:t}){return(0,E.jsxs)(`article`,{className:`empty`,children:[(0,E.jsx)(`h3`,{children:e}),(0,E.jsx)(`p`,{children:t})]})}function be(e){if(!e)return(0,E.jsx)(`p`,{className:`muted`,children:`Диагностика Android-клиента не загружена. Укажи device id из приложения и нажми “Обновить клиент”.`});let t=F(e.payload)||{},n=F(t.runtime),r=F(t.vpn_config),i=I(t,`app_version`,`н/д`),a=I(t,`service_state`,`н/д`),o=I(t,`control_network_mode`,`н/д`),s=I(r,`packet_relay_active_base_url`)||I(r,`packet_relay_base_url`,`н/д`),c=I(r,`packet_relay_profile_base_url`,`н/д`),l=I(r,`packet_relay_candidate_urls`,`н/д`),u=Ot(n,`uplink_read_total`),d=Ot(n,`uplink_sent_total`),f=Ot(n,`downlink_received_total`),p=Ot(n,`uplink_dropped_packets`)+Ot(n,`downlink_dropped_packets`),m=Ot(n,`uplink_bypassed_control_packets`),h=Ot(n,`downlink_received_bytes`),g=Ot(n,`uplink_sent_bytes`),_=I(n,`state`,`н/д`),v=I(n,`message`,``),y=Ot(n,`uplink_sent_mbps`),b=Ot(n,`downlink_received_mbps`),x=I(t,`last_command_type`,`н/д`),S=I(t,`last_command_result`,`н/д`);return(0,E.jsxs)(`div`,{className:`vpnCard diagnosticCard`,children:[(0,E.jsxs)(`div`,{children:[(0,E.jsxs)(`strong`,{children:[`Android client `,B(e.device_id)]}),(0,E.jsxs)(`p`,{className:`muted`,children:[i,` / `,a,` / `,V(e.observed_at)]}),(0,E.jsx)(_e,{value:Date.now()-new Date(e.observed_at).getTime()<3e4?`active`:`degraded`}),(0,E.jsx)(`span`,{className:`pill`,children:o})]}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Relay active`,value:s}),(0,E.jsx)(A,{label:`Relay profile`,value:c}),(0,E.jsx)(A,{label:`Relay candidates`,value:l}),(0,E.jsx)(A,{label:`Packets read/sent/down`,value:`${u} / ${d} / ${f}`}),(0,E.jsx)(A,{label:`Drops / control bypass`,value:`${p} / ${m}`}),(0,E.jsx)(A,{label:`Bytes up/down`,value:`${$n(g)} / ${$n(h)}`}),(0,E.jsx)(A,{label:`Rate up/down`,value:`${y.toFixed(2)} / ${b.toFixed(2)} Mbps`}),(0,E.jsx)(A,{label:`Runtime`,value:v?`${_}: ${v}`:_}),(0,E.jsx)(A,{label:`Last command`,value:`${x}: ${S}`})]})]})}function xe({items:e,emptyText:t}){if(e.length===0)return(0,E.jsx)(ye,{title:t,text:`Тестовая телеметрия появится здесь после отчета node-agent.`});let n=[...e].reverse().slice(-24),r=e[0],i=Math.max(...n.map(e=>e.memory_used_bytes||0),1);return(0,E.jsxs)(`div`,{className:`telemetryBox`,children:[(0,E.jsxs)(`div`,{className:`signalStrip compact`,children:[(0,E.jsx)(ge,{label:`Память`,value:`${$n(r.memory_used_bytes)} / ${$n(r.memory_total_bytes)}`}),(0,E.jsx)(ge,{label:`Процессор`,value:r.cpu_percent==null?`н/д`:`${r.cpu_percent.toFixed(1)}%`}),(0,E.jsx)(ge,{label:`Процессы`,value:r.process_count==null?`н/д`:String(r.process_count)}),(0,E.jsx)(ge,{label:`Обновлено`,value:V(r.observed_at)})]}),(0,E.jsx)(`div`,{className:`sparkline`,"aria-label":`memory telemetry`,children:n.map(e=>(0,E.jsx)(`span`,{style:{height:`${Math.max(8,Math.round((e.memory_used_bytes||0)/i*100))}%`}},e.id))})]})}function Se({node:e,memberships:t,activeRoles:n,desiredWorkloads:r,observedWorkloads:i,heartbeats:a,telemetry:o,meshLinks:s,syntheticConfig:c,allNodes:l,onSetUpdatePolicy:u,updatePlan:d,updateStatuses:f,labels:p}){let m=a[0],h=o[0],g=F(m?.metadata?.mesh_listener_report),v=F(m?.metadata?.mesh_endpoint_report),y=F(m?.metadata?.mesh_outbound_session_report),b=c?.mesh_listener,x=F(m?.metadata?.mesh_peer_recovery_report),S=F(m?.metadata?.mesh_peer_connection_intent_report),C=F(m?.metadata?.mesh_peer_connection_manager_report),w=F(m?.metadata?.mesh_rendezvous_lease_report),T=F(m?.metadata?.mesh_route_path_decision_report),ee=F(m?.metadata?.mesh_route_generation_report),te=F(m?.metadata?.mesh_route_health_config_report),D=c?.service_channel_route_feedback,ne=D?.observations||[],re=c?.service_channel_remediation_commands||[],ie=yt(s).filter(e=>e.source_node_id!==e.target_node_id),ae=ie.filter(e=>e.link_status===`reachable`),oe=ie.filter(e=>e.link_status!==`reachable`),se=Object.entries(m?.capabilities||{}).sort(([e],[t])=>e.localeCompare(t)),O=Tt(C?.probe_results),[k,ce]=(0,_.useState)(`network`),le=ot(f,`rap-node-agent`),ue=ot(f,`rap-host-agent`),de=f[0],fe=gt(f),pe=t.find(t=>t.node.id===e.id)?.cluster.id||t[0]?.cluster.id||``,me=Tt(v?.endpoint_candidates),he=me[0],ve=Et(v,[`peer_endpoint`,`advertised_endpoint`,`endpoint`])||I(he,`address`,``)||``,ye=Et(v,[`transport`,`advertise_transport`])||I(he,`transport`,``)||`н/д`,be=Et(v,[`connectivity_mode`,`connectivity`])||I(he,`connectivity_mode`,``)||I(g,`inbound_reachability`,``)||`н/д`,Se=I(v,`nat_type`,I(he,`nat_type`,`н/д`)),we=I(v,`region`,I(g,`region`,I(he,`region`,`н/д`))),Te=I(v,`observed_at`,I(g,`observed_at`,m?.observed_at||`н/д`)),Ee=I(g,`status`,``)||(ve?`нет listener report, есть advertised endpoint`:`report отсутствует`),j=I(g,`effective_listen_addr`,``)||`н/д`,De=I(g,`configured_listen_addr`,``)||`н/д`,Oe=me.length>0?me:ve?[{endpoint_id:`${e.id}-reported`,address:ve,transport:ye,reachability:be,connectivity_mode:be,nat_type:Se,priority:`н/д`,last_verified_at:Te}]:[],ke=Tt(b?.endpoint_candidates),Ae=Object.entries(c?.peer_endpoints||{}),je=Object.entries(c?.peer_endpoint_candidates||{}).flatMap(([e,t])=>t.map(t=>({peerID:e,candidate:t}))),Me=new Set(ae.map(t=>t.source_node_id===e.id?t.target_node_id:t.source_node_id)),Ne=je.filter(({peerID:e})=>!Me.has(e)),Pe=[g?`listener report: есть`:`listener report: не прислан агентом`,v?`endpoint report: есть`:`endpoint report: не прислан агентом`,y?`outbound session: есть`:`outbound session: не прислан агентом`,c?`scoped config: ${c.enabled?`enabled`:`disabled`}`:`scoped config: не загружен`,D?`service-channel feedback: ${D.observation_count}`:`service-channel feedback: не загружен`,`active links: ${ae.length}/${ie.length}`];return(0,E.jsxs)(`div`,{className:`nodeDetails`,children:[(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Сводка runtime`}),(0,E.jsxs)(`div`,{className:`signalStrip compact nodeMetricGrid`,children:[(0,E.jsx)(ge,{label:`Heartbeat`,value:m?V(m.observed_at):`н/д`}),(0,E.jsx)(ge,{label:`Health`,value:H(m?.health_status||e.health_status)}),(0,E.jsx)(ge,{label:`Listener`,value:Wn(m)}),(0,E.jsx)(ge,{label:`Mesh links`,value:`${ae.length}/${ie.length}`}),(0,E.jsx)(ge,{label:`Web ingress`,value:Re(m)}),(0,E.jsx)(ge,{label:`Update`,value:st(de,d)})]}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[(0,E.jsx)(_e,{value:e.registration_status}),(0,E.jsx)(_e,{value:e.membership_status}),(0,E.jsx)(_e,{value:e.partition_state}),(0,E.jsx)(`span`,{className:`pill`,children:e.reported_version||m?.reported_version||`версия неизвестна`}),g?.one_way_connectivity===!0&&(0,E.jsx)(`span`,{className:`pill warn`,children:`one-way`}),g?.port_conflict===!0&&(0,E.jsx)(`span`,{className:`pill bad`,children:`port conflict`})]})]}),(0,E.jsx)(`div`,{className:`nodeTabs`,role:`tablist`,"aria-label":`Node analysis sheets`,children:[[`overview`,`Обзор`],[`network`,`Сеть и адреса`],[`mesh`,`Mesh`],[`services`,`Роли и сервисы`],[`telemetry`,`Телеметрия`],[`updates`,`Обновления`],[`raw`,`Raw`]].map(([e,t])=>(0,E.jsx)(`button`,{className:k===e?`active`:``,onClick:()=>ce(e),type:`button`,children:t},e))}),k===`overview`&&(0,E.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Идентичность и размещение`}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Node ID`,value:e.id}),(0,E.jsx)(A,{label:`Node key`,value:e.node_key}),(0,E.jsx)(A,{label:`Имя`,value:e.name}),(0,E.jsx)(A,{label:`Владение`,value:H(e.ownership_type)}),(0,E.jsx)(A,{label:`Owner org`,value:B(e.owner_organization_id)}),(0,E.jsx)(A,{label:`Группа`,value:e.node_group_name||p.ungroupedNodes}),(0,E.jsx)(A,{label:`Создан`,value:V(e.created_at)}),(0,E.jsx)(A,{label:`Обновлен`,value:V(e.updated_at)})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Участие в кластерах`}),(0,E.jsx)(`div`,{className:`membershipList`,children:t.map(t=>(0,E.jsxs)(`span`,{className:t.node.id===e.id&&t.node.membership_status===`active`?`pill good`:`pill`,children:[t.cluster.name,`: `,H(t.node.membership_status)]},t.cluster.id))}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Активных ролей`,value:String(n.length)}),(0,E.jsx)(A,{label:`Desired workloads`,value:String(r.length)}),(0,E.jsx)(A,{label:`Observed workloads`,value:String(i.length)}),(0,E.jsx)(A,{label:`Последний сигнал`,value:V(e.last_seen_at||m?.observed_at)})]})]})]}),k===`network`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Локальный listener`}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Статус`,value:Ee}),(0,E.jsx)(A,{label:`Режим порта`,value:I(g,`listen_port_mode`,`н/д`)}),(0,E.jsx)(A,{label:`Configured addr`,value:De}),(0,E.jsx)(A,{label:`Effective addr`,value:j}),(0,E.jsx)(A,{label:`Inbound`,value:I(g,`inbound_reachability`,be)}),(0,E.jsx)(A,{label:`One-way`,value:I(g,`one_way_connectivity`,`н/д`)}),(0,E.jsx)(A,{label:`Port conflict`,value:I(g,`port_conflict`,`false`)}),(0,E.jsx)(A,{label:`Failure`,value:I(g,`failure_error`,I(g,`failure_reason`,`нет`))})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Desired listener`}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Состояние`,value:b?.desired_state||`н/д`}),(0,E.jsx)(A,{label:`Режим порта`,value:b?.listen_port_mode||`н/д`}),(0,E.jsx)(A,{label:`Listen addr`,value:b?.listen_addr||`н/д`}),(0,E.jsx)(A,{label:`Auto range`,value:b?`${b.auto_port_start||`н/д`}-${b.auto_port_end||`н/д`}`:`н/д`}),(0,E.jsx)(A,{label:`Advertise endpoint`,value:b?.advertise_endpoint||`auto-discovery`}),(0,E.jsx)(A,{label:`Endpoint candidates`,value:ke.length>0?String(ke.length):`auto-discovery`}),(0,E.jsx)(A,{label:`Advertise transport`,value:b?.advertise_transport||`н/д`}),(0,E.jsx)(A,{label:`Connectivity`,value:b?.connectivity_mode||`н/д`}),(0,E.jsx)(A,{label:`NAT`,value:b?.nat_type||`н/д`}),(0,E.jsx)(A,{label:`Region/site`,value:b?.region||`н/д`}),(0,E.jsx)(A,{label:`Version`,value:b?.config_version||`н/д`})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Что узел сообщает кластеру`}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Advertised endpoint`,value:ve||`не прислан`}),(0,E.jsx)(A,{label:`Transport`,value:ye}),(0,E.jsx)(A,{label:`Connectivity`,value:be}),(0,E.jsx)(A,{label:`NAT`,value:Se}),(0,E.jsx)(A,{label:`Region/site`,value:we}),(0,E.jsx)(A,{label:`Observed`,value:Te})]})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Исходящий control-channel`}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Status`,value:I(y,`status`,`не прислан`)}),(0,E.jsx)(A,{label:`Direction`,value:I(y,`direction`,`н/д`)}),(0,E.jsx)(A,{label:`Transport`,value:I(y,`transport`,`н/д`)}),(0,E.jsx)(A,{label:`Control API endpoint`,value:I(y,`control_plane_url`,`н/д`)}),(0,E.jsx)(A,{label:`Reverse usable`,value:I(y,`usable_for_inbound_control`,`н/д`)}),(0,E.jsx)(A,{label:`Inbound required`,value:I(y,`inbound_listener_required`,`н/д`)}),(0,E.jsx)(A,{label:`Relay ready`,value:I(y,`peer_connection_relay_ready`,`0`)}),(0,E.jsx)(A,{label:`Waiting rendezvous`,value:I(y,`peer_connection_waiting`,`0`)}),(0,E.jsx)(A,{label:`Rendezvous leases`,value:I(y,`rendezvous_lease_count`,`0`)}),(0,E.jsx)(A,{label:`Listener conflict`,value:I(y,`listener_port_conflict`,`false`)})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Наличие сетевых отчетов`}),(0,E.jsx)(`div`,{className:`summaryChips`,children:Pe.map(e=>(0,E.jsx)(`span`,{className:e.includes(`не прислан`)||e.includes(`не загружен`)?`pill warn`:`pill good`,children:e},e))}),!v&&!g&&(0,E.jsx)(`p`,{className:`muted`,children:`У этого узла есть heartbeat/mesh manager данные, но агент не передал адресный отчет. До обновления агента или включения endpoint/listener report панель может показать связи и config peers, но не может достоверно назвать локальный listen address.`})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Endpoint candidates узла`}),(0,E.jsx)(N,{columns:[`id`,`address`,`transport`,`reachability`,`mode`,`nat`,`priority`,`verified`],rows:Oe.map(e=>[I(e,`endpoint_id`,`н/д`),I(e,`address`,`н/д`),I(e,`transport`,`н/д`),I(e,`reachability`,`н/д`),I(e,`connectivity_mode`,`н/д`),I(e,`nat_type`,`н/д`),I(e,`priority`,`н/д`),I(e,`last_verified_at`,`н/д`)])})]}),(0,E.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Рабочие peer endpoints из config`}),(0,E.jsx)(N,{columns:[`peer`,`endpoint`],rows:Ae.map(([e,t])=>[L(l,e),t])})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Резервные кандидаты peer`}),(0,E.jsx)(N,{columns:[`peer`,`address`,`transport`,`reachability`,`mode`,`priority`],rows:Ne.slice(0,20).map(({peerID:e,candidate:t})=>[L(l,e),t.address,t.transport,t.reachability,t.connectivity_mode,String(t.priority)])})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Активные связи этого узла`}),(0,E.jsx)(N,{columns:[`peer`,`направление`,`тип`,`статус`,`latency`,`quality`,`путь`,`наблюдение`],rows:ie.slice(0,20).map(t=>[L(l,t.source_node_id===e.id?t.target_node_id:t.source_node_id),t.source_node_id===e.id?`out`:`in`,xt(t),t.link_status,t.latency_ms==null?`н/д`:`${t.latency_ms}мс`,t.quality_score==null?`н/д`:String(t.quality_score),St(t,l),V(t.observed_at)])}),oe.length>0&&(0,E.jsxs)(`p`,{className:`muted`,children:[`Проблемных связей: `,oe.length,`. Их статус виден в таблице выше.`]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Проверка адресов peer-to-peer`}),(0,E.jsx)(N,{columns:[`peer`,`status`,`selected endpoint`,`candidate`,`latency`,`attempts`,`failure`],rows:O.slice(0,20).map(e=>[L(l,I(e,`node_id`,``)),I(e,`link_status`,`н/д`),I(e,`selected_endpoint`,I(e,`endpoint`,`н/д`)),I(e,`selected_candidate_id`,`н/д`),I(e,`latency_ms`,`н/д`),It(e),I(e,`failure_reason`,`нет`)])})]})]}),k===`mesh`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Mesh control authority`}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Recovery`,value:Ln(m)}),(0,E.jsx)(A,{label:`Intents`,value:Rn(m)}),(0,E.jsx)(A,{label:`Manager`,value:Un(m)}),(0,E.jsx)(A,{label:`Rendezvous`,value:zn(m)}),(0,E.jsx)(A,{label:`Path decisions`,value:Bn(m)}),(0,E.jsx)(A,{label:`Route generation`,value:Vn(m)}),(0,E.jsx)(A,{label:`Route health`,value:Hn(m)}),(0,E.jsx)(A,{label:`Service-channel feedback`,value:D?`${D.healthy_route_count} healthy / ${D.degraded_route_count} degraded / ${D.fenced_route_count} fenced`:`н/д`}),(0,E.jsx)(A,{label:`Recovery policy`,value:D?.recovery_policy?`${D.recovery_policy.source} p${D.recovery_policy.hysteresis_penalty} promote ${D.recovery_policy.promotion_min_samples}`:`н/д`}),(0,E.jsx)(A,{label:`Route policy`,value:c?.route_path_decisions?.recovery_policy?`${c.route_path_decisions.recovery_policy.source} fail/drop/slow ${c.route_path_decisions.recovery_policy.demotion_failure_threshold}/${c.route_path_decisions.recovery_policy.demotion_drop_threshold}/${c.route_path_decisions.recovery_policy.demotion_slow_threshold}`:`н/д`}),(0,E.jsx)(A,{label:`Config version`,value:c?.config_version||`н/д`})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Scoped config counts`}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Peer endpoints`,value:String(Ae.length)}),(0,E.jsx)(A,{label:`Endpoint candidates`,value:String(je.length)}),(0,E.jsx)(A,{label:`Peer directory`,value:String(c?.peer_directory?.length||0)}),(0,E.jsx)(A,{label:`Recovery seeds`,value:String(c?.recovery_seeds?.length||0)}),(0,E.jsx)(A,{label:`Rendezvous leases`,value:String(c?.rendezvous_leases?.length||0)}),(0,E.jsx)(A,{label:`Routes`,value:String(c?.routes?.length||0)}),(0,E.jsx)(A,{label:`Fenced routes`,value:String(D?.fenced_route_count||0)}),(0,E.jsx)(A,{label:`Remediation commands`,value:String(re.length)}),(0,E.jsx)(A,{label:`Feedback provenance`,value:D?`missing ${D.missing_provenance_count||0} / stale policy ${D.stale_policy_count||0} / stale gen ${D.stale_generation_count||0}`:`н/д`})]})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Route decisions`}),(0,E.jsx)(N,{columns:[`route`,`replacement`,`source`,`destination`,`effective hops`,`decision`,`score`,`expires`],rows:(c?.route_path_decisions?.decisions||[]).map(e=>[B(e.route_id),e.replacement_route_id?B(e.replacement_route_id):`нет`,L(l,e.source_node_id),L(l,e.destination_node_id),e.effective_hops.map(e=>qn(L(l,e))).join(` > `),e.decision_source||(e.selected_relay_id?L(l,e.selected_relay_id):`direct`),e.path_score==null?`н/д`:String(e.path_score),V(e.expires_at)])})]}),re.length>0&&(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Service-channel remediation commands`}),(0,E.jsx)(N,{columns:[`channel`,`action`,`primary`,`replacement`,`guard`,`execution`,`reason`,`expires`],rows:re.slice(0,20).map(e=>[B(e?.channel_id||``),(0,E.jsx)(`span`,{className:`pill warn`,children:H(e?.action||``)}),e?.primary_route_id?B(e.primary_route_id):`н/д`,e?.replacement_route_id?B(e.replacement_route_id):`н/д`,(0,E.jsx)(`span`,{className:`pill ${e?.guard_status===`rejected`?`bad`:e?.guard_status===`allowed`?`good`:``}`,children:e?.guard_status?H(e.guard_status):`н/д`}),(0,E.jsxs)(`span`,{className:`pill ${rr(e?.execution_status)}`,children:[e?.execution_status?H(e.execution_status):`н/д`,e?.execution_reason?` / ${H(e.execution_reason)}`:``]}),e?.reason||`н/д`,e?.expires_at?V(e.expires_at):`н/д`])})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Service-channel route feedback`}),(0,E.jsx)(N,{columns:[`route`,`service`,`status`,`recovery`,`score`,`reasons`,`failures`,`duration`,`expires`],rows:ne.slice(0,40).map(e=>[B(e.route_id),e.service_class,(0,E.jsx)(`span`,{className:`pill ${dt(e.feedback_status)}`,children:H(e.feedback_status)}),e.recovery_state?(0,E.jsxs)(`span`,{className:`pill ${ft(e.recovery_state)}`,children:[e.recovery_demoted?`demoted ${e.recovery_reason?H(e.recovery_reason):``}`:e.recovery_promoted?`promoted`:H(e.recovery_state),e.recovery_hysteresis_penalty?` -${e.recovery_hysteresis_penalty}`:``]}):e.stale_policy||e.stale_generation?(0,E.jsx)(`span`,{className:`pill warn`,children:H(e.stale_reason||`stale`)}):e.provenance_missing?(0,E.jsx)(`span`,{className:`pill warn`,children:`provenance missing`}):`нет`,String(e.score_adjustment),(e.reasons||[]).join(`, `)||`нет`,String(e.consecutive_failures||0),e.last_send_duration_ms==null?`н/д`:`${e.last_send_duration_ms}мс`,V(e.expires_at)])}),ne.length===0&&(0,E.jsx)(`p`,{className:`muted`,children:`Пока нет свежих наблюдений. Узел будет присылать их после реального traffic через service-channel runtime.`})]})]}),k===`services`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:p.nodeRoles}),(0,E.jsxs)(`div`,{className:`serviceTags`,children:[n.length===0&&(0,E.jsx)(`p`,{className:`muted`,children:p.noRoles}),n.map(e=>(0,E.jsxs)(`div`,{className:`serviceTag`,children:[(0,E.jsx)(`strong`,{children:rt(e.role)}),(0,E.jsx)(`span`,{children:e.organization_id?`organization: ${B(e.organization_id)}`:`cluster-wide`}),(0,E.jsx)(`small`,{children:V(e.assigned_at)}),(0,E.jsx)(`span`,{className:`pill ${Pn(e.role,m)}`,children:Fn(e.role,m,p)})]},e.id))]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Capabilities`}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[se.length===0&&(0,E.jsx)(`span`,{className:`muted`,children:`Нет capability heartbeat.`}),se.slice(0,40).map(([e,t])=>(0,E.jsx)(`span`,{className:t===!0?`pill good`:`pill`,children:e},e))]})]})]}),(0,E.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:p.desiredServices}),(0,E.jsx)(N,{columns:[`service`,`desired`,`runtime`,`version`,`updated`],rows:r.map(e=>[e.service_type,H(e.desired_state),e.runtime_mode,e.version||`не закреплена`,V(e.updated_at)])})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:p.observedServices}),(0,E.jsx)(N,{columns:[`service`,`reported`,`runtime`,`version`,`observed`],rows:i.map(e=>[e.service_type,H(e.reported_state),e.runtime_mode,e.version||`н/д`,V(e.observed_at)])})]})]})]}),k===`telemetry`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:p.nodeTelemetry}),(0,E.jsx)(xe,{items:o,emptyText:p.noTelemetry}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Disk`,value:`${$n(h?.disk_used_bytes)} / ${$n(h?.disk_total_bytes)}`}),(0,E.jsx)(A,{label:`Network RX/TX`,value:`${$n(h?.network_rx_bytes)} / ${$n(h?.network_tx_bytes)}`}),(0,E.jsx)(A,{label:`Payload`,value:h?.payload?Ft(h.payload):`н/д`})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:p.recentHeartbeats}),(0,E.jsx)(N,{columns:[`состояние`,`версия`,`listener`,`mesh recovery`,`mesh intents`,`rv leases`,`path decisions`,`route gen`,`route health`,`наблюдение`],rows:a.slice(0,10).map(e=>[e.health_status,e.reported_version||`неизвестно`,Wn(e),Ln(e),Rn(e),zn(e),Bn(e),Vn(e),Hn(e),V(e.observed_at)])})]})]}),k===`updates`&&(0,E.jsxs)(E.Fragment,{children:[(0,E.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Текущая сборка`}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Node-agent version`,value:e.reported_version||m?.reported_version||`неизвестно`}),(0,E.jsx)(A,{label:`План`,value:d?`${d.action}: ${d.reason}`:`не загружен`}),(0,E.jsx)(A,{label:`Product`,value:d?.product||`rap-node-agent`}),(0,E.jsx)(A,{label:`Target`,value:d?.target_version||`н/д`}),(0,E.jsx)(A,{label:`Strategy`,value:d?.strategy||`н/д`}),(0,E.jsx)(A,{label:`Rollback`,value:d?.rollback_allowed?`разрешен`:`нет`}),(0,E.jsx)(A,{label:`Artifact`,value:d?.artifact?`${d.artifact.kind} ${d.artifact.os}/${d.artifact.arch}`:`н/д`})]}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`button`,{className:`primary`,disabled:!u,onClick:()=>u?.(e,`rap-node-agent`,null),children:`Node-agent latest`}),(0,E.jsx)(`button`,{className:`ghost`,disabled:!u||!d?.target_version,onClick:()=>u?.(e,`rap-node-agent`,d?.target_version||null),children:`Повторить target`}),(0,E.jsx)(`button`,{className:`ghost`,disabled:!u,onClick:()=>u?.(e,`rap-host-agent`,null),children:`Host-agent latest`})]}),(0,E.jsx)(`p`,{className:`muted`,children:`Latest означает policy без закрепленной версии: updater будет брать свежий active release своего канала при следующем цикле или heartbeat hint.`})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Последние отчеты updater`}),(0,E.jsxs)(`div`,{className:`stateList`,children:[(0,E.jsx)(A,{label:`Updater health`,value:`${fe.label}: ${fe.detail}`}),(0,E.jsx)(A,{label:`rap-node-agent`,value:ct(le)}),(0,E.jsx)(A,{label:`rap-host-agent`,value:ct(ue)}),(0,E.jsx)(A,{label:`Всего отчетов`,value:String(f.length)}),(0,E.jsx)(A,{label:`Последний отчет`,value:V(de?.observed_at)})]}),(0,E.jsxs)(`div`,{className:`summaryChips`,children:[(0,E.jsx)(`span`,{className:`pill ${fe.tone}`,children:fe.label}),le&&(0,E.jsxs)(`span`,{className:`pill ${ut(le)}`,children:[`node-agent: `,le.status]}),ue&&(0,E.jsxs)(`span`,{className:`pill ${ut(ue)}`,children:[`host-agent: `,ue.status]}),!le&&!ue&&(0,E.jsx)(`span`,{className:`pill warn`,children:`updater пока не отчитался`})]})]})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`История обновлений`}),(0,E.jsx)(N,{columns:[`product`,`current`,`target`,`phase`,`status`,`attempt`,`error`,`observed`],rows:f.slice(0,40).map(e=>[e.product,e.current_version||`н/д`,e.target_version||`н/д`,e.phase,(0,E.jsx)(`span`,{className:`pill ${ut(e)}`,children:e.status}),e.attempt_id?B(e.attempt_id):`н/д`,e.error_message||`нет`,V(e.observed_at)])})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Windows repair/update command`}),(0,E.jsx)(`p`,{className:`muted`,children:`Для существующего Windows-узла эта команда переустанавливает wrapper updater без нового join-token, сохраняет local state и запускает обновление до актуальной сборки.`}),(0,E.jsxs)(`div`,{className:`stateList compact`,children:[(0,E.jsx)(A,{label:`Когда выполнять`,value:`если updater stale, host-agent не отчитался или Windows-узел не доходит до target version`}),(0,E.jsx)(A,{label:`Control API endpoint`,value:R()}),(0,E.jsx)(A,{label:`Join-token`,value:`не нужен для repair существующего узла`})]}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`button`,{className:`primary`,onClick:()=>hn(fn(e),dn(e,pe)),children:`Скачать repair .cmd`}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>void gn(dn(e,pe)),children:`Скопировать команду`})]}),(0,E.jsx)(`pre`,{className:`codePreview`,children:dn(e,pe)})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Linux repair/update command`}),(0,E.jsx)(`p`,{className:`muted`,children:`Для существующего Ubuntu/Linux-узла эта команда восстанавливает systemd updater без нового join-token, сохраняет local state и делает одноразовую проверку обновления.`}),(0,E.jsxs)(`div`,{className:`stateList compact`,children:[(0,E.jsx)(A,{label:`Когда выполнять`,value:`если host-agent не отчитался, updater stale или Linux-узел не доходит до target version`}),(0,E.jsx)(A,{label:`Control API endpoint`,value:R()}),(0,E.jsx)(A,{label:`Join-token`,value:`не нужен для repair существующего узла`})]}),(0,E.jsxs)(`div`,{className:`actions`,children:[(0,E.jsx)(`button`,{className:`primary`,onClick:()=>hn(mn(e),pn(e,pe)),children:`Скачать repair .sh`}),(0,E.jsx)(`button`,{className:`ghost`,onClick:()=>void gn(pn(e,pe)),children:`Скопировать команду`})]}),(0,E.jsx)(`pre`,{className:`codePreview`,children:pn(e,pe)})]}),(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Payload последнего отчета`}),(0,E.jsxs)(`div`,{className:`rawDetailsGrid`,children:[(0,E.jsx)(Ce,{title:`rap-node-agent update status`,value:le}),(0,E.jsx)(Ce,{title:`rap-host-agent update status`,value:ue}),(0,E.jsx)(Ce,{title:`Update plan`,value:d})]})]})]}),k===`raw`&&(0,E.jsxs)(`section`,{className:`nodePanel`,children:[(0,E.jsx)(`h4`,{children:`Raw данные узла`}),(0,E.jsxs)(`div`,{className:`rawDetailsGrid`,children:[(0,E.jsx)(Ce,{title:`Последний heartbeat metadata`,value:m?.metadata}),(0,E.jsx)(Ce,{title:`Heartbeat capabilities`,value:m?.capabilities}),(0,E.jsx)(Ce,{title:`Heartbeat service states`,value:m?.service_states}),(0,E.jsx)(Ce,{title:`Synthetic mesh config`,value:c}),(0,E.jsx)(Ce,{title:`Listener report`,value:g}),(0,E.jsx)(Ce,{title:`Endpoint report`,value:v}),(0,E.jsx)(Ce,{title:`Peer recovery report`,value:x}),(0,E.jsx)(Ce,{title:`Connection intent report`,value:S}),(0,E.jsx)(Ce,{title:`Connection manager report`,value:C}),(0,E.jsx)(Ce,{title:`Rendezvous lease report`,value:w}),(0,E.jsx)(Ce,{title:`Route decision report`,value:T}),(0,E.jsx)(Ce,{title:`Route generation report`,value:ee}),(0,E.jsx)(Ce,{title:`Route health report`,value:te})]})]})]})}function Ce({title:e,value:t}){return(0,E.jsxs)(`details`,{className:`rawBlock`,children:[(0,E.jsx)(`summary`,{children:e}),(0,E.jsx)(`pre`,{children:t==null?`н/д`:JSON.stringify(t,null,2)})]})}function we({runtime:e}){return(0,E.jsxs)(`div`,{className:`runtimeBadges`,children:[(0,E.jsx)(`span`,{className:`pill ${e.agentTone}`,children:e.agentLabel}),(0,E.jsx)(`span`,{className:`pill ${e.clientTone}`,children:e.clientLabel}),(0,E.jsx)(`span`,{className:`pill ${e.outboundTone}`,children:e.outboundLabel}),(0,E.jsx)(`span`,{className:`pill ${e.inboundTone}`,children:e.inboundLabel})]})}function Te({node:e,fallback:t,heartbeatsByNode:n,meshLinks:r}){if(!e)return t;let i=Dt(e,n[e.id]||[],r);return(0,E.jsxs)(`div`,{className:`nodeEndpointCell`,children:[(0,E.jsx)(`strong`,{children:e.name}),(0,E.jsx)(we,{runtime:i}),(0,E.jsx)(`small`,{children:i.address})]})}function Ee({nodes:e,links:t,heartbeatsByNode:n,rolesByNode:r,workloadsByNode:i,labels:a,emptyText:o}){let[s,c]=(0,_.useState)(null);if(e.length===0)return(0,E.jsx)(ye,{title:`Нет узлов`,text:`Одобренные node-agent появятся на карте после первого heartbeat.`});let l=yt(t).filter(e=>e.source_node_id!==e.target_node_id),u=new Map(e.map(e=>[e.id,e])),d=l.filter(e=>Fe(e)&&!He(e,u)).map(e=>({link:e,status:Pe(e,l,u),kind:`direct`})).filter(e=>e.status===`reachable`||e.status===`one_way`),f=l.filter(e=>Ie(e)&&!He(e,u)).map(e=>({link:e,status:Pe(e,l,u),kind:`relay`})).filter(e=>e.status===`reachable`||e.status===`one_way`),p=l.filter(e=>P(e,`observation_type`)===`synthetic_route_health`&&!He(e,u)&&e.link_status===`reachable`).map(e=>({link:e,status:`reachable`,kind:`route`})),m=j(d,f,p),h=l.filter(e=>He(e,u)),g=l.filter(e=>!He(e,u)&&e.link_status!==`reachable`),v=Le(m.map(e=>e.link)),y=new Map(e.map(e=>[e.id,M(n[e.id]?.[0])])),b=[...y.values()].filter(e=>e.mode===`active`).length,x=[...y.values()].filter(e=>e.mode===`passive`).length,S=[...y.values()].filter(e=>e.mode===`mixed`).length,C=Ue(e.length),w=We(e.length),T=Ke(e,C.height,w),ee=new Map(e.map(e=>[e.id,De(e.id,m)]));return(0,E.jsxs)(`div`,{className:`topologyShell`,children:[(0,E.jsxs)(`svg`,{className:`topologySvg`,viewBox:`0 0 ${C.width} ${C.height}`,role:`img`,"aria-label":`Карта трафика узлов Fabric`,children:[(0,E.jsx)(`defs`,{children:(0,E.jsx)(`marker`,{id:`arrow`,markerHeight:`8`,markerWidth:`8`,orient:`auto`,refX:`7`,refY:`4`,children:(0,E.jsx)(`path`,{d:`M0,0 L8,4 L0,8 Z`,fill:`currentColor`})})}),m.map(({link:t,status:n,kind:r})=>{let i=T.get(t.source_node_id),a=T.get(t.target_node_id);if(!i||!a)return null;let o=m.some(e=>e.link.source_node_id===t.target_node_id&&e.link.target_node_id===t.source_node_id),s=Xe(t.source_node_id,t.target_node_id,r),l=Ze({source:i,target:a,sourceNodeID:t.source_node_id,targetNodeID:t.target_node_id,positions:T,nodeRadius:w,endpointOffset:w+8,laneOffset:o?9:0,laneSign:s,routeKind:r}),u=ke(Me(t,e,n)),d=Ae(l.labelX,l.labelY,C.width,C.height);return(0,E.jsxs)(`g`,{className:`topologyLinkGroup`,onMouseEnter:()=>c({...u,...d}),onMouseLeave:()=>c(null),children:[(0,E.jsx)(`title`,{children:[u.title,...u.lines].join(` +`)}),(0,E.jsx)(`path`,{d:l.d,className:`topologyLink ${Kn(t,n)} ${r}`,markerEnd:`url(#arrow)`}),m.length<=Math.max(6,e.length)&&(0,E.jsx)(`text`,{x:l.labelX,y:l.labelY-8,className:`topologyLinkLabel`,children:Ne(t,n,r)})]},`${r}-${t.id||`${t.source_node_id}-${t.target_node_id}`}`)}),e.map(t=>{let a=T.get(t.id),o=Ge(e.length),s=ee.get(t.id)||`isolated`,l=y.get(t.id)||{mode:`unknown`,detail:`no heartbeat`},u=ze(n[t.id]?.[0]),d=ke(je(t,l,s,u,n[t.id]?.[0],r[t.id]||[],i[t.id]||[])),f=Ae(a.x,a.y+w+12,C.width,C.height);return(0,E.jsxs)(`g`,{className:`topologyNode`,onMouseEnter:()=>c({...d,...f}),onMouseLeave:()=>c(null),children:[(0,E.jsx)(`title`,{children:[d.title,...d.lines].join(` +`)}),(0,E.jsx)(`circle`,{cx:a.x,cy:a.y,r:w,className:`topologyNodeCircle ${t.health_status} ${l.mode} web-${u}`}),(0,E.jsx)(`text`,{x:a.x,y:a.y-o.nameOffset,className:`topologyNodeName`,style:{fontSize:o.name},children:qn(t.name,o.maxChars)}),(0,E.jsx)(`text`,{x:a.x,y:a.y+o.metaOffset,className:`topologyNodeMeta`,style:{fontSize:o.meta},children:Oe(l.mode,s)})]},t.id)}),m.length===0&&(0,E.jsx)(`text`,{x:C.width/2,y:C.height-34,className:`topologyEmpty`,children:o}),s&&(0,E.jsx)(`foreignObject`,{x:s.x,y:s.y,width:`360`,height:`190`,className:`topologyTooltipObject`,children:(0,E.jsxs)(`div`,{className:`topologyTooltip`,children:[(0,E.jsx)(`strong`,{children:s.title}),s.lines.slice(0,6).map(e=>(0,E.jsx)(`span`,{children:e},e))]})})]}),(0,E.jsxs)(`div`,{className:`topologyLegend`,children:[(0,E.jsxs)(`span`,{children:[(0,E.jsx)(`i`,{className:`legendLine observed`}),` direct: `,d.length]}),(0,E.jsxs)(`span`,{children:[(0,E.jsx)(`i`,{className:`legendLine relay`}),` relay: `,f.length]}),(0,E.jsxs)(`span`,{children:[(0,E.jsx)(`i`,{className:`legendLine route`}),` route-health: `,p.length]}),(0,E.jsxs)(`span`,{children:[(0,E.jsx)(`i`,{className:`legendLine observed`}),` bidirectional pairs: `,v]}),(0,E.jsxs)(`span`,{children:[(0,E.jsx)(`i`,{className:`legendLine stale`}),` stale/problem: `,h.length,`/`,g.length]}),(0,E.jsxs)(`span`,{children:[(0,E.jsx)(`i`,{className:`legendDot webReady`}),` web ready`]}),(0,E.jsxs)(`span`,{children:[(0,E.jsx)(`i`,{className:`legendDot webDegraded`}),` web degraded`]}),(0,E.jsxs)(`span`,{children:[(0,E.jsx)(`i`,{className:`legendDot webBlocked`}),` web blocked`]}),(0,E.jsxs)(`span`,{children:[`active/passive/mixed: `,b,`/`,x,`/`,S]})]}),(0,E.jsxs)(`details`,{className:`sectionBlock fabricNodeDiagnostics`,children:[(0,E.jsxs)(`summary`,{children:[`Диагностика узлов (`,e.length,`)`]}),(0,E.jsx)(`div`,{className:`serviceTags`,children:e.map(e=>(0,E.jsxs)(`div`,{className:`serviceTag`,children:[(0,E.jsx)(`strong`,{children:e.name}),(0,E.jsxs)(`span`,{children:[H(e.health_status),` / `,H(y.get(e.id)?.mode||`unknown`),` / mesh `,H(ee.get(e.id)||`isolated`)]}),(0,E.jsx)(`small`,{children:Re(n[e.id]?.[0])}),(0,E.jsx)(`small`,{children:tt(r[e.id]||[])}),(0,E.jsx)(`small`,{children:it(i[e.id]||[])})]},e.id))})]})]})}function j(e,t,n){let r=[],i=new Set,a=e=>{let t=`${e.link.source_node_id}->${e.link.target_node_id}`;i.has(t)||(i.add(t),r.push(e))};return e.forEach(a),t.forEach(a),n.forEach(a),r}function De(e,t){let n=t.filter(t=>t.link.source_node_id!==t.link.target_node_id&&(t.link.source_node_id===e||t.link.target_node_id===e));return n.some(e=>e.status===`reachable`||e.status===`one_way`)?`connected`:n.some(e=>e.status!==`stale`)?`degraded`:`isolated`}function Oe(e,t){return`${e===`active`?`A`:e===`passive`?`P`:e===`mixed`?`M`:`?`} / ${t===`connected`?`mesh`:t===`degraded`?`degr`:`iso`}`}function ke(e){let[t,...n]=e.split(` +`).filter(Boolean);return{title:t||`Fabric`,lines:n}}function Ae(e,t,n,r){return{x:Math.min(Math.max(18,e+16),Math.max(18,n-360-18)),y:Math.min(Math.max(18,t+12),Math.max(18,r-190-18))}}function je(e,t,n,r,i,a,o){return[e.name,`health: ${H(e.health_status)}`,`mode: ${H(t.mode)} (${t.detail})`,`mesh: ${H(n)}`,`web ingress: ${H(r)} - ${Re(i)}`,`roles: ${tt(a)}`,`services: ${it(o)}`].join(` +`)}function Me(e,t,n){let r=L(t,e.source_node_id),i=L(t,e.target_node_id),a=V(e.observed_at),o=P(e,`observation_type`)||`link`,s=P(e,`transport_mode`)||`direct`,c=St(e,t),l=e.latency_ms==null?`н/д`:`${e.latency_ms}мс`;return[`${r} -> ${i}`,`status: ${Ct(e,t,n)}`,`type: ${o}`,`mode: ${s}`,`latency: ${l}`,`path: ${c}`,`observed: ${a}`].join(` +`)}function Ne(e,t,n=`direct`){return t===`one_way`?`1w`:n===`relay`?`relay`:n===`route`?`route`:e.latency_ms!=null&&e.latency_ms>0?`${e.latency_ms}мс`:``}function Pe(e,t,n){if(He(e,n))return`stale`;if(e.link_status!==`reachable`)return e.link_status===`degraded`||e.link_status===`unreachable`?e.link_status:`unknown`;let r=t.find(t=>t.source_node_id===e.target_node_id&&t.target_node_id===e.source_node_id&&!He(t,n));return!r||r.link_status!==`reachable`?`one_way`:`reachable`}function Fe(e){if(e.link_status!==`reachable`||P(e,`observation_type`)!==`peer_connection_manager`)return!1;let t=P(e,`transport_mode`);return t===`relay_control`||P(e,`relay_node_id`)?!1:e.metadata?.direct_candidate===!0||t===`direct_quic`||t===`private_lan`}function Ie(e){return P(e,`observation_type`)===`peer_connection_manager`?P(e,`transport_mode`)===`relay_control`||!!P(e,`relay_node_id`):!1}function Le(e){let t=new Set(e.map(e=>`${e.source_node_id}->${e.target_node_id}`)),n=new Set;for(let r of e){if(!t.has(`${r.target_node_id}->${r.source_node_id}`))continue;let e=[r.source_node_id,r.target_node_id].sort().join(`<->`);n.add(e)}return n.size}function M(e){if(!e)return{mode:`unknown`,detail:`no heartbeat`};let t=e.metadata||{},n=F(t.mesh_endpoint_report),r=F(t.mesh_listener_report),i=F(t.mesh_peer_connection_manager_report),a=Ot(i,`peer_connection_ready`),o=Ot(i,`peer_connection_relay_ready`),s=Ot(i,`peer_connection_waiting_rendezvous`),c=I(r,`status`,``),l=I(n,`connectivity_mode`,``),u=Et(n,[`peer_endpoint`,`advertised_endpoint`,`endpoint`]),d=c===`listening`||c===`auto_rebound`,f=l===`outbound_only`||s>0||o>a,p=[d?`listen`:`no-listen`,a?`direct${a}`:``,o?`relay${o}`:``,u?u.replace(/^quic:\/\//,``):``].filter(Boolean);return f&&a>0?{mode:`mixed`,detail:p.join(` `)||`mixed`}:f?{mode:`passive`,detail:p.join(` `)||`outbound/relay`}:d||a>0?{mode:`active`,detail:p.join(` `)||`direct`}:{mode:`unknown`,detail:p.join(` `)||`no runtime`}}function Re(e){let t=F(e?.metadata?.web_ingress_runtime_receiver_report);if(!t)return`web ingress: no report`;let n=t.enabled===!0||t.handler_installed===!0,r=Ot(t,`trusted_key_count`),i=Yt(t.service_classes),a=I(t,`status`,``),o=t.quic_fabric_ready===!0||a===`ready`,s=I(t,`reason`,n?`ready`:`blocked`),c=I(t,`quic_fabric_error`,``),l=i.length>0?i.join(`,`):`no classes`;return n?`web ingress: ${o?`ready`:a||s||`handler`} / keys ${r} / ${c||l}`:`web ingress: ${s}`}function ze(e){let t=F(e?.metadata?.web_ingress_runtime_receiver_report);if(!t)return`missing`;let n=I(t,`status`,``);return n===`ready`||n===`degraded`||n===`blocked`?n:t.handler_installed===!0?`degraded`:`blocked`}function Be(e,t){let n={ready:0,degraded:0,blocked:0,missing:0};for(let r of e){let e=ze(t[r.id]?.[0]);e===`ready`?n.ready+=1:e===`blocked`?n.blocked+=1:e===`degraded`?n.degraded+=1:n.missing+=1}return{...n,label:`${n.ready}/${e.length} ready, ${n.degraded} degraded, ${n.blocked} blocked`}}function Ve(e){let t=new Set;for(let n of nt(e))switch(n.role){case`global-admin-runtime`:t.add(`platform_admin`);break;case`cluster-admin-runtime`:t.add(`cluster_admin`);break;case`organization-portal-runtime`:t.add(`organization_portal`);break;case`user-portal-runtime`:t.add(`user_portal`);break}return[...t]}function He(e,t){if(e.link_status===`stale`||e.metadata?.derived_link_stale===!0)return!0;let n=new Date(e.observed_at).getTime();if(!Number.isFinite(n)||Date.now()-n>900*1e3)return!0;if(!t)return!1;let r=t.get(e.source_node_id),i=t.get(e.target_node_id);return r?.health_status!==`healthy`||i?.health_status!==`healthy`}function Ue(e){let t=qe(e),n=Math.max(Math.ceil(e/t),1);return{width:1280,height:Math.max(720,220+n*148)}}function We(e){return e>48?22:e>24?26:e>12?32:e>6?40:46}function Ge(e){return e>48?{name:11,meta:8,nameOffset:5,metaOffset:10,memoryOffset:0,maxChars:9}:e>24?{name:13,meta:9,nameOffset:6,metaOffset:12,memoryOffset:0,maxChars:11}:e>12?{name:15,meta:10,nameOffset:7,metaOffset:14,memoryOffset:0,maxChars:13}:e>6?{name:18,meta:12,nameOffset:8,metaOffset:15,memoryOffset:0,maxChars:15}:{name:20,meta:13,nameOffset:9,metaOffset:16,memoryOffset:0,maxChars:18}}function Ke(e,t,n){let r=qe(e.length),i=Math.max(1,Math.ceil(e.length/r)),a=n+98,o=1280-n-98,s=r===1?0:(o-a)/(r-1),c=n+88,l=t-n-88,u=i===1?0:(l-c)/(i-1);return new Map(e.map((e,t)=>{let n=t%r,o=Math.floor(t/r);return[e.id,{x:Math.round(r===1?560:a+s*n),y:Math.round(i===1?(c+l)/2:c+u*o)}]}))}function qe(e){return e>48?8:e>24?6:e>12?5:e>6?4:e>3?3:Math.max(1,e)}function Je(e,t,n){let r=t.x-e.x,i=t.y-e.y,a=Math.max(Math.sqrt(r*r+i*i),1),o=r/a*n,s=i/a*n;return{x1:e.x+o,y1:e.y+s,x2:t.x-o,y2:t.y-s}}function Ye(e,t,n,r,i){let a=Je(e,t,n);if(r===0)return a;let o=t.x-e.x,s=t.y-e.y,c=Math.max(Math.sqrt(o*o+s*s),1),l=-s/c*r*i,u=o/c*r*i;return{x1:a.x1+l,y1:a.y1+u,x2:a.x2+l,y2:a.y2+u}}function Xe(e,t,n){let r=`${e}:${t}:${n}`,i=0;for(let e=0;e=.94)continue;let o=u.x1+i*d,s=u.y1+i*f,c=Math.sqrt((t.x-o)**2+(t.y-s)**2);c0?Math.max(72,a+34-_+g*28):0,b=(s+v+y)*c;if(Math.abs(b)<1)return{d:`M ${u.x1} ${u.y1} L ${u.x2} ${u.y2}`,labelX:(u.x1+u.x2)/2,labelY:(u.y1+u.y2)/2};let x=(u.x1+u.x2)/2,S=(u.y1+u.y2)/2,C=x+m*b,w=S+h*b;return{d:`M ${u.x1} ${u.y1} Q ${C} ${w} ${u.x2} ${u.y2}`,labelX:(u.x1+2*C+u.x2)/4,labelY:(u.y1+2*w+u.y2)/4}}function Qe({children:e}){return(0,E.jsx)(`div`,{className:`formGrid`,children:e})}function N({columns:e,rows:t}){return t.length===0?(0,E.jsx)(ye,{title:`Нет данных`,text:`В текущей области пока нечего показать.`}):(0,E.jsx)(`div`,{className:`tableWrap`,children:(0,E.jsxs)(`table`,{children:[(0,E.jsx)(`thead`,{children:(0,E.jsx)(`tr`,{children:e.map(e=>(0,E.jsx)(`th`,{children:e},e))})}),(0,E.jsx)(`tbody`,{children:t.map((e,t)=>(0,E.jsx)(`tr`,{children:e.map((e,n)=>(0,E.jsx)(`td`,{children:e},`${t}-${n}`))},t))})]})})}function $e(e,t){let n=JSON.parse(e||`{}`);if(!n||Array.isArray(n)||typeof n!=`object`)throw Error(`${t}: требуется JSON object.`);return n}function et(e,t){let n=JSON.parse(e||`[]`);if(!Array.isArray(n))throw Error(`${t}: требуется JSON array.`);return n}function tt(e){let t=nt(e);return t.length===0?`активные роли не назначены`:t.map(e=>`${rt(e.role)}${e.organization_id?` @ ${B(e.organization_id)}`:``}`).join(`, `)}function nt(e){return e.filter(e=>e.status===`active`)}function rt(e){let t=oe[e];return t?`${t} (${e})`:e}function it(e){return e.length===0?`нет сервисов`:e.map(e=>`${e.service_type}:${e.reported_state}`).join(`, `)}function at(e,t,n){let r=n.find(e=>e.product===`rap-node-agent`&&e.channel===`stable`&&e.status===`active`)||n.find(e=>e.product===`rap-node-agent`&&e.status===`active`),i=e.reported_version||``,a=t?.target_version||r?.version||``;return e.version_state&&e.version_state!==`unknown`?{status:e.version_state,targetLabel:a?`target ${a}`:`policy target unknown`}:i?t?.action===`update`?{status:`outdated`,targetLabel:`target ${t.target_version||a}`}:a&&i!==a?{status:`outdated`,targetLabel:`latest ${a}`}:a&&i===a?{status:`current`,targetLabel:`latest ${a}`}:{status:t?.reason===`no_update_policy`?`no_policy`:`unknown`,targetLabel:t?.reason||`release policy unknown`}:{status:`unknown`,targetLabel:a?`target ${a}`:`target unknown`}}function ot(e,t){return e.find(e=>e.product===t)}function st(e,t){return e?`${e.product}: ${e.phase}/${e.status}`:t?`${t.action}: ${t.reason}`:`нет отчета`}function ct(e){if(!e)return`нет отчета`;let t=e.target_version?` -> ${e.target_version}`:``,n=e.error_message?`, ошибка: ${e.error_message}`:``;return`${e.current_version||`н/д`}${t}, ${e.phase}/${e.status}, ${V(e.observed_at)}${n}`}function lt(e){return e?`push ${e.pushed||0} / pop ${e.popped||0} / q ${e.queue_depth||0} / drop ${e.dropped||0}`:`нет данных`}function ut(e){if(!e)return`warn`;let t=`${e.phase}:${e.status}`.toLowerCase();return t.includes(`error`)||t.includes(`failed`)||t.includes(`rollback`)?`bad`:t.includes(`success`)||t.includes(`updated`)||t.includes(`noop`)||t.includes(`already_current`)?`good`:t.includes(`download`)||t.includes(`replace`)||t.includes(`plan`)||t.includes(`apply`)?`warn`:``}function dt(e){let t=e.toLowerCase();return t===`healthy`?`good`:t===`fenced`?`bad`:t===`degraded`||t===`operator_retry_cooldown`?`warn`:``}function ft(e){let t=e.toLowerCase();return t===`healthy`?`good`:t===`recovered`||t===`cooldown`||t===`degraded`?`warn`:t===`fenced`||t===`demoted`?`bad`:``}function pt(e){if(e.status===`disabled`||e.lifecycle_status===`disabled`)return`disabled`;if(e.is_expired||e.lifecycle_status===`expired`)return`expired`;let t=Date.parse(e.policy_expires_at||``);return Number.isFinite(t)&&t<=Date.now()?`expired`:e.lifecycle_status||e.status||`active`}function mt(e){let t=pt(e);return t===`active`?`good`:t===`expired`?`warn`:t===`disabled`?``:`warn`}function ht(e){let t=typeof e.node_id==`string`?e.node_id:``;if(t)return B(t);let n=Array.isArray(e.node_ids)?e.node_ids.filter(e=>typeof e==`string`):[];return n.length>0?n.map(B).join(`, `):`selector`}function gt(e){let t=ot(e,`rap-node-agent`),n=ot(e,`rap-host-agent`);if(!t&&!n)return{label:`updater: нет отчета`,detail:`repair/update task не отчитался`,tone:`bad`};let r=[t,n].some(e=>e&&_t(e)),i=!n,a=n?.phase===`apply`&&n?.status===`staged`,o=[t,n].some(e=>e&&ut(e)===`bad`),s=t?`${t.current_version||`?`}->${t.target_version||`?`}`:`node ?`,c=n?`${n.current_version||`?`}->${n.target_version||`?`}`:`host ?`,l=V((n||t)?.observed_at);return o?{label:`updater: ошибка`,detail:`${s}; ${c}; ${l}`,tone:`bad`}:i?{label:`repair updater`,detail:`host-agent не отчитался; ${s}; ${l}`,tone:`warn`}:a?{label:`host-agent staged`,detail:`${c}; нужен следующий запуск updater`,tone:`warn`}:r?{label:`updater: stale`,detail:`${s}; ${c}; ${l}`,tone:`warn`}:{label:`updater: ok`,detail:`${s}; ${c}; ${l}`,tone:`good`}}function _t(e){let t=new Date(e.observed_at).getTime();return!Number.isFinite(t)||Date.now()-t>900*1e3}function vt(e){let t=typeof e.scope?.node_name==`string`?e.scope.node_name:``,n=typeof e.scope?.purpose==`string`?e.scope.purpose:``;return t||n||B(e.id)}function yt(e){let t=new Map;for(let n of e){let e=`${n.source_node_id}->${n.target_node_id}:${bt(n)}`,r=t.get(e);(!r||new Date(n.observed_at).getTime()>new Date(r.observed_at).getTime())&&t.set(e,n)}return[...t.values()]}function bt(e){let t=P(e,`observation_type`)||`default`;return t===`synthetic_route_health`?`${t}:${P(e,`route_id`)||e.id}`:t===`peer_connection_manager`?`${t}:${P(e,`transport_mode`)}:${P(e,`relay_node_id`)}`:t}function xt(e){let t=P(e,`observation_type`);if(t===`synthetic_route_health`){let t=e.metadata?.route_path_drift_detected===!0?`drift`:`ok`;return`route-health ${e.metadata?.route_path_decision_applied===!0?`decision`:`route`} ${t}`}if(t===`peer_connection_manager`){let t=P(e,`transport_mode`)||`manager`,n=P(e,`connection_state`);return n?`${t} ${n}`:t}return t||`link`}function St(e,t){let n=P(e,`route_id`),r=P(e,`route_path_decision_selected_relay_id`)||P(e,`relay_node_id`),i=wt(e,`expected_effective_hops`),a=wt(e,`observed_ack_path`),o=i.length>0?i:a,s=[];return n&&s.push(B(n)),r&&s.push(`via ${B(r)}`),o.length>0&&s.push(o.map(e=>qn(L(t,e))).join(` > `)),s.length>0?s.join(` / `):`н/д`}function Ct(e,t,n=e.link_status===`reachable`?`reachable`:`unknown`){if(n===`stale`)return`stale`;if(n===`one_way`)return`one-way`;let r=P(e,`observation_type`);if(r===`synthetic_route_health`){let n=P(e,`route_path_decision_selected_relay_id`);return n?`relay ${qn(L(t,n),10)}`:e.metadata?.route_path_drift_detected===!0?`drift`:`route`}if(r===`peer_connection_manager`){let n=P(e,`transport_mode`),r=P(e,`relay_node_id`);if(n===`relay_control`||r)return r?`relay ${qn(L(t,r),10)}`:`relay`;if(n===`direct_quic`||n===`private_lan`||P(e,`direct_candidate`)===`true`)return e.latency_ms==null?`direct`:`${e.latency_ms}мс`;if(n)return H(n)}return e.latency_ms==null?`связь`:`${e.latency_ms}мс`}function P(e,t){let n=e.metadata?.[t];return typeof n==`string`?n:``}function wt(e,t){let n=e.metadata?.[t];return Array.isArray(n)?n.filter(e=>typeof e==`string`):[]}function F(e){return e&&typeof e==`object`&&!Array.isArray(e)?e:void 0}function Tt(e){return Array.isArray(e)?e.map(e=>F(e)).filter(e=>!!e):[]}function I(e,t,n=``){let r=e?.[t];return typeof r==`string`?r:typeof r==`number`||typeof r==`boolean`?String(r):n}function Et(e,t){for(let n of t){let t=I(e,n,``);if(t)return t}return``}function Dt(e,t,n){let r=t[0],i=r?.metadata||{},a=F(i.mesh_listener_report),o=F(i.mesh_endpoint_report),s=F(i.mesh_outbound_session_report),c=F(i.mesh_peer_connection_manager_report),l=F(i.mesh_peer_recovery_report),u=Tt(o?.endpoint_candidates)[0],d=Et(o,[`peer_endpoint`,`advertised_endpoint`,`endpoint`])||I(u,`address`,``)||I(a,`effective_listen_addr`,``)||`адрес не прислан`;if(!r&&!e.last_seen_at)return{agentLabel:`agent: no heartbeat`,agentTone:`bad`,clientLabel:`client: unknown`,clientTone:`warn`,outboundLabel:`outbound: no heartbeat`,outboundTone:`bad`,inboundLabel:`inbound: unknown`,inboundTone:`warn`,address:d,detail:`Узел создан/одобрен, но node-agent еще ни разу не прислал heartbeat.`};let f=Ot(c,`peer_connection_ready`)||Ot(l,`peer_connection_ready`)||yt(n).filter(t=>(t.source_node_id===e.id||t.target_node_id===e.id)&&t.link_status===`reachable`).length,p=Ot(c,`peer_connection_total`)||Ot(l,`peer_connection_total`)||yt(n).filter(t=>t.source_node_id===e.id||t.target_node_id===e.id).length,m=Ot(c,`failed`),h=I(a,`status`,``),g=a?.port_conflict===!0,_=a?.one_way_connectivity===!0||I(o,`connectivity_mode`,``)===`outbound_only`||Ot(c,`peer_connection_relay_ready`)>0,v=`inbound: no report`,y=`warn`;h===`listening`||h===`auto_rebound`?(v=h===`auto_rebound`?`inbound: auto port`:`inbound: listening`,y=`good`):h===`listen_failed`?(v=g?`inbound: port busy`:`inbound: failed`,y=`bad`):h===`disabled`?(v=`inbound: disabled`,y=_?`warn`:`bad`):o&&(v=`inbound: advertised`,y=`good`);let b=`client: no peers`,x=`warn`;f>0?(b=`client: ready ${f}/${Math.max(p,f)}`,x=`good`):(m>0||p>0)&&(b=`client: backoff ${f}/${Math.max(p,m)}`,x=`bad`);let S=I(s,`status`,``),C=s?.usable_for_inbound_control===!0,w=Ot(s,`peer_connection_relay_ready`),T=Ot(s,`rendezvous_lease_count`),ee=`outbound: no report`,te=`warn`;S===`ready`?(ee=C?`outbound: ready reverse`:`outbound: ready`,te=`good`):S===`backoff`||S===`failed`?(ee=`outbound: ${S}`,te=`bad`):(_||w>0||T>0)&&(ee=`outbound: inferred`,te=`warn`);let E=e.health_status===`healthy`?`good`:e.health_status===`unknown`?`warn`:`bad`;return{agentLabel:r?`agent: heartbeat`:`agent: stale`,agentTone:E,clientLabel:_&&f>0?`${b} one-way`:b,clientTone:x,outboundLabel:ee,outboundTone:te,inboundLabel:v,inboundTone:y,address:d,detail:I(a,`failure_error`,I(a,`failure_reason`,``))}}function Ot(e,t,n=0){let r=e?.[t];return typeof r==`number`&&Number.isFinite(r)?r:n}function kt(e){let t=e.products.filter(e=>!e.compatible_artifact_found).map(e=>e.product);if(t.length>0)return`Добавить recovery artifacts для ${t.join(`, `)} и не снимать compatibility overlap.`;let n=e.risks.filter(e=>e.startsWith(`stale_node_legacy_recovery_contract_`)).map(e=>e.replace(`stale_node_legacy_recovery_contract_`,``));if(n.length>0)return e.recovery_bridge_replay_ready?`Узел застрял на старом recovery-контракте (${n.join(`, `)}): bridge replay уже готов, держать recovery bridge / compatibility aliases / overlap и ждать следующий recovery-цикл узла.`:`Узел застрял на старом recovery-контракте (${n.join(`, `)}): держать recovery bridge / compatibility aliases / overlap и не удалять старые recovery-форматы до возврата heartbeat.`;let r=e.risks.filter(e=>e.startsWith(`stale_node_unknown_profile_`)).map(e=>e.replace(`stale_node_unknown_profile_`,``));if(r.length>0)return`Неизвестен update profile (${r.join(`, `)}): нужен heartbeat/update-status или fallback-profile alias.`;let i=e.risks.filter(e=>e.startsWith(`stale_node_unknown_`)&&e.endsWith(`_version`)).map(e=>e.replace(`stale_node_unknown_`,``).replace(`_version`,``));if(i.length>0)return`Нет подтвержденной версии (${i.join(`, `)}): сохранить overlap и проверить last known good release mapping.`;let a=e.risks.filter(e=>e.startsWith(`stale_node_no_`)&&e.endsWith(`_update_status`)).map(e=>e.replace(`stale_node_no_`,``).replace(`_update_status`,``));return a.length>0?`Узел молчит и не прислал update-status (${a.join(`, `)}): держать compatibility и чинить recovery channel.`:e.risks.includes(`stale_heartbeat`)?`Узел stale, но recovery artifacts уже есть: не удалять совместимость, ждать исходящий recovery heartbeat и проверить bootstrap/registry gossip.`:`Риск под контролем: можно наблюдать heartbeat и готовить controlled cleanup после отдельной проверки.`}function At(e){let t=e.products.filter(e=>!e.compatible_artifact_found).map(e=>e.product);if(t.length>0)return`artifact gap: ${t.join(`, `)}`;let n=e.risks.filter(e=>e.startsWith(`stale_node_legacy_recovery_contract_`)).map(e=>e.replace(`stale_node_legacy_recovery_contract_`,``));if(n.length>0)return e.recovery_bridge_replay_ready?`bridge replay ready: ${n.join(`, `)}`:`recovery bridge required: ${n.join(`, `)}`;let r=e.risks.filter(e=>e.startsWith(`stale_node_unknown_profile_`)).map(e=>e.replace(`stale_node_unknown_profile_`,``));if(r.length>0)return`profile unknown: ${r.join(`, `)}`;let i=e.risks.filter(e=>e.startsWith(`stale_node_no_`)&&e.endsWith(`_update_status`)).map(e=>e.replace(`stale_node_no_`,``).replace(`_update_status`,``));if(i.length>0)return`waiting update status: ${i.join(`, `)}`;let a=e.risks.filter(e=>e.startsWith(`stale_node_unknown_`)&&e.endsWith(`_version`)).map(e=>e.replace(`stale_node_unknown_`,``).replace(`_version`,``));return a.length>0?`version unknown: ${a.join(`, `)}`:e.heartbeat_stale?`artifacts ready, waiting recovery heartbeat`:`recovery ready`}function jt(e,t){if(!t||e.target_id===t)return!0;let n=F(e.payload);return[`node_id`,`target_node_id`,`reporter_node_id`,`entry_node_id`,`exit_node_id`,`selected_node_id`].some(e=>I(n,e,``)===t)}function Mt(e){let t=Tt(e.endpoint_candidates);if(t.length>0)return t.map(e=>{let t=I(e,`address`,``);if(!t)return``;let n=[t];for(let t of[`reachability`,`connectivity_mode`,`nat_type`,`region`,`priority`]){let r=I(e,t,``);r&&n.push(`${t.replace(`_mode`,``).replace(`_type`,``)}=${r}`)}let r=F(e.metadata);for(let e of[`provider`,`interface`,`maps_to`]){let t=I(r,e,``);t&&n.push(`${e}=${t}`)}return n.join(` `)}).filter(Boolean).join(` +`);let n=Array.isArray(e.advertise_endpoints)?e.advertise_endpoints:[];return n.length>0?n.map(e=>String(e||``).trim()).filter(Boolean).join(` +`):I(e,`advertise_endpoint`,``)}function Nt(e,t){let n=e.endpointCandidates.split(/\r?\n/).map(e=>e.trim()).filter(e=>e&&!e.startsWith(`#`));n.length===0&&e.advertiseEndpoint.trim()&&n.push(e.advertiseEndpoint.trim());let r=new Set;return n.flatMap((n,i)=>{let a=n.split(/\s+/).filter(Boolean),o=(a.shift()||``).replace(/\/$/,``);if(!o||r.has(o))return[];r.add(o);let s={};for(let e of a){let t=e.indexOf(`=`);t>0?s[e.slice(0,t).trim().toLowerCase()]=e.slice(t+1).trim():[`public`,`private`,`relay`,`outbound_only`,`unknown`].includes(e)?s.reachability=e:[`direct`,`private_lan`,`relay_required`].includes(e)?s.connectivity=e:[`none`,`full_cone`,`restricted`,`port_restricted`,`symmetric`,`blocked`].includes(e)&&(s.nat=e)}let c=s.connectivity||s.connectivity_mode||e.connectivity||`direct`,l=s.reachability||Pt(o,c),u=s.nat||s.nat_type||e.nat||`unknown`,d=Object.fromEntries(Object.entries(s).filter(([e])=>![`reachability`,`connectivity`,`connectivity_mode`,`nat`,`nat_type`,`priority`,`region`,`transport`].includes(e)));return[{endpoint_id:`${t}-operator-${i+1}`,node_id:t,address:o,transport:s.transport||e.advertiseTransport||`direct_quic`,reachability:l,connectivity_mode:c,nat_type:u,region:s.region||e.region||void 0,priority:Number.isFinite(Number(s.priority))?Number(s.priority):i+1,policy_tags:[`operator-configured`,`desired-mesh-listener`],metadata:{source:`web-admin.mesh-listener`,verification_scope:l===`public`?`external-network-required`:`local-or-peer-probe`,...d}}]})}function Pt(e,t){if(t===`relay_required`)return`relay`;if(t===`outbound_only`)return`outbound_only`;let n=e.replace(/^[a-z][a-z0-9+.-]*:\/\//i,``).split(/[/:?#]/)[0]||``;return n===`localhost`||n.startsWith(`127.`)||n.startsWith(`10.`)||n.startsWith(`192.168.`)||/^172\.(1[6-9]|2\d|3[0-1])\./.test(n)?`private`:`public`}function Ft(e){if(e==null)return`н/д`;let t=JSON.stringify(e);return t.length>140?`${t.slice(0,137)}...`:t}function It(e){let t=Tt(e.candidate_results);return t.length===0?`н/д`:t.slice(0,4).map(e=>{let t=I(e,`candidate_id`,`candidate`),n=I(e,`link_status`,`unknown`),r=I(e,`latency_ms`,``);return r&&r!==`0`?`${t}:${n}:${r}мс`:`${t}:${n}`}).join(`, `)}function Lt(e){return Object.values(e.peer_endpoint_candidates||{}).reduce((e,t)=>e+t.length,0)}function Rt(e){let t=e?.rendezvous_relay_policy;if(!t)return`none`;let n=[`stale${t.stale_relay_count}`,`wd${t.withdrawn_lease_count}`,`repl${t.replacement_lease_count}`];t.scoring_mode.includes(`synthetic_route_health_feedback`)&&n.push(`rh feedback`);let r=t.decisions?.find(e=>e.selected_relay_id);return r?.selected_relay_id&&n.push(`via ${B(r.selected_relay_id)}`),n.join(` `)}function zt(e){let t=e?.route_path_decisions;if(!t)return`none`;let n=[`path${t.decision_count}`,`repl${t.replacement_decision_count}`];(t.degraded_decision_count||0)>0&&n.push(`degr${t.degraded_decision_count}`);let r=t.decisions?.find(e=>e.selected_relay_id||e.next_hop_id);return r?.selected_relay_id?n.push(`via ${B(r.selected_relay_id)}`):r?.next_hop_id&&n.push(`next ${B(r.next_hop_id)}`),n.join(` `)}function L(e,t){return e.find(e=>e.id===t)?.name||B(t)}function Bt(e,t){let n=new Map(t.map(e=>[e.id,e])),r=[e.name],i=e.parent_group_id,a=new Set([e.id]);for(;i&&!a.has(i);){a.add(i);let e=n.get(i);if(!e)break;r.unshift(e.name),i=e.parent_group_id}return r.join(` / `)}function Vt(e,t){let n=t.find(t=>t.id===e);return n?Bt(n,t):e}function Ht(e,t){let n=[],r=new Map;for(let e of t){let t=e.parent_group_id||``;r.set(t,[...r.get(t)||[],e])}let i=e=>{for(let t of r.get(e)||[])n.push(t.id),i(t.id)};return i(e),n}function Ut(e,t,n,r,i){let a=[],o=new Map,s=[],c=[];for(let t of e){let e=t.memberships.find(e=>e.cluster.id===n);if(!e){c.push(t);continue}let r=e.node.node_group_id;if(!r){s.push(t);continue}o.set(r,[...o.get(r)||[],t])}let l=new Map;for(let e of t){let t=e.parent_group_id||``;l.set(t,[...l.get(t)||[],e])}for(let e of l.values())e.sort((e,t)=>e.sort_order-t.sort_order||e.name.localeCompare(t.name));let u=new Map,d=e=>{let t=u.get(e.id);if(t!=null)return t;let n=o.get(e.id)?.length||0;for(let t of l.get(e.id)||[])n+=d(t);return u.set(e.id,n),n},f=(e,t)=>{let n=[...o.get(e.id)||[]].sort((e,t)=>e.node.name.localeCompare(t.node.name)),r=l.get(e.id)||[],s=`group-${e.id}`,c=d(e);if(a.push({kind:`group`,key:s,label:e.name,depth:t,count:c,groupId:e.id}),!i.has(s)){for(let r of n)a.push({kind:`node`,key:`node-${e.id}-${r.node.id}`,entry:r,depth:t+1});for(let e of r)f(e,t+1)}return c};for(let e of l.get(``)||[])f(e,0);if(s.length>0){let e=`group-ungrouped`;if(a.push({kind:`group`,key:e,label:r.ungroupedNodes,depth:0,count:s.length}),!i.has(e))for(let e of s.sort((e,t)=>e.node.name.localeCompare(t.node.name)))a.push({kind:`node`,key:`node-ungrouped-${e.node.id}`,entry:e,depth:1})}if(c.length>0){let e=`group-outside-active-cluster`;if(a.push({kind:`group`,key:e,label:r.notMemberOfActiveCluster,depth:0,count:c.length}),!i.has(e))for(let e of c.sort((e,t)=>e.node.name.localeCompare(t.node.name)))a.push({kind:`node`,key:`node-outside-${e.node.id}`,entry:e,depth:1})}return a}function Wt(e,t){return Object.entries(t).filter(([,t])=>t.some(t=>t.role===e&&t.status===`active`)).map(([e])=>e)}function Gt(e){let t=Jt(e),n=t.map(e=>String(e.address||``)).filter(Boolean),r=e.meshAdvertiseEndpoint.trim().replace(/\/$/,``)||n[0]||``,i={roles:e.roles,node_name:e.nodeName.trim()||null,node_group_id:e.nodeGroupId||null,ownership_type:e.ownershipType,purpose:e.purpose.trim()||null,approval:{mode:`manual`,auto_approve:!1,role_assignment:`manual_after_approval`},source:`platform_owner_console`};if(e.installMode===`docker`){let n=(e.controlPlaneEndpoint||Zt()).trim().replace(/\/$/,``);i.install_profile=`docker`,i.backend_url=n,i.control_plane_endpoints=[n],i.image=e.dockerImage||`rap-node-agent:latest`,e.dockerContainerName.trim()&&(i.container_name=e.dockerContainerName.trim()),i.artifact_endpoints=$t(e.artifactEndpoints||Qt()),e.dockerImageArtifactSHA256.trim()&&(i.docker_image_artifact_sha256=e.dockerImageArtifactSHA256.trim()),i.network=e.dockerNetwork||`host`,i.restart_policy=`unless-stopped`,i.pull_image=!!e.pullImage,i.replace=e.replace!==!1,i.mesh_synthetic_runtime_enabled=e.syntheticRuntime===!0,i.mesh_production_forwarding_enabled=!1,i.mesh_listen_addr=e.meshListenAddr||``,i.mesh_listen_port_mode=e.meshListenPortMode||`auto`,i.mesh_listen_auto_port_start=e.meshListenAutoPortStart||19131,i.mesh_listen_auto_port_end=e.meshListenAutoPortEnd||19231,r&&(i.mesh_advertise_endpoint=r),t.length>0&&(i.mesh_advertise_endpoints_json=JSON.stringify(t)),i.mesh_advertise_transport=e.meshAdvertiseTransport||`direct_quic`,i.mesh_connectivity_mode=e.meshConnectivityMode||`private_lan`,i.mesh_nat_type=e.meshNATType||`unknown`,i.mesh_region=e.meshRegion||null}if(e.installMode===`windows_service`){let n=(e.controlPlaneEndpoint||Zt()).trim().replace(/\/$/,``);i.install_profile=`windows_service`,i.backend_url=n,i.control_plane_endpoints=[n],i.artifact_endpoints=$t(e.artifactEndpoints||Qt()),i.startup_mode=e.windowsStartupMode||`auto`,e.windowsInstallDir.trim()&&(i.install_dir=e.windowsInstallDir.trim()),e.windowsNodeAgentSHA256.trim()&&(i.node_agent_artifact_sha256=e.windowsNodeAgentSHA256.trim()),i.mesh_synthetic_runtime_enabled=e.syntheticRuntime===!0,i.mesh_production_forwarding_enabled=!1,i.mesh_listen_addr=e.meshListenAddr||``,i.mesh_listen_port_mode=e.meshListenPortMode||`auto`,i.mesh_listen_auto_port_start=e.meshListenAutoPortStart||19131,i.mesh_listen_auto_port_end=e.meshListenAutoPortEnd||19231,r&&(i.mesh_advertise_endpoint=r),t.length>0&&(i.mesh_advertise_endpoints_json=JSON.stringify(t)),i.mesh_advertise_transport=e.meshAdvertiseTransport||`direct_quic`,i.mesh_connectivity_mode=e.meshConnectivityMode||`outbound_only`,i.mesh_nat_type=e.meshNATType||`unknown`,i.mesh_region=e.meshRegion||`windows`}if(e.installMode===`linux_binary`){let n=(e.controlPlaneEndpoint||Zt()).trim().replace(/\/$/,``);i.install_profile=`linux_binary`,i.backend_url=n,i.control_plane_endpoints=[n],i.artifact_endpoints=$t(e.artifactEndpoints||Qt()),i.startup_mode=`systemd`,e.linuxInstallDir.trim()&&(i.install_dir=e.linuxInstallDir.trim()),e.linuxNodeAgentSHA256.trim()&&(i.node_agent_artifact_sha256=e.linuxNodeAgentSHA256.trim()),i.replace=e.replace!==!1,i.mesh_synthetic_runtime_enabled=e.syntheticRuntime===!0,i.mesh_production_forwarding_enabled=!1,i.mesh_listen_addr=e.meshListenAddr||``,i.mesh_listen_port_mode=e.meshListenPortMode||`auto`,i.mesh_listen_auto_port_start=e.meshListenAutoPortStart||19131,i.mesh_listen_auto_port_end=e.meshListenAutoPortEnd||19231,r&&(i.mesh_advertise_endpoint=r),t.length>0&&(i.mesh_advertise_endpoints_json=JSON.stringify(t)),i.mesh_advertise_transport=e.meshAdvertiseTransport||`direct_quic`,i.mesh_connectivity_mode=e.meshConnectivityMode||`outbound_only`,i.mesh_nat_type=e.meshNATType||`unknown`,i.mesh_region=e.meshRegion||`linux`}return i}function Kt(e,t){let n=Yt(e.roles),r=Yt(e.artifact_endpoints).join(`, `);return{...t,roles:n.length>0?n:t.roles,nodeName:I(e,`node_name`,``)||t.nodeName,nodeGroupId:I(e,`node_group_id`,``)||t.nodeGroupId,ownershipType:I(e,`ownership_type`,t.ownershipType),purpose:I(e,`purpose`,``)||t.purpose,installMode:I(e,`install_profile`,t.installMode),dockerImage:I(e,`image`,t.dockerImage),dockerContainerName:I(e,`container_name`,``)||t.dockerContainerName,dockerNetwork:I(e,`network`,t.dockerNetwork),windowsStartupMode:I(e,`startup_mode`,t.windowsStartupMode),windowsInstallDir:I(e,`install_dir`,``)||t.windowsInstallDir,windowsNodeAgentSHA256:I(e,`node_agent_artifact_sha256`,``)||t.windowsNodeAgentSHA256,linuxInstallDir:I(e,`install_dir`,``)||t.linuxInstallDir,linuxNodeAgentSHA256:I(e,`node_agent_artifact_sha256`,``)||t.linuxNodeAgentSHA256,meshListenAddr:I(e,`mesh_listen_addr`,t.meshListenAddr),meshListenPortMode:I(e,`mesh_listen_port_mode`,t.meshListenPortMode),meshListenAutoPortStart:Ot(e,`mesh_listen_auto_port_start`,t.meshListenAutoPortStart),meshListenAutoPortEnd:Ot(e,`mesh_listen_auto_port_end`,t.meshListenAutoPortEnd),meshAdvertiseEndpoint:I(e,`mesh_advertise_endpoint`,``)||t.meshAdvertiseEndpoint,meshAdvertiseEndpoints:qt(e)||t.meshAdvertiseEndpoints,meshAdvertiseTransport:I(e,`mesh_advertise_transport`,t.meshAdvertiseTransport),meshConnectivityMode:I(e,`mesh_connectivity_mode`,t.meshConnectivityMode),meshNATType:I(e,`mesh_nat_type`,t.meshNATType),meshRegion:I(e,`mesh_region`,``)||t.meshRegion,controlPlaneEndpoint:Yt(e.control_plane_endpoints)[0]||I(e,`backend_url`,``)||t.controlPlaneEndpoint,artifactEndpoints:r||t.artifactEndpoints,dockerImageArtifactSHA256:I(e,`docker_image_artifact_sha256`,``)||t.dockerImageArtifactSHA256,pullImage:Xt(e,`pull_image`,t.pullImage),replace:Xt(e,`replace`,t.replace),syntheticRuntime:Xt(e,`mesh_synthetic_runtime_enabled`,t.syntheticRuntime)}}function qt(e){let t=I(e,`mesh_advertise_endpoints_json`,``);if(!t)return``;try{let e=JSON.parse(t);return Array.isArray(e)?e.map(e=>{let t=F(e);if(!t)return``;let n=I(t,`address`,``);if(!n)return``;let r=[n];for(let e of[`reachability`,`connectivity_mode`,`nat_type`,`region`,`priority`]){let n=I(t,e,``);n&&r.push(`${e.replace(`_mode`,``).replace(`_type`,``)}=${n}`)}let i=F(t.metadata);for(let e of[`provider`,`interface`,`maps_to`]){let t=I(i,e,``);t&&r.push(`${e}=${t}`)}return r.join(` `)}).filter(Boolean).join(` +`):``}catch{return``}}function Jt(e){return Nt({endpointCandidates:e.meshAdvertiseEndpoints,advertiseEndpoint:e.meshAdvertiseEndpoint,advertiseTransport:e.meshAdvertiseTransport||`direct_quic`,connectivity:e.meshConnectivityMode,nat:e.meshNATType,region:e.meshRegion},e.nodeName.trim()||`install-node`)}function Yt(e){return Array.isArray(e)?e.filter(e=>typeof e==`string`).map(e=>e.trim()).filter(Boolean):[]}function Xt(e,t,n){let r=e[t];return typeof r==`boolean`?r:n}function Zt(){return typeof window>`u`||!window.location?.origin?`http://:18080/api/v1`:`${window.location.origin.replace(/\/$/,``)}/api/v1`}function Qt(){return typeof window>`u`||!window.location?.origin?`http://:18080/downloads`:`${window.location.origin.replace(/\/$/,``)}/downloads`}function $t(e){return e.split(`,`).map(e=>e.trim().replace(/\/$/,``)).filter(Boolean)}function en(e){return $t(e.artifactEndpoints||Qt()).map(e=>`${e}/rap-node-agent-dev-enrollment-bootstrap-smoke.tar`)}function tn(e){return e.meshConnectivityMode===`outbound_only`?`outbound_only`:e.meshConnectivityMode===`private_lan`?`private_lan`:e.meshNATType!==`none`&&e.meshAdvertiseEndpoint.trim()?`nat_forward`:`direct`}function nn(e,t){let n={...e};return t===`private_lan`?(n.meshConnectivityMode=`private_lan`,n.meshNATType=`none`):t===`direct`?(n.meshConnectivityMode=`direct`,n.meshNATType=`none`):t===`nat_forward`?(n.meshConnectivityMode=`direct`,n.meshNATType=`port_restricted`):(n.meshConnectivityMode=`outbound_only`,n.meshNATType=`symmetric`,n.meshAdvertiseEndpoint=``),n}function rn(e,t){return e.nodeName.trim()?e.nodeName.trim():`${Tn(t?.slug||t?.name||`rap-node`)}-node-1`}function an(e,t){return e.dockerContainerName.trim()?e.dockerContainerName.trim():`rap-node-agent-${Tn(rn(e,t))}`}function on(e,t,n=ue){let r=t?.id||e.cluster_id,i=rn(n,t),a=an(n,t),o=Tn(i),s=[`rap-host-agent install`,`--backend-url ${z(xn(n))}`,`--cluster-id ${z(r)}`,`--join-token ${z(e.token)}`,`--node-name ${z(i)}`,`--image ${z(n.dockerImage||`rap-node-agent:latest`)}`,`--container-name ${z(a)}`,`--state-dir ${z(`/var/lib/rap/nodes/${o}`)}`,`--network host`,`--replace`];for(let e of en(n))s.push(`--image-artifact-url ${z(e)}`);return n.dockerImageArtifactSHA256.trim()&&s.push(`--image-artifact-sha256 ${z(n.dockerImageArtifactSHA256.trim())}`),s.join(` \\ + `)}function sn(e,t,n=ue){let r=t?.id||e.cluster_id,i=rn(n,t),a=[`sudo "$rap_host_agent" install`,`--profile-url ${z(xn(n))}`,`--cluster-id ${z(r)}`,`--install-token ${z(e.token)}`,`--node-name ${z(i)}`].join(` \\ + `);return[`rap_host_agent="$(mktemp /tmp/rap-host-agent.XXXXXX)"`,`curl -fL --retry 3 --retry-delay 1 ${z(Cn(n))} -o "$rap_host_agent"`,`chmod +x "$rap_host_agent"`,a].join(` && \\ + `)}function cn(e,t,n=ue){let r=t?.id||e.cluster_id,i=rn(n,t),a=[`sudo "$rap_host_agent" install-linux`,`--profile-url ${z(xn(n))}`,`--cluster-id ${z(r)}`,`--install-token ${z(e.token)}`,`--node-name ${z(i)}`].join(` \\ + `);return[`rap_host_agent="$(mktemp /tmp/rap-host-agent.XXXXXX)"`,`curl -fL --retry 3 --retry-delay 1 ${z(Cn(n))} -o "$rap_host_agent"`,`chmod +x "$rap_host_agent"`,a].join(` && \\ + `)}function ln(e,t,n=ue){let r=t?.id||e.cluster_id,i=rn(n,t),a=xn(n);return[`$rapHostAgent = Join-Path $env:TEMP "rap-host-agent.exe"`,`Invoke-WebRequest -UseBasicParsing ${En(wn(n))} -OutFile $rapHostAgent`,`& $rapHostAgent install-windows --profile-url ${En(a)} --cluster-id ${En(r)} --install-token ${En(e.token)} --node-name ${En(i)} --startup-mode ${En(n.windowsStartupMode||`auto`)}`].join(`\r +`)}function un(e,t,n=ue){let r=t?.id||e.cluster_id,i=rn(n,t),a=xn(n),o=wn(n),s=n.windowsStartupMode||`auto`;return[`powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -UseBasicParsing '${o}' -OutFile $env:TEMP\\rap-host-agent.exe"`,`%TEMP%\\rap-host-agent.exe install-windows --profile-url "${a}" --cluster-id "${r}" --install-token "${e.token}" --node-name "${i}" --startup-mode "${s}"`].join(`\r +`)}function dn(e,t){let n=R(),r=n.replace(/\/api\/v1$/i,``).replace(/\/api$/i,``).replace(/\/$/,``),i=e.name||e.node_key||e.id,a=vn(i),o=`%ProgramFiles%\\RAP\\${a}`,s=`%ProgramData%\\RAP\\nodes\\${a}`,c=`RAP Node Agent ${a}`,l=`RAP Host Agent Updater ${a}`,u=`${o}\\rap-host-agent.exe`,d=`${u}.next`;return[`@echo off`,`echo === RAP Windows updater repair: ${Dn(i)} ===`,`echo Node ID: ${e.id}`,`echo Control API endpoint: ${n}`,`echo.`,`echo === Before repair: scheduled tasks ===`,`schtasks /Query /TN "${c}" /V /FO LIST`,`schtasks /Query /TN "${l}" /V /FO LIST`,`echo.`,`echo === Before repair: binaries ===`,`dir "${o}\\rap-*.exe*"`,`echo.`,`powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -UseBasicParsing '${r}/downloads/rap-host-agent-windows-amd64.exe' -OutFile $env:TEMP\\rap-host-agent.exe"`,`%TEMP%\\rap-host-agent.exe install-windows --backend-url "${n}" --cluster-id "${t||``}" --node-id "${e.id}" --node-name "${Dn(i)}" --replace --startup-mode "auto" --auto-update-current-version "0.0.0" --auto-update-initial-delay-seconds 1`,`"${u}" update-loop --backend-url "${n}" --cluster-id "${t||``}" --node-id "${e.id}" --state-dir "${s}" --current-version "0.0.0" --os windows --arch amd64 --install-type windows_service --binary-path "${o}\\rap-node-agent.exe" --windows-task-name "${c}" --health-timeout-seconds 30 --interval-seconds 0 --initial-delay-seconds 0 --max-runs 1 --host-agent-update-status-enabled --host-agent-current-version "0.0.0" --host-agent-binary-path "${u}"`,`echo.`,`echo === Applying staged host-agent if present ===`,`if exist "${d}" copy /Y "${d}" "${u}"`,`if exist "${d}" del /F /Q "${d}"`,`schtasks /End /TN "${l}"`,`schtasks /Run /TN "${l}"`,`echo.`,`echo === After repair: binaries ===`,`dir "${o}\\rap-*.exe*"`,`echo.`,`echo === After repair: updater task ===`,`schtasks /Query /TN "${l}" /V /FO LIST`,`echo.`,`echo Repair command finished. Check the admin panel for rap-node-agent and rap-host-agent plan/noop reports.`].join(`\r +`)}function fn(e){return`rap-repair-updater-${_n(e.name||e.node_key||e.id||`node`)}.cmd`}function pn(e,t){let n=R(),r=n.replace(/\/api\/v1$/i,``).replace(/\/api$/i,``).replace(/\/$/,``),i=e.name||e.node_key||e.id,a=yn(i),o=`/opt/rap/${a}`,s=`/var/lib/rap/nodes/${a}`,c=`rap-node-agent-${a}.service`,l=`rap-host-agent-updater-${a}.service`,u=`${o}/rap-host-agent`;return[`#!/usr/bin/env bash`,`set -euo pipefail`,`echo "=== RAP Linux updater repair: ${bn(i)} ==="`,`echo "Node ID: ${e.id}"`,`echo "Control API endpoint: ${n}"`,`echo`,`echo "=== Before repair: systemd units ==="`,`systemctl status ${z(c)} --no-pager || true`,`systemctl status ${z(l)} --no-pager || true`,`echo`,`echo "=== Before repair: binaries ==="`,`ls -la ${z(o)} || true`,`echo`,`rap_host_agent="$(mktemp /tmp/rap-host-agent.XXXXXX)"`,`curl -fL --retry 3 --retry-delay 1 ${z(`${r}/downloads/rap-host-agent-linux-amd64`)} -o "$rap_host_agent"`,`chmod +x "$rap_host_agent"`,`sudo "$rap_host_agent" install-linux --backend-url ${z(n)} --cluster-id ${z(t||``)} --node-id ${z(e.id)} --node-name ${z(i)} --replace --startup-mode systemd --auto-update-current-version 0.0.0 --auto-update-initial-delay-seconds 1`,`sudo ${z(u)} update-loop --backend-url ${z(n)} --cluster-id ${z(t||``)} --node-id ${z(e.id)} --state-dir ${z(s)} --current-version 0.0.0 --os linux --arch amd64 --install-type linux_binary --binary-path ${z(`${o}/rap-node-agent`)} --systemd-unit ${z(c)} --health-timeout-seconds 30 --interval-seconds 0 --initial-delay-seconds 0 --max-runs 1 --host-agent-update-status-enabled --host-agent-current-version 0.0.0 --host-agent-binary-path ${z(u)}`,`sudo systemctl daemon-reload`,`sudo systemctl restart ${z(l)}`,`echo`,`echo "=== After repair: systemd updater ==="`,`systemctl status ${z(l)} --no-pager || true`,`echo "Repair command finished. Check the admin panel for rap-node-agent and rap-host-agent plan/noop reports."`].join(` +`)}function mn(e){return`rap-repair-updater-${_n(e.name||e.node_key||e.id||`node`)}.sh`}function hn(e,t){if(typeof document>`u`)return;let n=new Blob([t.endsWith(`\r +`)?t:`${t}\r\n`],{type:`text/plain;charset=utf-8`}),r=URL.createObjectURL(n),i=document.createElement(`a`);i.href=r,i.download=e,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(r)}async function gn(e){await navigator.clipboard.writeText(e)}function _n(e){return e.trim().replace(/[\\/:*?"<>|]+/g,`-`).replace(/\s+/g,`-`).replace(/-+/g,`-`).replace(/^-|-$/g,``).slice(0,80)||`node`}function vn(e){return e.trim().replace(/[\\/:*?"<>|]+/g,`-`).replace(/\s+/g,`-`).replace(/-+/g,`-`).replace(/^-|-$/g,``)||`node`}function yn(e){return vn(e).slice(0,48)||`node`}function bn(e){return e.replace(/\\/g,`\\\\`).replace(/"/g,`\\"`).replace(/\$/g,`\\$`).replace(/`/g,"\\`")}function xn(e=ue){return(e.controlPlaneEndpoint||Zt()).trim().replace(/\/$/,``)}function R(){let e=typeof window>`u`?``:window.location?.origin||``;return/^(http:\/\/)?(192\.168\.200\.61|docker-test\.cin\.su)(:18080)?$/i.test(e.replace(/\/$/,``))?`https://vpn.cin.su/api/v1`:`${e.replace(/\/$/,``)}/api/v1`}function Sn(e=ue){let t=$t(e.artifactEndpoints)[0];return t?t.replace(/\/downloads$/i,``).replace(/\/$/,``):xn(e).replace(/\/api\/v1$/i,``).replace(/\/api$/i,``).replace(/\/$/,``)}function Cn(e=ue){return`${typeof window>`u`&&!e.controlPlaneEndpoint?`http://:18080`:Sn(e)}/downloads/rap-host-agent-linux-amd64`}function wn(e=ue){return`${typeof window>`u`&&!e.controlPlaneEndpoint?`http://:18080`:Sn(e)}/downloads/rap-host-agent-windows-amd64.exe`}function Tn(e){return e.trim().toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,42)||`rap-node`}function z(e){return`'${e.replace(/'/g,`'\\''`)}'`}function En(e){return`'${e.replace(/'/g,`''`)}'`}function Dn(e){return e.replace(/"/g,`""`)}function On(e,t){return e.includes(t)?e.filter(e=>e!==t):[...e,t]}function kn(e,t,n,r,i){let a=n.trim().toLowerCase(),o=new Map;for(let n of e){if(a&&!An(n,a))continue;let e=jn(n,t,r,i);o.set(e,[...o.get(e)||[],n])}return Array.from(o.entries()).map(([e,t])=>({label:e,items:t.sort((e,t)=>e.node.name.localeCompare(t.node.name))})).sort((e,t)=>e.label.localeCompare(t.label))}function An(e,t){return[e.node.name,e.node.node_key,e.node.health_status,e.node.ownership_type,e.node.reported_version||``,...e.memberships.flatMap(e=>[e.cluster.name,e.cluster.slug,e.node.membership_status])].some(e=>e.toLowerCase().includes(t))}function jn(e,t,n,r){if(n===`health`)return H(e.node.health_status);if(n===`ownership`)return H(e.node.ownership_type);if(n===`cluster_count`)return Gn(e.memberships.length,r);let i=e.memberships.find(e=>e.cluster.id===t);return i?i.node.membership_status===`active`?r===`en`?`In active cluster`:`В активном кластере`:`${r===`en`?`Membership`:`Участие`}: ${H(i.node.membership_status)}`:r===`en`?`Not in active cluster`:`Не в активном кластере`}function Mn(e,t){let n=se[e]||[];if(n.length===0||!t)return`unknown`;if(Nn(t))return`stale`;let r=t.capabilities||{};return n.some(e=>!!r[e])?`confirmed`:`missing`}function Nn(e){if(!e?.observed_at)return!0;let t=new Date(e.observed_at).getTime();return!Number.isFinite(t)||Date.now()-t>60*1e3}function Pn(e,t){let n=Mn(e,t);return n===`confirmed`?`good`:n===`missing`?`bad`:n===`stale`?`warn`:``}function Fn(e,t,n){let r=Mn(e,t);return r===`confirmed`?n.capabilityConfirmed:r===`missing`?n.capabilityMissing:r===`stale`?`heartbeat устарел`:n.capabilityUnknown}function In(e,t,n){let r=Mn(e,t);return n===`en`?r===`confirmed`?`capable`:r===`missing`?`not reported`:r===`stale`?`stale heartbeat`:`unknown`:r===`confirmed`?`подходит`:r===`missing`?`не заявлено`:r===`stale`?`heartbeat устарел`:`неизвестно`}function Ln(e){let t=e?.metadata?.mesh_peer_recovery_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t,r=typeof n.mode==`string`?n.mode:`unknown`,i=typeof n.ready_peer_count==`number`?n.ready_peer_count:null,a=typeof n.target_ready_peers==`number`?n.target_ready_peers:null,o=typeof n.deficit==`number`?n.deficit:0,s=i==null||a==null?r:`${r} ${i}/${a}`;return o>0?`${s} deficit ${o}`:s}function Rn(e){let t=e?.metadata?.mesh_peer_connection_intent_report;if(!t||typeof t!=`object`||Array.isArray(t))return Un(e);let n=t,r=typeof n.intent_count==`number`?n.intent_count:0,i=typeof n.maintain_count==`number`?n.maintain_count:0,a=typeof n.recover_count==`number`?n.recover_count:0,o=typeof n.rendezvous_required_count==`number`?n.rendezvous_required_count:0,s=typeof n.rendezvous_resolved_count==`number`?n.rendezvous_resolved_count:0,c=typeof n.relay_control_count==`number`?n.relay_control_count:0,l=[`rv${o}`];s>0&&l.push(`ok${s}`),c>0&&l.push(`relay${c}`);let u=o>0||s>0||c>0?`${r} intents m${i}/r${a} ${l.join(`/`)}`:`${r} intents m${i}/r${a}`,d=Un(e);return d===`н/д`?u:`${u}; ${d}`}function zn(e){let t=e?.metadata?.mesh_rendezvous_lease_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t,r=typeof n.lease_count==`number`?n.lease_count:0,i=typeof n.active_count==`number`?n.active_count:0,a=typeof n.admitted_as_relay_count==`number`?n.admitted_as_relay_count:0,o=typeof n.admitted_as_peer_count==`number`?n.admitted_as_peer_count:0,s=typeof n.renewal_needed_count==`number`?n.renewal_needed_count:0,c=typeof n.relay_control_ready_count==`number`?n.relay_control_ready_count:0,l=typeof n.stale_relay_count==`number`?n.stale_relay_count:0,u=typeof n.refresh_attempt_count==`number`?n.refresh_attempt_count:0,d=typeof n.refresh_success_count==`number`?n.refresh_success_count:0,f=[`lease ${i}/${r}`];return a>0&&f.push(`relay${a}`),o>0&&f.push(`peer${o}`),s>0&&f.push(`renew${s}`),l>0&&f.push(`stale${l}`),c>0&&f.push(`ready${c}`),u>0&&f.push(`ref${d}/${u}`),f.join(` `)}function Bn(e){let t=e?.metadata?.mesh_route_path_decision_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t,r=typeof n.decision_count==`number`?n.decision_count:0,i=typeof n.replacement_decision_count==`number`?n.replacement_decision_count:0,a=typeof n.degraded_decision_count==`number`?n.degraded_decision_count:0,o=typeof n.recovery_hysteresis_count==`number`?n.recovery_hysteresis_count:0,s=typeof n.recovery_promoted_count==`number`?n.recovery_promoted_count:0,c=typeof n.recovery_demoted_count==`number`?n.recovery_demoted_count:0,l=typeof n.local_effective_path_count==`number`?n.local_effective_path_count:0,u=typeof n.next_hop_available_count==`number`?n.next_hop_available_count:0,d=typeof n.withdrawn_local_relay_count==`number`?n.withdrawn_local_relay_count:0,f=[`path ${l}/${r}`];return i>0&&f.push(`repl${i}`),a>0&&f.push(`degr${a}`),o>0&&f.push(`rec${o}`),s>0&&f.push(`prom${s}`),c>0&&f.push(`dem${c}`),u>0&&f.push(`next${u}`),d>0&&f.push(`wd${d}`),f.join(` `)}function Vn(e){let t=e?.metadata?.mesh_route_generation_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t,r=typeof n.active_decision_count==`number`?n.active_decision_count:0,i=typeof n.applied_decision_count==`number`?n.applied_decision_count:0,a=typeof n.withdrawn_decision_count==`number`?n.withdrawn_decision_count:0,o=n.generation_changed===!0,s=[`gen ${r}`];return i>0&&s.push(`ap${i}`),a>0&&s.push(`wd${a}`),o&&s.push(`chg`),s.join(` `)}function Hn(e){let t=e?.metadata?.mesh_route_health_config_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t,r=e?.metadata?.mesh_route_health_feedback_refresh_report,i=r&&typeof r==`object`&&!Array.isArray(r)?r:{},a=typeof n.route_health_route_count==`number`?n.route_health_route_count:0,o=typeof n.route_path_decision_applied_count==`number`?n.route_path_decision_applied_count:0,s=typeof n.replacement_route_health_route_count==`number`?n.replacement_route_health_route_count:0,c=typeof n.route_health_decision_drift_candidate_count==`number`?n.route_health_decision_drift_candidate_count:0,l=typeof i.feedback_refresh_attempt_count==`number`?i.feedback_refresh_attempt_count:typeof n.feedback_refresh_attempt_count==`number`?n.feedback_refresh_attempt_count:0,u=typeof i.feedback_refresh_success_count==`number`?i.feedback_refresh_success_count:typeof n.feedback_refresh_success_count==`number`?n.feedback_refresh_success_count:0,d=typeof i.feedback_refresh_suppressed_count==`number`?i.feedback_refresh_suppressed_count:typeof n.feedback_refresh_suppressed_count==`number`?n.feedback_refresh_suppressed_count:0,f=[`rh ${o}/${a}`];return s>0&&f.push(`repl${s}`),c>0&&f.push(`drift${c}`),(l>0||d>0)&&f.push(`fb${u}/${l}`),d>0&&f.push(`sup${d}`),f.join(` `)}function Un(e){let t=e?.metadata?.mesh_peer_connection_manager_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t;if(n.enabled===!1)return`manager off`;let r=typeof n.attempted==`number`?n.attempted:0,i=typeof n.succeeded==`number`?n.succeeded:0,a=typeof n.deferred==`number`?n.deferred:0,o=typeof n.relay_control_count==`number`?n.relay_control_count:0,s=o>0?`mgr ${i}/${r} relay${o}`:`mgr ${i}/${r}`;return a>0?`${s} def${a}`:s}function Wn(e){let t=e?.metadata?.mesh_listener_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t,r=typeof n.status==`string`?n.status:`unknown`,i=typeof n.listen_port_mode==`string`?n.listen_port_mode:`manual`,a=typeof n.effective_listen_addr==`string`?n.effective_listen_addr:``,o=typeof n.failure_reason==`string`?n.failure_reason:``;return r===`listening`?a?`listen ${a}`:`listen`:r===`auto_rebound`?a?`auto ${a}`:`auto rebound`:r===`listen_failed`?o?`${i} failed: ${o}`:`${i} failed`:r===`disabled`?i===`disabled`?`inbound off`:`inbound unavailable`:r}function Gn(e,t){if(t===`en`)return e===1?`1 cluster`:`${e} clusters`;let n=e%10,r=e%100;return n===1&&r!==11?`${e} кластер`:n>=2&&n<=4&&(r<12||r>14)?`${e} кластера`:`${e} кластеров`}function Kn(e,t=e.link_status===`reachable`?`reachable`:`unknown`){return t===`stale`?`stale`:t===`one_way`?`oneWay`:t!==`reachable`||e.link_status!==`reachable`?`bad`:e.quality_score!=null&&e.quality_score<70||e.latency_ms!=null&&e.latency_ms>80?`weak`:`good`}function qn(e,t=16){return e.length>t?`${e.slice(0,Math.max(1,t-2))}…`:e}function Jn(e){return window.confirm(`${e}?\n\nЭто высокорисковая операция владельца платформы. Действие будет записано в аудит.`)}function Yn(e){let t=(e||``).replace(/\/$/,``);return!t||t===`/api/v1`?window.location.origin:t.endsWith(`/api/v1`)?t.slice(0,-7):t}function B(e){return e?e.length>12?`${e.slice(0,8)}...${e.slice(-4)}`:e:`нет`}function V(e){return e?new Intl.DateTimeFormat(void 0,{dateStyle:`medium`,timeStyle:`short`}).format(new Date(e)):`никогда`}function Xn(e){return e==null||Number.isNaN(e)?`age n/a`:e<60?`${Math.max(0,Math.round(e))}s ago`:e<3600?`${Math.round(e/60)}m ago`:e<86400?`${Math.round(e/3600)}h ago`:`${Math.round(e/86400)}d ago`}function Zn(e){if(!e)return`time n/a`;let t=Date.parse(e);return Number.isNaN(t)?`time n/a`:Xn(Math.max(0,Math.round((Date.now()-t)/1e3)))}function Qn(e){return e?new Intl.DateTimeFormat(void 0,{hour:`2-digit`,minute:`2-digit`,second:`2-digit`}).format(new Date(e)):`н/д`}function $n(e){if(e==null||Number.isNaN(e))return`н/д`;let t=[`B`,`KB`,`MB`,`GB`,`TB`],n=e,r=0;for(;n>=1024&&r(e[t]||0)>0).map(t=>`${t[0]}:${e[t]}`).join(` `)||`qos none`}function tr(e){return!e||Object.keys(e).length===0?`n/a`:[`control`,`interactive`,`reliable`,`bulk`,`droppable`].filter(t=>(e[t]||0)>0).map(t=>`${t[0]}:${e[t]}`).join(` `)||`n/a`}function nr(e,t){return(t||0)>0||e===`critical`?`bad`:e===`degraded`?`warn`:e===`watch`?`info`:`good`}function rr(e){switch(e){case`applied`:case`rebuild_request_applied`:return`good`;case`waiting_node_apply`:case`pending_rebuild_request`:case`pending_degraded_fallback`:case`rebuild_request_recorded`:case`rebuild_request_recorded_node_pending`:case`rebuild_request_no_alternate`:case`rebuild_request_deferred_by_policy`:case`route_rebuild_no_safe_recovery`:return`warn`;case`expired`:case`rejected_by_policy_guard`:case`rebuild_request_rejected`:case`rebuild_request_expired`:return`bad`;default:return e?`bad`:``}}function ir(e,t,n){return e===`service_channel_feedback_no_alternate`||t===`pending_degraded_fallback`||(n||[]).includes(`no_unfenced_alternate_route`)?`warn`:t===`applied`||(n||[]).includes(`service_channel_rebuild_applied`)?`good`:e?.includes(`replacement`)||e||t?`info`:``}function H(e){return{active:`активно`,approved:`одобрено`,authoritative:`authoritative`,connecting:`подключается`,connected:`связан`,critical:`критично`,current:`актуальна`,degraded:`degraded`,disabled:`выключено`,enabled:`включено`,failed:`ошибка`,healthy:`здоров`,watch:`наблюдение`,flow_health_ready:`flow ready`,flow_drops_reported:`flow drops`,route_quality_window_drops_reported:`route drops`,backend_fallback_observed:`backend fallback`,route_quality_window_failures_reported:`route failures`,route_quality_window_slow_samples_reported:`slow samples`,route_send_latency_high:`high latency`,flow_queue_pressure_high:`queue pressure high`,bulk_pressure_with_interactive_qos_observed:`bulk+interactive`,bulk_pressure_observed:`bulk pressure`,flow_queue_pressure_observed:`queue pressure`,flow_health_degraded:`flow degraded`,bulk_window_reduced_to_protect_interactive:`bulk reduced`,rebuild_request_applied:`planner applied`,rebuild_request_recorded:`rebuild recorded`,rebuild_request_recorded_node_pending:`node pending`,rebuild_request_no_alternate:`no alternate`,rebuild_request_deferred_by_policy:`deferred by policy`,rebuild_request_rejected:`rebuild rejected`,rebuild_request_expired:`rebuild expired`,route_rebuild_no_safe_recovery:`no safe recovery`,access_decision:`access decision`,access_no_safe_recovery:`access no-safe`,access_recovery_selected:`access recovery`,access_rebuild_applied:`access applied`,access_replacement_selected:`access replacement`,inspect_access_no_safe_recovery_route_pool_and_signed_policy:`inspect no-safe route pool`,watch_recovery_route_quality_and_confirm_post_recovery_traffic:`watch recovery traffic`,confirm_applied_rebuild_runtime_traffic_stays_on_replacement:`confirm applied traffic`,watch_replacement_route_quality_until_applied_or_recovered:`watch replacement`,pending_degraded_fallback:`pending fallback`,service_channel_feedback_no_alternate:`no safe route`,service_channel_feedback_replacement:`replacement`,service_channel_feedback_exit_pool_replacement:`exit replacement`,service_channel_feedback_entry_pool_replacement:`entry replacement`,service_channel_feedback_entry_exit_pool_replacement:`pool replacement`,service_channel_remediation_command:`remediation`,service_channel_feedback_rebuild_requested:`rebuild requested`,remediation_rebuild_applied_to_alternate:`planner selected alternate`,no_unfenced_alternate_route:`no safe alternate`,active_lease_not_found_for_rebuild_resolution:`lease missing`,remediation_command_ttl_expired:`command expired`,durable_rebuild_route_request_recorded:`rebuild recorded`,durable_rebuild_route_request_rejected:`request rejected`,durable_rebuild_route_request_applied:`request applied`,durable_rebuild_route_no_alternate:`no alternate`,durable_rebuild_route_deferred_by_policy:`deferred by policy`,durable_rebuild_route_expired:`request expired`,isolated:`изолирован`,offline:`нет связи`,one_way:`односторонняя`,outdated:`обновить`,pending:`ожидает`,platform_managed:`платформенный`,promoted:`promoted`,rejected:`отклонено`,ready:`готово`,revoked:`отозвано`,running:`работает`,customer_managed:`клиентский`,no_policy:`нет политики`,not_configured:`не задано`,missing:`нет отчета`,service_channel_recovery_demoted:`demoted`,service_channel_recovery_demoted_degraded:`degraded`,service_channel_recovery_demoted_degraded_fallback:`fallback`,service_channel_recovery_demoted_failure:`failure`,service_channel_recovery_demoted_fenced:`fenced`,service_channel_recovery_demoted_rebuild:`rebuild`,service_channel_recovery_demoted_slow:`slow`,service_channel_feedback_provenance_missing:`provenance missing`,service_channel_feedback_stale:`stale feedback`,service_channel_feedback_stale_generation:`stale generation`,service_channel_feedback_stale_policy:`stale policy`,service_channel_feedback_stale_policy_and_generation:`stale policy+generation`,schema_ready:`schema ready`,schema_migration_required:`schema migration required`,snapshots_warmed:`snapshots warmed`,missing_snapshots_warmed_stale_deferred:`missing warmed, stale deferred`,snapshot_warmup_partial:`warmup partial`,stopped:`остановлено`,stale:`устарело`,unknown:`неизвестно`}[e]||e}(0,v.createRoot)(document.getElementById(`root`)).render((0,E.jsx)(_.StrictMode,{children:(0,E.jsx)(me,{})})); \ No newline at end of file diff --git a/web-admin/deploy/html/assets/index-gMV--oab.js b/web-admin/deploy/html/assets/index-gMV--oab.js deleted file mode 100644 index bd01d72..0000000 --- a/web-admin/deploy/html/assets/index-gMV--oab.js +++ /dev/null @@ -1,24 +0,0 @@ -var e=Object.create,t=Object.defineProperty,n=Object.getOwnPropertyDescriptor,r=Object.getOwnPropertyNames,i=Object.getPrototypeOf,a=Object.prototype.hasOwnProperty,o=(e,t)=>()=>(t||(e((t={exports:{}}).exports,t),e=null),t.exports),s=(e,i,o,s)=>{if(i&&typeof i==`object`||typeof i==`function`)for(var c=r(i),l=0,u=c.length,d;li[e]).bind(null,d),enumerable:!(s=n(i,d))||s.enumerable});return e},c=(n,r,a)=>(a=n==null?{}:e(i(n)),s(r||!n||!n.__esModule?t(a,`default`,{value:n,enumerable:!0}):a,n));(function(){let e=document.createElement(`link`).relList;if(e&&e.supports&&e.supports(`modulepreload`))return;for(let e of document.querySelectorAll(`link[rel="modulepreload"]`))n(e);new MutationObserver(e=>{for(let t of e)if(t.type===`childList`)for(let e of t.addedNodes)e.tagName===`LINK`&&e.rel===`modulepreload`&&n(e)}).observe(document,{childList:!0,subtree:!0});function t(e){let t={};return e.integrity&&(t.integrity=e.integrity),e.referrerPolicy&&(t.referrerPolicy=e.referrerPolicy),e.crossOrigin===`use-credentials`?t.credentials=`include`:e.crossOrigin===`anonymous`?t.credentials=`omit`:t.credentials=`same-origin`,t}function n(e){if(e.ep)return;e.ep=!0;let n=t(e);fetch(e.href,n)}})();var l=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.portal`),r=Symbol.for(`react.fragment`),i=Symbol.for(`react.strict_mode`),a=Symbol.for(`react.profiler`),o=Symbol.for(`react.consumer`),s=Symbol.for(`react.context`),c=Symbol.for(`react.forward_ref`),l=Symbol.for(`react.suspense`),u=Symbol.for(`react.memo`),d=Symbol.for(`react.lazy`),f=Symbol.for(`react.activity`),p=Symbol.iterator;function m(e){return typeof e!=`object`||!e?null:(e=p&&e[p]||e[`@@iterator`],typeof e==`function`?e:null)}var h={isMounted:function(){return!1},enqueueForceUpdate:function(){},enqueueReplaceState:function(){},enqueueSetState:function(){}},g=Object.assign,_={};function v(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}v.prototype.isReactComponent={},v.prototype.setState=function(e,t){if(typeof e!=`object`&&typeof e!=`function`&&e!=null)throw Error(`takes an object of state variables to update or a function which returns an object of state variables.`);this.updater.enqueueSetState(this,e,t,`setState`)},v.prototype.forceUpdate=function(e){this.updater.enqueueForceUpdate(this,e,`forceUpdate`)};function y(){}y.prototype=v.prototype;function b(e,t,n){this.props=e,this.context=t,this.refs=_,this.updater=n||h}var x=b.prototype=new y;x.constructor=b,g(x,v.prototype),x.isPureReactComponent=!0;var ee=Array.isArray;function S(){}var C={H:null,A:null,T:null,S:null},te=Object.prototype.hasOwnProperty;function ne(e,n,r){var i=r.ref;return{$$typeof:t,type:e,key:n,ref:i===void 0?null:i,props:r}}function re(e,t){return ne(e.type,t,e.props)}function ie(e){return typeof e==`object`&&!!e&&e.$$typeof===t}function w(e){var t={"=":`=0`,":":`=2`};return`$`+e.replace(/[=:]/g,function(e){return t[e]})}var ae=/\/+/g;function oe(e,t){return typeof e==`object`&&e&&e.key!=null?w(``+e.key):t.toString(36)}function T(e){switch(e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason;default:switch(typeof e.status==`string`?e.then(S,S):(e.status=`pending`,e.then(function(t){e.status===`pending`&&(e.status=`fulfilled`,e.value=t)},function(t){e.status===`pending`&&(e.status=`rejected`,e.reason=t)})),e.status){case`fulfilled`:return e.value;case`rejected`:throw e.reason}}throw e}function se(e,r,i,a,o){var s=typeof e;(s===`undefined`||s===`boolean`)&&(e=null);var c=!1;if(e===null)c=!0;else switch(s){case`bigint`:case`string`:case`number`:c=!0;break;case`object`:switch(e.$$typeof){case t:case n:c=!0;break;case d:return c=e._init,se(c(e._payload),r,i,a,o)}}if(c)return o=o(e),c=a===``?`.`+oe(e,0):a,ee(o)?(i=``,c!=null&&(i=c.replace(ae,`$&/`)+`/`),se(o,r,i,``,function(e){return e})):o!=null&&(ie(o)&&(o=re(o,i+(o.key==null||e&&e.key===o.key?``:(``+o.key).replace(ae,`$&/`)+`/`)+c)),r.push(o)),1;c=0;var l=a===``?`.`:a+`:`;if(ee(e))for(var u=0;u{t.exports=l()})),d=o((e=>{function t(e,t){var n=e.length;e.push(t);a:for(;0>>1,a=e[r];if(0>>1;ri(c,n))li(u,c)?(e[r]=u,e[l]=n,r=l):(e[r]=c,e[s]=n,r=s);else if(li(u,n))e[r]=u,e[l]=n,r=l;else break a}}return t}function i(e,t){var n=e.sortIndex-t.sortIndex;return n===0?e.id-t.id:n}if(e.unstable_now=void 0,typeof performance==`object`&&typeof performance.now==`function`){var a=performance;e.unstable_now=function(){return a.now()}}else{var o=Date,s=o.now();e.unstable_now=function(){return o.now()-s}}var c=[],l=[],u=1,d=null,f=3,p=!1,m=!1,h=!1,g=!1,_=typeof setTimeout==`function`?setTimeout:null,v=typeof clearTimeout==`function`?clearTimeout:null,y=typeof setImmediate<`u`?setImmediate:null;function b(e){for(var i=n(l);i!==null;){if(i.callback===null)r(l);else if(i.startTime<=e)r(l),i.sortIndex=i.expirationTime,t(c,i);else break;i=n(l)}}function x(e){if(h=!1,b(e),!m)if(n(c)!==null)m=!0,ee||(ee=!0,ie());else{var t=n(l);t!==null&&oe(x,t.startTime-e)}}var ee=!1,S=-1,C=5,te=-1;function ne(){return g?!0:!(e.unstable_now()-tet&&ne());){var o=d.callback;if(typeof o==`function`){d.callback=null,f=d.priorityLevel;var s=o(d.expirationTime<=t);if(t=e.unstable_now(),typeof s==`function`){d.callback=s,b(t),i=!0;break b}d===n(c)&&r(c),b(t)}else r(c);d=n(c)}if(d!==null)i=!0;else{var u=n(l);u!==null&&oe(x,u.startTime-t),i=!1}}break a}finally{d=null,f=a,p=!1}i=void 0}}finally{i?ie():ee=!1}}}var ie;if(typeof y==`function`)ie=function(){y(re)};else if(typeof MessageChannel<`u`){var w=new MessageChannel,ae=w.port2;w.port1.onmessage=re,ie=function(){ae.postMessage(null)}}else ie=function(){_(re,0)};function oe(t,n){S=_(function(){t(e.unstable_now())},n)}e.unstable_IdlePriority=5,e.unstable_ImmediatePriority=1,e.unstable_LowPriority=4,e.unstable_NormalPriority=3,e.unstable_Profiling=null,e.unstable_UserBlockingPriority=2,e.unstable_cancelCallback=function(e){e.callback=null},e.unstable_forceFrameRate=function(e){0>e||125o?(r.sortIndex=a,t(l,r),n(c)===null&&r===n(l)&&(h?(v(S),S=-1):h=!0,oe(x,a-o))):(r.sortIndex=s,t(c,r),m||p||(m=!0,ee||(ee=!0,ie()))),r},e.unstable_shouldYield=ne,e.unstable_wrapCallback=function(e){var t=f;return function(){var n=f;f=t;try{return e.apply(this,arguments)}finally{f=n}}}})),f=o(((e,t)=>{t.exports=d()})),p=o((e=>{var t=u();function n(e){var t=`https://react.dev/errors/`+e;if(1{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=p()})),h=o((e=>{var t=f(),n=u(),r=m();function i(e){var t=`https://react.dev/errors/`+e;if(1fe||(e.current=de[fe],de[fe]=null,fe--)}function pe(e,t){fe++,de[fe]=e.current,e.current=t}var A=O(null),me=O(null),he=O(null),ge=O(null);function _e(e,t){switch(pe(he,t),pe(me,e),pe(A,null),t.nodeType){case 9:case 11:e=(e=t.documentElement)&&(e=e.namespaceURI)?Vd(e):0;break;default:if(e=t.tagName,t=t.namespaceURI)t=Vd(t),e=Hd(t,e);else switch(e){case`svg`:e=1;break;case`math`:e=2;break;default:e=0}}k(A),pe(A,e)}function ve(){k(A),k(me),k(he)}function ye(e){e.memoizedState!==null&&pe(ge,e);var t=A.current,n=Hd(t,e.type);t!==n&&(pe(me,e),pe(A,n))}function be(e){me.current===e&&(k(A),k(me)),ge.current===e&&(k(ge),Qf._currentValue=ue)}var xe,Se;function Ce(e){if(xe===void 0)try{throw Error()}catch(e){var t=e.stack.trim().match(/\n( *(at )?)/);xe=t&&t[1]||``,Se=-1)`:-1i||c[r]!==l[i]){var u=` -`+c[r].replace(` at new `,` at `);return e.displayName&&u.includes(``)&&(u=u.replace(``,e.displayName)),u}while(1<=r&&0<=i);break}}}finally{we=!1,Error.prepareStackTrace=n}return(n=e?e.displayName||e.name:``)?Ce(n):``}function j(e,t){switch(e.tag){case 26:case 27:case 5:return Ce(e.type);case 16:return Ce(`Lazy`);case 13:return e.child!==t&&t!==null?Ce(`Suspense Fallback`):Ce(`Suspense`);case 19:return Ce(`SuspenseList`);case 0:case 15:return Te(e.type,!1);case 11:return Te(e.type.render,!1);case 1:return Te(e.type,!0);case 31:return Ce(`Activity`);default:return``}}function Ee(e){try{var t=``,n=null;do t+=j(e,n),n=e,e=e.return;while(e);return t}catch(e){return` -Error generating stack: `+e.message+` -`+e.stack}}var De=Object.prototype.hasOwnProperty,Oe=t.unstable_scheduleCallback,ke=t.unstable_cancelCallback,Ae=t.unstable_shouldYield,je=t.unstable_requestPaint,Me=t.unstable_now,Ne=t.unstable_getCurrentPriorityLevel,Pe=t.unstable_ImmediatePriority,Fe=t.unstable_UserBlockingPriority,Ie=t.unstable_NormalPriority,Le=t.unstable_LowPriority,Re=t.unstable_IdlePriority,ze=t.log,Be=t.unstable_setDisableYieldValue,Ve=null,He=null;function Ue(e){if(typeof ze==`function`&&Be(e),He&&typeof He.setStrictMode==`function`)try{He.setStrictMode(Ve,e)}catch{}}var We=Math.clz32?Math.clz32:qe,Ge=Math.log,Ke=Math.LN2;function qe(e){return e>>>=0,e===0?32:31-(Ge(e)/Ke|0)|0}var Je=256,M=262144,Ye=4194304;function Xe(e){var t=e&42;if(t!==0)return t;switch(e&-e){case 1:return 1;case 2:return 2;case 4:return 4;case 8:return 8;case 16:return 16;case 32:return 32;case 64:return 64;case 128:return 128;case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:return e&261888;case 262144:case 524288:case 1048576:case 2097152:return e&3932160;case 4194304:case 8388608:case 16777216:case 33554432:return e&62914560;case 67108864:return 67108864;case 134217728:return 134217728;case 268435456:return 268435456;case 536870912:return 536870912;case 1073741824:return 0;default:return e}}function Ze(e,t,n){var r=e.pendingLanes;if(r===0)return 0;var i=0,a=e.suspendedLanes,o=e.pingedLanes;e=e.warmLanes;var s=r&134217727;return s===0?(s=r&~a,s===0?o===0?n||(n=r&~e,n!==0&&(i=Xe(n))):i=Xe(o):i=Xe(s)):(r=s&~a,r===0?(o&=s,o===0?n||(n=s&~e,n!==0&&(i=Xe(n))):i=Xe(o)):i=Xe(r)),i===0?0:t!==0&&t!==i&&(t&a)===0&&(a=i&-i,n=t&-t,a>=n||a===32&&n&4194048)?t:i}function Qe(e,t){return(e.pendingLanes&~(e.suspendedLanes&~e.pingedLanes)&t)===0}function $e(e,t){switch(e){case 1:case 2:case 4:case 8:case 64:return t+250;case 16:case 32:case 128:case 256:case 512:case 1024:case 2048:case 4096:case 8192:case 16384:case 32768:case 65536:case 131072:case 262144:case 524288:case 1048576:case 2097152:return t+5e3;case 4194304:case 8388608:case 16777216:case 33554432:return-1;case 67108864:case 134217728:case 268435456:case 536870912:case 1073741824:return-1;default:return-1}}function et(){var e=Ye;return Ye<<=1,!(Ye&62914560)&&(Ye=4194304),e}function tt(e){for(var t=[],n=0;31>n;n++)t.push(e);return t}function nt(e,t){e.pendingLanes|=t,t!==268435456&&(e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0)}function rt(e,t,n,r,i,a){var o=e.pendingLanes;e.pendingLanes=n,e.suspendedLanes=0,e.pingedLanes=0,e.warmLanes=0,e.expiredLanes&=n,e.entangledLanes&=n,e.errorRecoveryDisabledLanes&=n,e.shellSuspendCounter=0;var s=e.entanglements,c=e.expirationTimes,l=e.hiddenUpdates;for(n=o&~n;0`u`||window.document===void 0||window.document.createElement===void 0),L=!1;if(dn)try{var fn={};Object.defineProperty(fn,`passive`,{get:function(){L=!0}}),window.addEventListener(`test`,fn,fn),window.removeEventListener(`test`,fn,fn)}catch{L=!1}var pn=null,R=null,mn=null;function hn(){if(mn)return mn;var e,t=R,n=t.length,r,i=`value`in pn?pn.value:pn.textContent,a=i.length;for(e=0;e=Wn),Kn=` `,qn=!1;function Jn(e,t){switch(e){case`keyup`:return Hn.indexOf(t.keyCode)!==-1;case`keydown`:return t.keyCode!==229;case`keypress`:case`mousedown`:case`focusout`:return!0;default:return!1}}function Yn(e){return e=e.detail,typeof e==`object`&&`data`in e?e.data:null}var Xn=!1;function Zn(e,t){switch(e){case`compositionend`:return Yn(t);case`keypress`:return t.which===32?(qn=!0,Kn):null;case`textInput`:return e=t.data,e===Kn&&qn?null:e;default:return null}}function Qn(e,t){if(Xn)return e===`compositionend`||!Un&&Jn(e,t)?(e=hn(),mn=R=pn=null,Xn=!1,e):null;switch(e){case`paste`:return null;case`keypress`:if(!(t.ctrlKey||t.altKey||t.metaKey)||t.ctrlKey&&t.altKey){if(t.char&&1=t)return{node:n,offset:t-e};e=r}a:{for(;n;){if(n.nextSibling){n=n.nextSibling;break a}n=n.parentNode}n=void 0}n=br(n)}}function Sr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Sr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function Cr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=zt(e.document);t instanceof e.HTMLIFrameElement;){try{var n=typeof t.contentWindow.location.href==`string`}catch{n=!1}if(n)e=t.contentWindow;else break;t=zt(e.document)}return t}function wr(e){var t=e&&e.nodeName&&e.nodeName.toLowerCase();return t&&(t===`input`&&(e.type===`text`||e.type===`search`||e.type===`tel`||e.type===`url`||e.type===`password`)||t===`textarea`||e.contentEditable===`true`)}var Tr=dn&&`documentMode`in document&&11>=document.documentMode,Er=null,Dr=null,Or=null,kr=!1;function Ar(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;kr||Er==null||Er!==zt(r)||(r=Er,`selectionStart`in r&&wr(r)?r={start:r.selectionStart,end:r.selectionEnd}:(r=(r.ownerDocument&&r.ownerDocument.defaultView||window).getSelection(),r={anchorNode:r.anchorNode,anchorOffset:r.anchorOffset,focusNode:r.focusNode,focusOffset:r.focusOffset}),Or&&yr(Or,r)||(Or=r,r=Td(Dr,`onSelect`),0>=o,i-=o,xi=1<<32-We(t)+i|n<h?(g=d,d=null):g=d.sibling;var _=p(i,d,s[h],c);if(_===null){d===null&&(d=g);break}e&&d&&_.alternate===null&&t(i,d),a=o(_,a,h),u===null?l=_:u.sibling=_,u=_,d=g}if(h===s.length)return n(i,d),G&&Ci(i,h),l;if(d===null){for(;hg?(_=h,h=null):_=h.sibling;var y=p(a,h,v.value,l);if(y===null){h===null&&(h=_);break}e&&h&&y.alternate===null&&t(a,h),s=o(y,s,g),d===null?u=y:d.sibling=y,d=y,h=_}if(v.done)return n(a,h),G&&Ci(a,g),u;if(h===null){for(;!v.done;g++,v=c.next())v=f(a,v.value,l),v!==null&&(s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return G&&Ci(a,g),u}for(h=r(h);!v.done;g++,v=c.next())v=m(h,a,g,v.value,l),v!==null&&(e&&v.alternate!==null&&h.delete(v.key===null?g:v.key),s=o(v,s,g),d===null?u=v:d.sibling=v,d=v);return e&&h.forEach(function(e){return t(a,e)}),G&&Ci(a,g),u}function b(e,r,o,c){if(typeof o==`object`&&o&&o.type===y&&o.key===null&&(o=o.props.children),typeof o==`object`&&o){switch(o.$$typeof){case _:a:{for(var l=o.key;r!==null;){if(r.key===l){if(l=o.type,l===y){if(r.tag===7){n(e,r.sibling),c=a(r,o.props.children),c.return=e,e=c;break a}}else if(r.elementType===l||typeof l==`object`&&l&&l.$$typeof===ie&&ya(l)===r.type){n(e,r.sibling),c=a(r,o.props),Ea(c,o),c.return=e,e=c;break a}n(e,r);break}else t(e,r);r=r.sibling}o.type===y?(c=ci(o.props.children,e.mode,c,o.key),c.return=e,e=c):(c=si(o.type,o.key,o.props,null,e.mode,c),Ea(c,o),c.return=e,e=c)}return s(e);case v:a:{for(l=o.key;r!==null;){if(r.key===l)if(r.tag===4&&r.stateNode.containerInfo===o.containerInfo&&r.stateNode.implementation===o.implementation){n(e,r.sibling),c=a(r,o.children||[]),c.return=e,e=c;break a}else{n(e,r);break}else t(e,r);r=r.sibling}c=di(o,e.mode,c),c.return=e,e=c}return s(e);case ie:return o=ya(o),b(e,r,o,c)}if(le(o))return h(e,r,o,c);if(T(o)){if(l=T(o),typeof l!=`function`)throw Error(i(150));return o=l.call(o),g(e,r,o,c)}if(typeof o.then==`function`)return b(e,r,Ta(o),c);if(o.$$typeof===S)return b(e,r,Ji(e,o),c);Y(e,o)}return typeof o==`string`&&o!==``||typeof o==`number`||typeof o==`bigint`?(o=``+o,r!==null&&r.tag===6?(n(e,r.sibling),c=a(r,o),c.return=e,e=c):(n(e,r),c=li(o,e.mode,c),c.return=e,e=c),s(e)):n(e,r)}return function(e,t,n,r){try{wa=0;var i=b(e,t,n,r);return Ca=null,i}catch(t){if(t===pa||t===ha)throw t;var a=ri(29,t,null,e.mode);return a.lanes=r,a.return=e,a}}}var Oa=Da(!0),ka=Da(!1),Aa=!1;function ja(e){e.updateQueue={baseState:e.memoizedState,firstBaseUpdate:null,lastBaseUpdate:null,shared:{pending:null,lanes:0,hiddenCallbacks:null},callbacks:null}}function Ma(e,t){e=e.updateQueue,t.updateQueue===e&&(t.updateQueue={baseState:e.baseState,firstBaseUpdate:e.firstBaseUpdate,lastBaseUpdate:e.lastBaseUpdate,shared:e.shared,callbacks:null})}function Na(e){return{lane:e,tag:0,payload:null,callback:null,next:null}}function Pa(e,t,n){var r=e.updateQueue;if(r===null)return null;if(r=r.shared,Pl&2){var i=r.pending;return i===null?t.next=t:(t.next=i.next,i.next=t),r.pending=t,t=ei(e),$r(e,null,n),t}return Xr(e,r,t,n),ei(e)}function Fa(e,t,n){if(t=t.updateQueue,t!==null&&(t=t.shared,n&4194048)){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,at(e,n)}}function Ia(e,t){var n=e.updateQueue,r=e.alternate;if(r!==null&&(r=r.updateQueue,n===r)){var i=null,a=null;if(n=n.firstBaseUpdate,n!==null){do{var o={lane:n.lane,tag:n.tag,payload:n.payload,callback:null,next:null};a===null?i=a=o:a=a.next=o,n=n.next}while(n!==null);a===null?i=a=t:a=a.next=t}else i=a=t;n={baseState:r.baseState,firstBaseUpdate:i,lastBaseUpdate:a,shared:r.shared,callbacks:r.callbacks},e.updateQueue=n;return}e=n.lastBaseUpdate,e===null?n.firstBaseUpdate=t:e.next=t,n.lastBaseUpdate=t}var La=!1;function Ra(){if(La){var e=ia;if(e!==null)throw e}}function za(e,t,n,r){La=!1;var i=e.updateQueue;Aa=!1;var a=i.firstBaseUpdate,o=i.lastBaseUpdate,s=i.shared.pending;if(s!==null){i.shared.pending=null;var c=s,l=c.next;c.next=null,o===null?a=l:o.next=l,o=c;var u=e.alternate;u!==null&&(u=u.updateQueue,s=u.lastBaseUpdate,s!==o&&(s===null?u.firstBaseUpdate=l:s.next=l,u.lastBaseUpdate=c))}if(a!==null){var d=i.baseState;o=0,u=l=c=null,s=a;do{var f=s.lane&-536870913,p=f!==s.lane;if(p?(Q&f)===f:(r&f)===f){f!==0&&f===ra&&(La=!0),u!==null&&(u=u.next={lane:0,tag:s.tag,payload:s.payload,callback:null,next:null});a:{var m=e,g=s;f=t;var _=n;switch(g.tag){case 1:if(m=g.payload,typeof m==`function`){d=m.call(_,d,f);break a}d=m;break a;case 3:m.flags=m.flags&-65537|128;case 0:if(m=g.payload,f=typeof m==`function`?m.call(_,d,f):m,f==null)break a;d=h({},d,f);break a;case 2:Aa=!0}}f=s.callback,f!==null&&(e.flags|=64,p&&(e.flags|=8192),p=i.callbacks,p===null?i.callbacks=[f]:p.push(f))}else p={lane:f,tag:s.tag,payload:s.payload,callback:s.callback,next:null},u===null?(l=u=p,c=d):u=u.next=p,o|=f;if(s=s.next,s===null){if(s=i.shared.pending,s===null)break;p=s,s=p.next,p.next=null,i.lastBaseUpdate=p,i.shared.pending=null}}while(1);u===null&&(c=d),i.baseState=c,i.firstBaseUpdate=l,i.lastBaseUpdate=u,a===null&&(i.shared.lanes=0),Ul|=o,e.lanes=o,e.memoizedState=d}}function Ba(e,t){if(typeof e!=`function`)throw Error(i(191,e));e.call(t)}function Va(e,t){var n=e.callbacks;if(n!==null)for(e.callbacks=null,e=0;ea?a:8;var o=E.T,s={};E.T=s,Ds(e,!1,t,n);try{var c=i(),l=E.S;l!==null&&l(s,c),typeof c==`object`&&c&&typeof c.then==`function`?Es(e,t,sa(c,r),du(e)):Es(e,t,r,du(e))}catch(n){Es(e,t,{then:function(){},status:`rejected`,reason:n},du())}finally{D.p=a,o!==null&&s.types!==null&&(o.types=s.types),E.T=o}}function gs(){}function _s(e,t,n,r){if(e.tag!==5)throw Error(i(476));var a=vs(e).queue;hs(e,a,t,ue,n===null?gs:function(){return ys(e),n(r)})}function vs(e){var t=e.memoizedState;if(t!==null)return t;t={memoizedState:ue,baseState:ue,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Oo,lastRenderedState:ue},next:null};var n={};return t.next={memoizedState:n,baseState:n,baseQueue:null,queue:{pending:null,lanes:0,dispatch:null,lastRenderedReducer:Oo,lastRenderedState:n},next:null},e.memoizedState=t,e=e.alternate,e!==null&&(e.memoizedState=t),t}function ys(e){var t=vs(e);t.next===null&&(t=e.alternate.memoizedState),Es(e,t.next.queue,{},du())}function bs(){return qi(Qf)}function xs(){return Co().memoizedState}function Ss(){return Co().memoizedState}function Cs(e){for(var t=e.return;t!==null;){switch(t.tag){case 24:case 3:var n=du();e=Na(n);var r=Pa(t,e,n);r!==null&&(pu(r,t,n),Fa(r,t,n)),t={cache:$i()},e.payload=t;return}t=t.return}}function ws(e,t,n){var r=du();n={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null},Os(e)?ks(t,n):(n=Zr(e,t,n,r),n!==null&&(pu(n,e,r),As(n,t,r)))}function Ts(e,t,n){Es(e,t,n,du())}function Es(e,t,n,r){var i={lane:r,revertLane:0,gesture:null,action:n,hasEagerState:!1,eagerState:null,next:null};if(Os(e))ks(t,i);else{var a=e.alternate;if(e.lanes===0&&(a===null||a.lanes===0)&&(a=t.lastRenderedReducer,a!==null))try{var o=t.lastRenderedState,s=a(o,n);if(i.hasEagerState=!0,i.eagerState=s,vr(s,o))return Xr(e,t,i,0),Fl===null&&Yr(),!1}catch{}if(n=Zr(e,t,i,r),n!==null)return pu(n,e,r),As(n,t,r),!0}return!1}function Ds(e,t,n,r){if(r={lane:2,revertLane:ud(),gesture:null,action:r,hasEagerState:!1,eagerState:null,next:null},Os(e)){if(t)throw Error(i(479))}else t=Zr(e,n,r,2),t!==null&&pu(t,e,2)}function Os(e){var t=e.alternate;return e===X||t!==null&&t===X}function ks(e,t){oo=ao=!0;var n=e.pending;n===null?t.next=t:(t.next=n.next,n.next=t),e.pending=t}function As(e,t,n){if(n&4194048){var r=t.lanes;r&=e.pendingLanes,n|=r,t.lanes=n,at(e,n)}}var js={readContext:qi,use:Eo,useCallback:po,useContext:po,useEffect:po,useImperativeHandle:po,useLayoutEffect:po,useInsertionEffect:po,useMemo:po,useReducer:po,useRef:po,useState:po,useDebugValue:po,useDeferredValue:po,useTransition:po,useSyncExternalStore:po,useId:po,useHostTransitionStatus:po,useFormState:po,useActionState:po,useOptimistic:po,useMemoCache:po,useCacheRefresh:po};js.useEffectEvent=po;var Ms={readContext:qi,use:Eo,useCallback:function(e,t){return So().memoizedState=[e,t===void 0?null:t],e},useContext:qi,useEffect:ns,useImperativeHandle:function(e,t,n){n=n==null?null:n.concat([e]),es(4194308,4,cs.bind(null,t,e),n)},useLayoutEffect:function(e,t){return es(4194308,4,e,t)},useInsertionEffect:function(e,t){es(4,2,e,t)},useMemo:function(e,t){var n=So();t=t===void 0?null:t;var r=e();if(so){Ue(!0);try{e()}finally{Ue(!1)}}return n.memoizedState=[r,t],r},useReducer:function(e,t,n){var r=So();if(n!==void 0){var i=n(t);if(so){Ue(!0);try{n(t)}finally{Ue(!1)}}}else i=t;return r.memoizedState=r.baseState=i,e={pending:null,lanes:0,dispatch:null,lastRenderedReducer:e,lastRenderedState:i},r.queue=e,e=e.dispatch=ws.bind(null,X,e),[r.memoizedState,e]},useRef:function(e){var t=So();return e={current:e},t.memoizedState=e},useState:function(e){e=Ro(e);var t=e.queue,n=Ts.bind(null,X,t);return t.dispatch=n,[e.memoizedState,n]},useDebugValue:us,useDeferredValue:function(e,t){return ps(So(),e,t)},useTransition:function(){var e=Ro(!1);return e=hs.bind(null,X,e.queue,!0,!1),So().memoizedState=e,[!1,e]},useSyncExternalStore:function(e,t,n){var r=X,a=So();if(G){if(n===void 0)throw Error(i(407));n=n()}else{if(n=t(),Fl===null)throw Error(i(349));Q&127||No(r,t,n)}a.memoizedState=n;var o={value:n,getSnapshot:t};return a.queue=o,ns(Fo.bind(null,r,o,e),[e]),r.flags|=2048,Qo(9,{destroy:void 0},Po.bind(null,r,o,n,t),null),n},useId:function(){var e=So(),t=Fl.identifierPrefix;if(G){var n=Si,r=xi;n=(r&~(1<<32-We(r)-1)).toString(32)+n,t=`_`+t+`R_`+n,n=co++,0<\/script>`,o=o.removeChild(o.firstChild);break;case`select`:o=typeof r.is==`string`?s.createElement(`select`,{is:r.is}):s.createElement(`select`),r.multiple?o.multiple=!0:r.size&&(o.size=r.size);break;default:o=typeof r.is==`string`?s.createElement(a,{is:r.is}):s.createElement(a)}}o[ft]=t,o[pt]=r;a:for(s=t.child;s!==null;){if(s.tag===5||s.tag===6)o.appendChild(s.stateNode);else if(s.tag!==4&&s.tag!==27&&s.child!==null){s.child.return=s,s=s.child;continue}if(s===t)break a;for(;s.sibling===null;){if(s.return===null||s.return===t)break a;s=s.return}s.sibling.return=s.return,s=s.sibling}t.stateNode=o;a:switch(Pd(o,a,r),a){case`button`:case`input`:case`select`:case`textarea`:r=!!r.autoFocus;break a;case`img`:r=!0;break a;default:r=!1}r&&Ec(t)}}return jc(t),Dc(t,t.type,e===null?null:e.memoizedProps,t.pendingProps,n),null;case 6:if(e&&t.stateNode!=null)e.memoizedProps!==r&&Ec(t);else{if(typeof r!=`string`&&t.stateNode===null)throw Error(i(166));if(e=he.current,Fi(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Di,a!==null)switch(a.tag){case 27:case 5:r=a.memoizedProps}e[ft]=t,e=!!(e.nodeValue===n||r!==null&&!0===r.suppressHydrationWarning||jd(e.nodeValue,n)),e||Mi(t,!0)}else e=Bd(e).createTextNode(r),e[ft]=t,t.stateNode=e}return jc(t),null;case 31:if(n=t.memoizedState,e===null||e.memoizedState!==null){if(r=Fi(t),n!==null){if(e===null){if(!r)throw Error(i(318));if(e=t.memoizedState,e=e===null?null:e.dehydrated,!e)throw Error(i(557));e[ft]=t}else K(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;jc(t),e=!1}else n=Ii(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=n),e=!0;if(!e)return t.flags&256?($a(t),t):($a(t),null);if(t.flags&128)throw Error(i(558))}return jc(t),null;case 13:if(r=t.memoizedState,e===null||e.memoizedState!==null&&e.memoizedState.dehydrated!==null){if(a=Fi(t),r!==null&&r.dehydrated!==null){if(e===null){if(!a)throw Error(i(318));if(a=t.memoizedState,a=a===null?null:a.dehydrated,!a)throw Error(i(317));a[ft]=t}else K(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;jc(t),a=!1}else a=Ii(),e!==null&&e.memoizedState!==null&&(e.memoizedState.hydrationErrors=a),a=!0;if(!a)return t.flags&256?($a(t),t):($a(t),null)}return $a(t),t.flags&128?(t.lanes=n,t):(n=r!==null,e=e!==null&&e.memoizedState!==null,n&&(r=t.child,a=null,r.alternate!==null&&r.alternate.memoizedState!==null&&r.alternate.memoizedState.cachePool!==null&&(a=r.alternate.memoizedState.cachePool.pool),o=null,r.memoizedState!==null&&r.memoizedState.cachePool!==null&&(o=r.memoizedState.cachePool.pool),o!==a&&(r.flags|=2048)),n!==e&&n&&(t.child.flags|=8192),kc(t,t.updateQueue),jc(t),null);case 4:return ve(),e===null&&xd(t.stateNode.containerInfo),jc(t),null;case 10:return Hi(t.type),jc(t),null;case 19:if(k(eo),r=t.memoizedState,r===null)return jc(t),null;if(a=(t.flags&128)!=0,o=r.rendering,o===null)if(a)Ac(r,!1);else{if(Hl!==0||e!==null&&e.flags&128)for(e=t.child;e!==null;){if(o=to(e),o!==null){for(t.flags|=128,Ac(r,!1),e=o.updateQueue,t.updateQueue=e,kc(t,e),t.subtreeFlags=0,e=n,n=t.child;n!==null;)oi(n,e),n=n.sibling;return pe(eo,eo.current&1|2),G&&Ci(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&Me()>$l&&(t.flags|=128,a=!0,Ac(r,!1),t.lanes=4194304)}else{if(!a)if(e=to(o),e!==null){if(t.flags|=128,a=!0,e=e.updateQueue,t.updateQueue=e,kc(t,e),Ac(r,!0),r.tail===null&&r.tailMode===`hidden`&&!o.alternate&&!G)return jc(t),null}else 2*Me()-r.renderingStartTime>$l&&n!==536870912&&(t.flags|=128,a=!0,Ac(r,!1),t.lanes=4194304);r.isBackwards?(o.sibling=t.child,t.child=o):(e=r.last,e===null?t.child=o:e.sibling=o,r.last=o)}return r.tail===null?(jc(t),null):(e=r.tail,r.rendering=e,r.tail=e.sibling,r.renderingStartTime=Me(),e.sibling=null,n=eo.current,pe(eo,a?n&1|2:n&1),G&&Ci(t,r.treeForkCount),e);case 22:case 23:return $a(t),Ka(),r=t.memoizedState!==null,e===null?r&&(t.flags|=8192):e.memoizedState!==null!==r&&(t.flags|=8192),r?n&536870912&&!(t.flags&128)&&(jc(t),t.subtreeFlags&6&&(t.flags|=8192)):jc(t),n=t.updateQueue,n!==null&&kc(t,n.retryQueue),n=null,e!==null&&e.memoizedState!==null&&e.memoizedState.cachePool!==null&&(n=e.memoizedState.cachePool.pool),r=null,t.memoizedState!==null&&t.memoizedState.cachePool!==null&&(r=t.memoizedState.cachePool.pool),r!==n&&(t.flags|=2048),e!==null&&k(la),null;case 24:return n=null,e!==null&&(n=e.memoizedState.cache),t.memoizedState.cache!==n&&(t.flags|=2048),Hi(Qi),jc(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Nc(e,t){switch(W(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Hi(Qi),ve(),e=t.flags,e&65536&&!(e&128)?(t.flags=e&-65537|128,t):null;case 26:case 27:case 5:return be(t),null;case 31:if(t.memoizedState!==null){if($a(t),t.alternate===null)throw Error(i(340));K()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 13:if($a(t),e=t.memoizedState,e!==null&&e.dehydrated!==null){if(t.alternate===null)throw Error(i(340));K()}return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 19:return k(eo),null;case 4:return ve(),null;case 10:return Hi(t.type),null;case 22:case 23:return $a(t),Ka(),e!==null&&k(la),e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 24:return Hi(Qi),null;case 25:return null;default:return null}}function Pc(e,t){switch(W(t),t.tag){case 3:Hi(Qi),ve();break;case 26:case 27:case 5:be(t);break;case 4:ve();break;case 31:t.memoizedState!==null&&$a(t);break;case 13:$a(t);break;case 19:k(eo);break;case 10:Hi(t.type);break;case 22:case 23:$a(t),Ka(),e!==null&&k(la);break;case 24:Hi(Qi)}}function Fc(e,t){try{var n=t.updateQueue,r=n===null?null:n.lastEffect;if(r!==null){var i=r.next;n=i;do{if((n.tag&e)===e){r=void 0;var a=n.create,o=n.inst;r=a(),o.destroy=r}n=n.next}while(n!==i)}}catch(e){Uu(t,t.return,e)}}function Ic(e,t,n){try{var r=t.updateQueue,i=r===null?null:r.lastEffect;if(i!==null){var a=i.next;r=a;do{if((r.tag&e)===e){var o=r.inst,s=o.destroy;if(s!==void 0){o.destroy=void 0,i=t;var c=n,l=s;try{l()}catch(e){Uu(i,c,e)}}}r=r.next}while(r!==a)}}catch(e){Uu(t,t.return,e)}}function Lc(e){var t=e.updateQueue;if(t!==null){var n=e.stateNode;try{Va(t,n)}catch(t){Uu(e,e.return,t)}}}function Rc(e,t,n){n.props=zs(e.type,e.memoizedProps),n.state=e.memoizedState;try{n.componentWillUnmount()}catch(n){Uu(e,t,n)}}function zc(e,t){try{var n=e.ref;if(n!==null){switch(e.tag){case 26:case 27:case 5:var r=e.stateNode;break;case 30:r=e.stateNode;break;default:r=e.stateNode}typeof n==`function`?e.refCleanup=n(r):n.current=r}}catch(n){Uu(e,t,n)}}function Bc(e,t){var n=e.ref,r=e.refCleanup;if(n!==null)if(typeof r==`function`)try{r()}catch(n){Uu(e,t,n)}finally{e.refCleanup=null,e=e.alternate,e!=null&&(e.refCleanup=null)}else if(typeof n==`function`)try{n(null)}catch(n){Uu(e,t,n)}else n.current=null}function Vc(e){var t=e.type,n=e.memoizedProps,r=e.stateNode;try{a:switch(t){case`button`:case`input`:case`select`:case`textarea`:n.autoFocus&&r.focus();break a;case`img`:n.src?r.src=n.src:n.srcSet&&(r.srcset=n.srcSet)}}catch(t){Uu(e,e.return,t)}}function Hc(e,t,n){try{var r=e.stateNode;Fd(r,e.type,n,t),r[pt]=t}catch(t){Uu(e,e.return,t)}}function Uc(e){return e.tag===5||e.tag===3||e.tag===26||e.tag===27&&Zd(e.type)||e.tag===4}function Wc(e){a:for(;;){for(;e.sibling===null;){if(e.return===null||Uc(e.return))return null;e=e.return}for(e.sibling.return=e.return,e=e.sibling;e.tag!==5&&e.tag!==6&&e.tag!==18;){if(e.tag===27&&Zd(e.type)||e.flags&2||e.child===null||e.tag===4)continue a;e.child.return=e,e=e.child}if(!(e.flags&2))return e.stateNode}}function Gc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?(n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n).insertBefore(e,t):(t=n.nodeType===9?n.body:n.nodeName===`HTML`?n.ownerDocument.body:n,t.appendChild(e),n=n._reactRootContainer,n!=null||t.onclick!==null||(t.onclick=tn));else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode,t=null),e=e.child,e!==null))for(Gc(e,t,n),e=e.sibling;e!==null;)Gc(e,t,n),e=e.sibling}function Kc(e,t,n){var r=e.tag;if(r===5||r===6)e=e.stateNode,t?n.insertBefore(e,t):n.appendChild(e);else if(r!==4&&(r===27&&Zd(e.type)&&(n=e.stateNode),e=e.child,e!==null))for(Kc(e,t,n),e=e.sibling;e!==null;)Kc(e,t,n),e=e.sibling}function qc(e){var t=e.stateNode,n=e.memoizedProps;try{for(var r=e.type,i=t.attributes;i.length;)t.removeAttributeNode(i[0]);Pd(t,r,n),t[ft]=e,t[pt]=n}catch(t){Uu(e,e.return,t)}}var Jc=!1,Yc=!1,Xc=!1,Zc=typeof WeakSet==`function`?WeakSet:Set,Qc=null;function $c(e,t){if(e=e.containerInfo,Rd=sp,e=Cr(e),wr(e)){if(`selectionStart`in e)var n={start:e.selectionStart,end:e.selectionEnd};else a:{n=(n=e.ownerDocument)&&n.defaultView||window;var r=n.getSelection&&n.getSelection();if(r&&r.rangeCount!==0){n=r.anchorNode;var a=r.anchorOffset,o=r.focusNode;r=r.focusOffset;try{n.nodeType,o.nodeType}catch{n=null;break a}var s=0,c=-1,l=-1,u=0,d=0,f=e,p=null;b:for(;;){for(var m;f!==n||a!==0&&f.nodeType!==3||(c=s+a),f!==o||r!==0&&f.nodeType!==3||(l=s+r),f.nodeType===3&&(s+=f.nodeValue.length),(m=f.firstChild)!==null;)p=f,f=m;for(;;){if(f===e)break b;if(p===n&&++u===a&&(c=s),p===o&&++d===r&&(l=s),(m=f.nextSibling)!==null)break;f=p,p=f.parentNode}f=m}n=c===-1||l===-1?null:{start:c,end:l}}else n=null}n||={start:0,end:0}}else n=null;for(zd={focusedElem:e,selectionRange:n},sp=!1,Qc=t;Qc!==null;)if(t=Qc,e=t.child,t.subtreeFlags&1028&&e!==null)e.return=t,Qc=e;else for(;Qc!==null;){switch(t=Qc,o=t.alternate,e=t.flags,t.tag){case 0:if(e&4&&(e=t.updateQueue,e=e===null?null:e.events,e!==null))for(n=0;n title`))),Pd(o,r,n),o[ft]=e,Ct(o),r=o;break a;case`link`:var s=Vf(`link`,`href`,a).get(r+(n.href||``));if(s){for(var c=0;cg&&(o=g,g=h,h=o);var _=xr(s,h),v=xr(s,g);if(_&&v&&(p.rangeCount!==1||p.anchorNode!==_.node||p.anchorOffset!==_.offset||p.focusNode!==v.node||p.focusOffset!==v.offset)){var y=d.createRange();y.setStart(_.node,_.offset),p.removeAllRanges(),h>g?(p.addRange(y),p.extend(v.node,v.offset)):(y.setEnd(v.node,v.offset),p.addRange(y))}}}}for(d=[],p=s;p=p.parentNode;)p.nodeType===1&&d.push({element:p,left:p.scrollLeft,top:p.scrollTop});for(typeof s.focus==`function`&&s.focus(),s=0;sn?32:n,E.T=null,n=su,su=null;var o=ru,s=au;if(nu=0,iu=ru=null,au=0,Pl&6)throw Error(i(331));var c=Pl;if(Pl|=4,kl(o.current),xl(o,o.current,s,n),Pl=c,rd(0,!1),He&&typeof He.onPostCommitFiberRoot==`function`)try{He.onPostCommitFiberRoot(Ve,o)}catch{}return!0}finally{D.p=a,E.T=r,zu(e,t)}}function Hu(e,t,n){t=pi(n,t),t=Gs(e.stateNode,t,2),e=Pa(e,t,2),e!==null&&(nt(e,2),nd(e))}function Uu(e,t,n){if(e.tag===3)Hu(e,e,n);else for(;t!==null;){if(t.tag===3){Hu(t,e,n);break}else if(t.tag===1){var r=t.stateNode;if(typeof t.type.getDerivedStateFromError==`function`||typeof r.componentDidCatch==`function`&&(tu===null||!tu.has(r))){e=pi(n,e),n=Ks(2),r=Pa(t,n,2),r!==null&&(qs(n,r,t,e),nt(r,2),nd(r));break}}t=t.return}}function Wu(e,t,n){var r=e.pingCache;if(r===null){r=e.pingCache=new Nl;var i=new Set;r.set(t,i)}else i=r.get(t),i===void 0&&(i=new Set,r.set(t,i));i.has(n)||(Bl=!0,i.add(n),e=Gu.bind(null,e,t,n),t.then(e,e))}function Gu(e,t,n){var r=e.pingCache;r!==null&&r.delete(t),e.pingedLanes|=e.suspendedLanes&n,e.warmLanes&=~n,Fl===e&&(Q&n)===n&&(Hl===4||Hl===3&&(Q&62914560)===Q&&300>Me()-Zl?!(Pl&2)&&bu(e,0):Gl|=n,ql===Q&&(ql=0)),nd(e)}function Ku(e,t){t===0&&(t=et()),e=Qr(e,t),e!==null&&(nt(e,t),nd(e))}function qu(e){var t=e.memoizedState,n=0;t!==null&&(n=t.retryLane),Ku(e,n)}function Ju(e,t){var n=0;switch(e.tag){case 31:case 13:var r=e.stateNode,a=e.memoizedState;a!==null&&(n=a.retryLane);break;case 19:r=e.stateNode;break;case 22:r=e.stateNode._retryCache;break;default:throw Error(i(314))}r!==null&&r.delete(t),Ku(e,n)}function Yu(e,t){return Oe(e,t)}var Xu=null,Zu=null,Qu=!1,$u=!1,ed=!1,td=0;function nd(e){e!==Zu&&e.next===null&&(Zu===null?Xu=Zu=e:Zu=Zu.next=e),$u=!0,Qu||(Qu=!0,ld())}function rd(e,t){if(!ed&&$u){ed=!0;do for(var n=!1,r=Xu;r!==null;){if(!t)if(e!==0){var i=r.pendingLanes;if(i===0)var a=0;else{var o=r.suspendedLanes,s=r.pingedLanes;a=(1<<31-We(42|e)+1)-1,a&=i&~(o&~s),a=a&201326741?a&201326741|1:a?a|2:0}a!==0&&(n=!0,cd(r,a))}else a=Q,a=Ze(r,r===Fl?a:0,r.cancelPendingCommit!==null||r.timeoutHandle!==-1),!(a&3)||Qe(r,a)||(n=!0,cd(r,a));r=r.next}while(n);ed=!1}}function id(){ad()}function ad(){$u=Qu=!1;var e=0;td!==0&&Gd()&&(e=td);for(var t=Me(),n=null,r=Xu;r!==null;){var i=r.next,a=od(r,t);a===0?(r.next=null,n===null?Xu=i:n.next=i,i===null&&(Zu=n)):(n=r,(e!==0||a&3)&&($u=!0)),r=i}nu!==0&&nu!==5||rd(e,!1),td!==0&&(td=0)}function od(e,t){for(var n=e.suspendedLanes,r=e.pingedLanes,i=e.expirationTimes,a=e.pendingLanes&-62914561;0s)break;var u=c.transferSize,d=c.initiatorType;u&&Id(d)&&(c=c.responseEnd,o+=u*(c`u`?null:document;function xf(e,t,n){var r=bf;if(r&&typeof t==`string`&&t){var i=Vt(t);i=`link[rel="`+e+`"][href="`+i+`"]`,typeof n==`string`&&(i+=`[crossorigin="`+n+`"]`),hf.has(i)||(hf.add(i),e={rel:e,crossOrigin:n,href:t},r.querySelector(i)===null&&(t=r.createElement(`link`),Pd(t,`link`,e),Ct(t),r.head.appendChild(t)))}}function Sf(e){_f.D(e),xf(`dns-prefetch`,e,null)}function Cf(e,t){_f.C(e,t),xf(`preconnect`,e,t)}function wf(e,t,n){_f.L(e,t,n);var r=bf;if(r&&e&&t){var i=`link[rel="preload"][as="`+Vt(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Vt(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Vt(n.imageSizes)+`"]`)):i+=`[href="`+Vt(e)+`"]`;var a=i;switch(t){case`style`:a=Af(e);break;case`script`:a=Pf(e)}mf.has(a)||(e=h({rel:`preload`,href:t===`image`&&n&&n.imageSrcSet?void 0:e,as:t},n),mf.set(a,e),r.querySelector(i)!==null||t===`style`&&r.querySelector(jf(a))||t===`script`&&r.querySelector(Ff(a))||(t=r.createElement(`link`),Pd(t,`link`,e),Ct(t),r.head.appendChild(t)))}}function Tf(e,t){_f.m(e,t);var n=bf;if(n&&e){var r=t&&typeof t.as==`string`?t.as:`script`,i=`link[rel="modulepreload"][as="`+Vt(r)+`"][href="`+Vt(e)+`"]`,a=i;switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:a=Pf(e)}if(!mf.has(a)&&(e=h({rel:`modulepreload`,href:e},t),mf.set(a,e),n.querySelector(i)===null)){switch(r){case`audioworklet`:case`paintworklet`:case`serviceworker`:case`sharedworker`:case`worker`:case`script`:if(n.querySelector(Ff(a)))return}r=n.createElement(`link`),Pd(r,`link`,e),Ct(r),n.head.appendChild(r)}}}function Ef(e,t,n){_f.S(e,t,n);var r=bf;if(r&&e){var i=P(r).hoistableStyles,a=Af(e);t||=`default`;var o=i.get(a);if(!o){var s={loading:0,preload:null};if(o=r.querySelector(jf(a)))s.loading=5;else{e=h({rel:`stylesheet`,href:e,"data-precedence":t},n),(n=mf.get(a))&&Rf(e,n);var c=o=r.createElement(`link`);Ct(c),Pd(c,`link`,e),c._p=new Promise(function(e,t){c.onload=e,c.onerror=t}),c.addEventListener(`load`,function(){s.loading|=1}),c.addEventListener(`error`,function(){s.loading|=2}),s.loading|=4,Lf(o,t,r)}o={type:`stylesheet`,instance:o,count:1,state:s},i.set(a,o)}}}function Df(e,t){_f.X(e,t);var n=bf;if(n&&e){var r=P(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),Ct(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function Of(e,t){_f.M(e,t);var n=bf;if(n&&e){var r=P(n).hoistableScripts,i=Pf(e),a=r.get(i);a||(a=n.querySelector(Ff(i)),a||(e=h({src:e,async:!0,type:`module`},t),(t=mf.get(i))&&zf(e,t),a=n.createElement(`script`),Ct(a),Pd(a,`link`,e),n.head.appendChild(a)),a={type:`script`,instance:a,count:1,state:null},r.set(i,a))}}function kf(e,t,n,r){var a=(a=he.current)?gf(a):null;if(!a)throw Error(i(446));switch(e){case`meta`:case`title`:return null;case`style`:return typeof n.precedence==`string`&&typeof n.href==`string`?(t=Af(n.href),n=P(a).hoistableStyles,r=n.get(t),r||(r={type:`style`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};case`link`:if(n.rel===`stylesheet`&&typeof n.href==`string`&&typeof n.precedence==`string`){e=Af(n.href);var o=P(a).hoistableStyles,s=o.get(e);if(s||(a=a.ownerDocument||a,s={type:`stylesheet`,instance:null,count:0,state:{loading:0,preload:null}},o.set(e,s),(o=a.querySelector(jf(e)))&&!o._p&&(s.instance=o,s.state.loading=5),mf.has(e)||(n={rel:`preload`,as:`style`,href:n.href,crossOrigin:n.crossOrigin,integrity:n.integrity,media:n.media,hrefLang:n.hrefLang,referrerPolicy:n.referrerPolicy},mf.set(e,n),o||Nf(a,e,n,s.state))),t&&r===null)throw Error(i(528,``));return s}if(t&&r!==null)throw Error(i(529,``));return null;case`script`:return t=n.async,n=n.src,typeof n==`string`&&t&&typeof t!=`function`&&typeof t!=`symbol`?(t=Pf(n),n=P(a).hoistableScripts,r=n.get(t),r||(r={type:`script`,instance:null,count:0,state:null},n.set(t,r)),r):{type:`void`,instance:null,count:0,state:null};default:throw Error(i(444,e))}}function Af(e){return`href="`+Vt(e)+`"`}function jf(e){return`link[rel="stylesheet"][`+e+`]`}function Mf(e){return h({},e,{"data-precedence":e.precedence,precedence:null})}function Nf(e,t,n,r){e.querySelector(`link[rel="preload"][as="style"][`+t+`]`)?r.loading=1:(t=e.createElement(`link`),r.preload=t,t.addEventListener(`load`,function(){return r.loading|=1}),t.addEventListener(`error`,function(){return r.loading|=2}),Pd(t,`link`,n),Ct(t),e.head.appendChild(t))}function Pf(e){return`[src="`+Vt(e)+`"]`}function Ff(e){return`script[async]`+e}function If(e,t,n){if(t.count++,t.instance===null)switch(t.type){case`style`:var r=e.querySelector(`style[data-href~="`+Vt(n.href)+`"]`);if(r)return t.instance=r,Ct(r),r;var a=h({},n,{"data-href":n.href,"data-precedence":n.precedence,href:null,precedence:null});return r=(e.ownerDocument||e).createElement(`style`),Ct(r),Pd(r,`style`,a),Lf(r,n.precedence,e),t.instance=r;case`stylesheet`:a=Af(n.href);var o=e.querySelector(jf(a));if(o)return t.state.loading|=4,t.instance=o,Ct(o),o;r=Mf(n),(a=mf.get(a))&&Rf(r,a),o=(e.ownerDocument||e).createElement(`link`),Ct(o);var s=o;return s._p=new Promise(function(e,t){s.onload=e,s.onerror=t}),Pd(o,`link`,r),t.state.loading|=4,Lf(o,n.precedence,e),t.instance=o;case`script`:return o=Pf(n.src),(a=e.querySelector(Ff(o)))?(t.instance=a,Ct(a),a):(r=n,(a=mf.get(o))&&(r=h({},n),zf(r,a)),e=e.ownerDocument||e,a=e.createElement(`script`),Ct(a),Pd(a,`link`,r),e.head.appendChild(a),t.instance=a);case`void`:return null;default:throw Error(i(443,t.type))}else t.type===`stylesheet`&&!(t.state.loading&4)&&(r=t.instance,t.state.loading|=4,Lf(r,n.precedence,e));return t.instance}function Lf(e,t,n){for(var r=n.querySelectorAll(`link[rel="stylesheet"][data-precedence],style[data-precedence]`),i=r.length?r[r.length-1]:null,a=i,o=0;o title`):null)}function Uf(e,t,n){if(n===1||t.itemProp!=null)return!1;switch(e){case`meta`:case`title`:return!0;case`style`:if(typeof t.precedence!=`string`||typeof t.href!=`string`||t.href===``)break;return!0;case`link`:if(typeof t.rel!=`string`||typeof t.href!=`string`||t.href===``||t.onLoad||t.onError)break;switch(t.rel){case`stylesheet`:return e=t.disabled,typeof t.precedence==`string`&&e==null;default:return!0}case`script`:if(t.async&&typeof t.async!=`function`&&typeof t.async!=`symbol`&&!t.onLoad&&!t.onError&&t.src&&typeof t.src==`string`)return!0}return!1}function Wf(e){return!(e.type===`stylesheet`&&!(e.state.loading&3))}function Gf(e,t,n,r){if(n.type===`stylesheet`&&(typeof r.media!=`string`||!1!==matchMedia(r.media).matches)&&!(n.state.loading&4)){if(n.instance===null){var i=Af(r.href),a=t.querySelector(jf(i));if(a){t=a._p,typeof t==`object`&&t&&typeof t.then==`function`&&(e.count++,e=Jf.bind(e),t.then(e,e)),n.state.loading|=4,n.instance=a,Ct(a);return}a=t.ownerDocument||t,r=Mf(r),(i=mf.get(i))&&Rf(r,i),a=a.createElement(`link`),Ct(a);var o=a;o._p=new Promise(function(e,t){o.onload=e,o.onerror=t}),Pd(a,`link`,r),n.instance=a}e.stylesheets===null&&(e.stylesheets=new Map),e.stylesheets.set(n,t),(t=n.state.preload)&&!(n.state.loading&3)&&(e.count++,n=Jf.bind(e),t.addEventListener(`load`,n),t.addEventListener(`error`,n))}}var Kf=0;function qf(e,t){return e.stylesheets&&e.count===0&&Xf(e,e.stylesheets),0Kf?50:800)+t);return e.unsuspend=n,function(){e.unsuspend=null,clearTimeout(r),clearTimeout(i)}}:null}function Jf(){if(this.count--,this.count===0&&(this.imgCount===0||!this.waitingForImages)){if(this.stylesheets)Xf(this,this.stylesheets);else if(this.unsuspend){var e=this.unsuspend;this.unsuspend=null,e()}}}var Yf=null;function Xf(e,t){e.stylesheets=null,e.unsuspend!==null&&(e.count++,Yf=new Map,t.forEach(Zf,e),Yf=null,Jf.call(e))}function Zf(e,t){if(!(t.state.loading&4)){var n=Yf.get(e);if(n)var r=n.get(null);else{n=new Map,Yf.set(e,n);for(var i=e.querySelectorAll(`link[data-precedence],style[data-precedence]`),a=0;a{function n(){if(!(typeof __REACT_DEVTOOLS_GLOBAL_HOOK__>`u`||typeof __REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE!=`function`))try{__REACT_DEVTOOLS_GLOBAL_HOOK__.checkDCE(n)}catch(e){console.error(e)}}n(),t.exports=h()})),_=c(u(),1),v=g(),y=class{baseUrl;actorUserId;constructor(e){this.baseUrl=e.baseUrl.replace(/\/$/,``),this.actorUserId=e.actorUserId.trim()}async login(e){return this.post(`/auth/login`,{email:e.email,password:e.password,device_fingerprint:b(),device_label:e.deviceLabel,trust_device:e.trustDevice})}async refresh(e){return this.post(`/auth/refresh`,{refresh_token:e.refreshToken})}async getInstallationStatus(){return(await this.request(`/installation/status`,{method:`GET`})).installation}async bootstrapOwner(e){return this.post(`/installation/bootstrap-owner`,{email:e.email,password:e.password,activation_payload:e.activationPayload,activation_signature:e.activationSignature||``})}async revokeAuthSession(e){await this.post(`/auth/sessions/revoke`,{user_id:e.userId,auth_session_id:e.authSessionId,reason:e.reason})}async listClusters(){return(await this.get(`/clusters`)).clusters??[]}async createCluster(e){return(await this.post(`/clusters/`,{actor_user_id:this.actorUserId,slug:e.slug,name:e.name,region:e.region||null,metadata:{}})).cluster}async updateCluster(e,t){return(await this.put(`/clusters/${e}`,{actor_user_id:this.actorUserId,name:t.name,status:t.status,region:t.region||null,metadata:t.metadata||{}})).cluster}async listClusterSummaries(){return(await this.get(`/cluster-admin-summaries`)).cluster_summaries??[]}async getClusterAuthority(e){return(await this.get(`/clusters/${e}/authority`)).authority_state}async updateClusterAuthority(e,t){return(await this.put(`/clusters/${e}/authority`,{actor_user_id:this.actorUserId,authority_state:t.authorityState,mutation_mode:t.mutationMode,notes:t.notes||null})).authority_state}async listNodes(e){return(await this.get(`/clusters/${e}/nodes`)).nodes??[]}async listNodeGroups(e){return(await this.get(`/clusters/${e}/node-groups`)).node_groups??[]}async createNodeGroup(e,t){return(await this.post(`/clusters/${e}/node-groups`,{actor_user_id:this.actorUserId,parent_group_id:t.parentGroupId||null,name:t.name,description:t.description||null,sort_order:t.sortOrder||0,metadata:{}})).node_group}async assignNodeGroup(e,t,n){return(await this.put(`/clusters/${e}/nodes/${t}/group`,{actor_user_id:this.actorUserId,group_id:n||null})).node}async disableMembership(e,t,n){await this.post(`/clusters/${e}/nodes/${t}/membership/disable`,{actor_user_id:this.actorUserId,reason:n})}async attachExistingNode(e,t,n){return(await this.post(`/clusters/${e}/nodes/${t}/membership/attach`,{actor_user_id:this.actorUserId,roles:n})).node}async revokeNodeIdentity(e,t,n){await this.post(`/clusters/${e}/nodes/${t}/identity/revoke`,{actor_user_id:this.actorUserId,reason:n})}async deleteClusterNode(e,t,n){await this.delete(`/clusters/${e}/nodes/${t}`,{actor_user_id:this.actorUserId,reason:n})}async listJoinRequests(e){return(await this.get(`/clusters/${e}/join-requests`)).join_requests??[]}async createJoinToken(e,t){let n=new Date(Date.now()+Math.max(t.ttlHours,1)*60*60*1e3).toISOString();return(await this.post(`/clusters/${e}/join-tokens`,{actor_user_id:this.actorUserId,scope:t.scope,expires_at:n,max_uses:Math.max(t.maxUses,1)})).join_token}async listJoinTokens(e){return(await this.get(`/clusters/${e}/join-tokens`)).join_tokens??[]}async revokeJoinToken(e,t){return(await this.post(`/clusters/${e}/join-tokens/${t}/revoke`,{actor_user_id:this.actorUserId})).join_token}async approveJoinRequest(e,t){await this.post(`/clusters/${e}/join-requests/${t}/approve`,{actor_user_id:this.actorUserId,ownership_type:`platform_managed`})}async rejectJoinRequest(e,t,n){await this.post(`/clusters/${e}/join-requests/${t}/reject`,{actor_user_id:this.actorUserId,reason:n})}async listNodeRoles(e,t){return(await this.get(`/clusters/${e}/nodes/${t}/roles`)).role_assignments??[]}async assignRole(e,t,n,r){await this.setRoleStatus(e,t,n,`active`,r)}async setRoleStatus(e,t,n,r,i){await this.post(`/clusters/${e}/nodes/${t}/roles`,{actor_user_id:this.actorUserId,organization_id:i||null,role:n,status:r,policy:{}})}async listWorkloadStatuses(e,t){return(await this.get(`/clusters/${e}/nodes/${t}/workloads/status`)).workload_statuses??[]}async listDesiredWorkloads(e,t){return(await this.get(`/clusters/${e}/nodes/${t}/workloads/desired`)).desired_workloads??[]}async listNodeHeartbeats(e,t,n=100){return(await this.get(`/clusters/${e}/nodes/${t}/heartbeats?limit=${n}`)).heartbeats??[]}async listNodeTelemetry(e,t,n=120){return(await this.get(`/clusters/${e}/nodes/${t}/telemetry?limit=${n}`)).telemetry??[]}async listReleaseVersions(e,t=`rap-node-agent`,n=`dev`){let r=new URLSearchParams({product:t,channel:n});return(await this.get(`/clusters/${e}/updates/releases?${r.toString()}`)).release_versions??[]}async getNodeUpdatePlan(e,t,n){let r=new URLSearchParams({product:n.product||`rap-node-agent`,current_version:n.currentVersion||``,os:n.os||`linux`,arch:n.arch||`amd64`,install_type:n.installType||`docker`,channel:n.channel||`dev`});return(await this.get(`/clusters/${e}/nodes/${t}/updates/plan?${r.toString()}`)).node_update_plan}async upsertNodeUpdatePolicy(e,t,n){return(await this.put(`/clusters/${e}/nodes/${t}/updates/policy`,{actor_user_id:this.actorUserId,product:n.product,channel:n.channel||`dev`,target_version:n.targetVersion??null,strategy:n.strategy||`rolling`,enabled:n.enabled??!0,rollback_allowed:n.rollbackAllowed??!0,health_window_seconds:n.healthWindowSeconds||180})).node_update_policy}async listNodeUpdateStatuses(e,t,n=80){let r=new URLSearchParams({actor_user_id:this.actorUserId,limit:String(n)});return(await this.get(`/clusters/${e}/nodes/${t}/updates/statuses?${r.toString()}`)).node_update_statuses??[]}async listFabricTestingFlags(){return(await this.get(`/fabric/testing-flags`)).testing_flags??[]}async updateFabricTestingFlag(e){return(await this.put(`/fabric/testing-flags`,{actor_user_id:this.actorUserId,scope_type:e.scopeType,scope_id:e.scopeId||null,cluster_id:e.clusterId||null,enabled:e.enabled,telemetry_enabled:e.telemetryEnabled,synthetic_links_enabled:e.syntheticLinksEnabled,history_retention_hours:e.historyRetentionHours,metadata:e.metadata||{}})).testing_flag}async setDesiredWorkload(e,t,n,r){await this.put(`/clusters/${e}/nodes/${t}/workloads/${n}/desired`,{actor_user_id:this.actorUserId,desired_state:r.desiredState,version:r.version||null,runtime_mode:r.runtimeMode,artifact_ref:null,config:r.config,environment:r.environment})}async listMeshLinks(e){return(await this.get(`/clusters/${e}/mesh/links`)).mesh_links??[]}async listRouteIntents(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/mesh/route-intents?${t.toString()}`)).route_intents??[]}async expireRouteIntent(e,t,n){return(await this.post(`/clusters/${e}/mesh/route-intents/${t}/expire`,{actor_user_id:this.actorUserId,reason:n})).route_intent}async disableRouteIntent(e,t,n){return(await this.post(`/clusters/${e}/mesh/route-intents/${t}/disable`,{actor_user_id:this.actorUserId,reason:n})).route_intent}async getNodeSyntheticMeshConfig(e,t){return(await this.get(`/clusters/${e}/nodes/${t}/mesh/synthetic-config`)).synthetic_mesh_config}async listFabricServiceChannelRouteFeedback(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.reporterNodeId&&n.set(`reporter_node_id`,t.reporterNodeId),t.routeId&&n.set(`route_id`,t.routeId),t.serviceClass&&n.set(`service_class`,t.serviceClass),t.feedbackStatus&&n.set(`feedback_status`,t.feedbackStatus),t.includeExpired&&n.set(`include_expired`,`true`),(await this.get(`/clusters/${e}/fabric/service-channels/route-feedback?${n.toString()}`)).route_feedback??[]}async expireFabricServiceChannelRouteFeedback(e,t){return(await this.post(`/clusters/${e}/fabric/service-channels/route-feedback/expire`,{actor_user_id:this.actorUserId,route_id:t.routeId,reporter_node_id:t.reporterNodeId||``,service_class:t.serviceClass||``,reason:t.reason||`expired from admin fabric diagnostics`})).route_feedback_expire}async listFabricServiceChannelRouteRebuildAttempts(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.reporterNodeId&&n.set(`reporter_node_id`,t.reporterNodeId),t.routeId&&n.set(`route_id`,t.routeId),t.replacementRouteId&&n.set(`replacement_route_id`,t.replacementRouteId),t.serviceClass&&n.set(`service_class`,t.serviceClass),t.rebuildStatus&&n.set(`rebuild_status`,t.rebuildStatus),t.rebuildRequestId&&n.set(`rebuild_request_id`,t.rebuildRequestId),t.generation&&n.set(`generation`,t.generation),t.feedbackSource&&n.set(`feedback_source`,t.feedbackSource),t.feedbackChannelId&&n.set(`feedback_channel_id`,t.feedbackChannelId),t.feedbackViolationStatus&&n.set(`feedback_violation_status`,t.feedbackViolationStatus),t.enrichment&&n.set(`enrichment`,t.enrichment),t.limit&&n.set(`limit`,String(t.limit)),t.offset&&n.set(`offset`,String(t.offset)),(await this.get(`/clusters/${e}/fabric/service-channels/rebuild-attempts?${n.toString()}`)).rebuild_attempts??[]}async getFabricServiceChannelRouteRebuildHealthSummary(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),(await this.get(`/clusters/${e}/fabric/service-channels/rebuild-health?${n.toString()}`)).rebuild_health}async getFabricServiceChannelReadiness(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),(await this.get(`/clusters/${e}/fabric/service-channels/readiness?${n.toString()}`)).fabric_service_channel_readiness}async getFabricServiceChannelSchemaStatus(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/fabric/service-channels/schema-status?${t.toString()}`)).fabric_service_channel_schema_status}async getFabricServiceChannelRebuildSnapshotMaintenanceHealth(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),t.minAgeSeconds&&n.set(`min_age_seconds`,String(t.minAgeSeconds)),t.heartbeatThreshold&&n.set(`heartbeat_threshold`,String(t.heartbeatThreshold)),(await this.get(`/clusters/${e}/fabric/service-channels/rebuild-snapshots/health?${n.toString()}`)).rebuild_snapshot_health}async warmupFabricServiceChannelRebuildSnapshots(e,t={}){return(await this.post(`/clusters/${e}/fabric/service-channels/rebuild-snapshots/warmup`,{actor_user_id:this.actorUserId,limit:t.limit||10,stale_after_seconds:t.staleAfterSeconds||60})).rebuild_snapshot_warmup}async getFabricServiceChannelLeaseMaintenance(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),t.includeExpired&&n.set(`include_expired`,`true`),(await this.get(`/clusters/${e}/fabric/service-channels/leases?${n.toString()}`)).fabric_service_channel_lease_maintenance}async cleanupFabricServiceChannelLeases(e,t={}){return(await this.post(`/clusters/${e}/fabric/service-channels/leases/cleanup`,{actor_user_id:this.actorUserId,limit:t.limit||100})).fabric_service_channel_lease_maintenance}async getFabricServiceChannelAccessTelemetry(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),(await this.get(`/clusters/${e}/fabric/service-channels/access-telemetry?${n.toString()}`)).fabric_service_channel_access_telemetry}async listFabricServiceChannelRouteRebuildIncidents(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),(await this.get(`/clusters/${e}/fabric/service-channels/rebuild-incidents?${n.toString()}`)).rebuild_incidents??[]}async getFabricServiceChannelRebuildInvestigationBreadcrumbs(e,t={}){let n=new URLSearchParams({actor_user_id:this.actorUserId});return t.limit&&n.set(`limit`,String(t.limit)),t.currentWindowSeconds&&n.set(`current_window_seconds`,String(t.currentWindowSeconds)),t.historyWindowSeconds&&n.set(`history_window_seconds`,String(t.historyWindowSeconds)),(await this.get(`/clusters/${e}/fabric/service-channels/rebuild-investigations/breadcrumbs?${n.toString()}`)).rebuild_investigation_breadcrumbs}async recordFabricServiceChannelRouteRebuildInvestigation(e,t){await this.post(`/clusters/${e}/fabric/service-channels/rebuild-incidents/investigations`,{actor_user_id:this.actorUserId,reporter_node_id:t.reporterNodeId,route_id:t.routeId,service_class:t.serviceClass||``,generation:t.generation||``,guard_status:t.guardStatus||``,incident_id:t.incidentId||``,feedback_source:t.feedbackSource||``,feedback_channel_id:t.feedbackChannelId||``,feedback_violation_status:t.feedbackViolationStatus||``,drilldown_source:t.drilldownSource||``,reason:t.reason||`operator opened deep rebuild ledger`})}async silenceFabricServiceChannelRouteRebuildAlert(e,t){return(await this.post(`/clusters/${e}/fabric/service-channels/rebuild-health/silences`,{actor_user_id:this.actorUserId,incident_source:t.incidentSource||``,channel_id:t.channelId||``,reporter_node_id:t.reporterNodeId,route_id:t.routeId,guard_status:t.guardStatus,generation:t.generation||``,reason:t.reason||`operator acknowledged rebuild alert`,ttl_seconds:t.ttlSeconds||21600})).rebuild_alert_silence}async listFabricServiceChannelRouteRebuildAlertSilences(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/fabric/service-channels/rebuild-health/silences?${t.toString()}`)).rebuild_alert_silences??[]}async unsilenceFabricServiceChannelRouteRebuildAlert(e,t,n){return(await this.delete(`/clusters/${e}/fabric/service-channels/rebuild-health/silences/${encodeURIComponent(t)}`,{actor_user_id:this.actorUserId,reason:n||`operator removed rebuild alert silence`})).rebuild_alert_silence}async getFabricServiceChannelRecoveryPolicy(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/fabric/service-channels/recovery-policy?${t.toString()}`)).fabric_service_channel_recovery_policy}async updateFabricServiceChannelRecoveryPolicy(e,t){return(await this.put(`/clusters/${e}/fabric/service-channels/recovery-policy`,{actor_user_id:this.actorUserId,hysteresis_penalty:t.hysteresisPenalty,promotion_min_samples:t.promotionMinSamples,demotion_failure_threshold:t.demotionFailureThreshold,demotion_drop_threshold:t.demotionDropThreshold,demotion_slow_threshold:t.demotionSlowThreshold,demotion_rebuild_enabled:t.demotionRebuildEnabled,demotion_fenced_enabled:t.demotionFencedEnabled})).fabric_service_channel_recovery_policy}async getFabricServiceChannelAdaptivePolicy(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/fabric/service-channels/adaptive-policy?${t.toString()}`)).fabric_service_channel_adaptive_policy}async updateFabricServiceChannelAdaptivePolicy(e,t){return(await this.put(`/clusters/${e}/fabric/service-channels/adaptive-policy`,{actor_user_id:this.actorUserId,max_parallel_window:t.maxParallelWindow,bulk_pressure_channel_threshold:t.bulkPressureChannelThreshold,queue_pressure_high_watermark:t.queuePressureHighWatermark,queue_pressure_max_in_flight:t.queuePressureMaxInFlight,class_windows:t.classWindows})).fabric_service_channel_adaptive_policy}async getFabricServiceChannelPoolPolicy(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/fabric/service-channels/pool-policy?${t.toString()}`)).fabric_service_channel_pool_policy}async updateFabricServiceChannelPoolPolicy(e,t){return(await this.put(`/clusters/${e}/fabric/service-channels/pool-policy`,{actor_user_id:this.actorUserId,entry_pool_node_ids:t.entryPoolNodeIds,exit_pool_node_ids:t.exitPoolNodeIds,preferred_entry_node_id:t.preferredEntryNodeId,preferred_exit_node_id:t.preferredExitNodeId,selection_strategy:t.selectionStrategy,route_rebuild:t.routeRebuild,entry_failover:t.entryFailover,exit_failover:t.exitFailover,backend_fallback_allowed:t.backendFallbackAllowed,sticky_session:t.stickySession})).fabric_service_channel_pool_policy}async getFabricServiceChannelBreadcrumbWindowPolicy(e){let t=new URLSearchParams({actor_user_id:this.actorUserId});return(await this.get(`/clusters/${e}/fabric/service-channels/breadcrumb-window-policy?${t.toString()}`)).fabric_service_channel_breadcrumb_window_policy}async updateFabricServiceChannelBreadcrumbWindowPolicy(e,t){return(await this.put(`/clusters/${e}/fabric/service-channels/breadcrumb-window-policy`,{actor_user_id:this.actorUserId,current_window_seconds:t.currentWindowSeconds,history_window_seconds:t.historyWindowSeconds})).fabric_service_channel_breadcrumb_window_policy}async listQoSPolicies(e){return(await this.get(`/clusters/${e}/mesh/qos-policies`)).qos_policies??[]}async listVPNConnections(e){return(await this.get(`/clusters/${e}/vpn-connections`)).vpn_connections??[]}async createVPNConnection(e,t){return(await this.post(`/clusters/${e}/vpn-connections`,{actor_user_id:this.actorUserId,organization_id:t.organizationId,name:t.name,target_endpoint:t.targetEndpoint,protocol_family:t.protocolFamily,credential_ref:t.credentialRef||null,mode:`single_active`,desired_state:t.desiredState,allowed_node_policy:t.allowedNodePolicy,routing_usage:t.routingUsage,route_policy:t.routePolicy,qos_policy:t.qosPolicy,placement_policy:t.placementPolicy,metadata:{}})).vpn_connection}async updateVPNConnectionDesiredState(e,t,n){return(await this.put(`/clusters/${e}/vpn-connections/${t}/desired-state`,{actor_user_id:this.actorUserId,desired_state:n})).vpn_connection}async getActiveVPNLease(e,t){try{return(await this.get(`/clusters/${e}/vpn-connections/${t}/leases/active`)).lease}catch{return null}}async getVPNPacketStats(e,t){return(await this.get(`/clusters/${e}/vpn-connections/${t}/tunnel/stats`)).vpn_packet_stats??{}}async getVPNClientDiagnosticStatus(e,t){if(!t.trim())return null;try{return(await this.get(`/clusters/${e}/vpn/client-diagnostics/${encodeURIComponent(t.trim())}/status`)).vpn_client_diagnostic_status??null}catch{return null}}async listVPNClientDiagnosticStatuses(e){return(await this.get(`/clusters/${e}/vpn/client-diagnostics`)).vpn_client_diagnostic_statuses??[]}async enqueueVPNClientDiagnosticCommand(e,t,n){return(await this.post(`/clusters/${e}/vpn/client-diagnostics/${encodeURIComponent(t.trim())}/commands`,n)).vpn_client_diagnostic_command}async expireStaleVPNLeases(e){return(await this.post(`/clusters/${e}/vpn-connection-leases/expire-stale`,{actor_user_id:this.actorUserId})).expired_leases??[]}async listAudit(e,t={}){return(await this.listAuditDetailed(e,t)).events}async listAuditDetailed(e,t={}){let n=new URLSearchParams({limit:String(t.limit||100)});for(let e of t.eventTypes||[])e&&n.append(`event_type`,e);for(let e of t.targetTypes||[])e&&n.append(`target_type`,e);t.correlation&&n.set(`correlation`,t.correlation);let r=await this.get(`/clusters/${e}/audit?${n.toString()}`);return{events:r.audit_events??[],summary:r.audit_summary}}clusterEventsURL(e){return`${this.baseUrl}/clusters/${encodeURIComponent(e)}/events?actor_user_id=${encodeURIComponent(this.actorUserId)}`}async getOrganizationAdminSummary(e){return(await this.get(`/organizations/${e}/admin-summary`)).admin_summary}async listOrganizations(){return(await this.request(`/organizations?user_id=${encodeURIComponent(this.actorUserId)}`,{method:`GET`})).organizations??[]}async createOrganization(e){return(await this.post(`/organizations/`,{actor_user_id:this.actorUserId,slug:e.slug,name:e.name,metadata:e.metadata||{}})).organization}async listUsers(){return(await this.get(`/users/`)).users??[]}async createUser(e){return(await this.post(`/users/`,{actor_user_id:this.actorUserId,email:e.email,password:e.password,platform_role:e.platformRole||`user`})).user}async listOrganizationMemberships(e){return(await this.request(`/organizations/${e}/memberships?user_id=${encodeURIComponent(this.actorUserId)}`,{method:`GET`})).memberships??[]}async addOrganizationMembership(e,t){return(await this.post(`/organizations/${e}/memberships`,{actor_user_id:this.actorUserId,user_id:t.userId,role_id:t.roleId})).membership}async listResources(e){let t=new URLSearchParams({user_id:this.actorUserId});return e&&t.set(`organization_id`,e),(await this.request(`/resources?${t.toString()}`,{method:`GET`})).resources??[]}async createResource(e){return(await this.post(`/resources/`,{actor_user_id:this.actorUserId,organization_id:e.organizationId,name:e.name,address:e.address,protocol:e.protocol||`rdp`,secret_ref:e.secretRef||null,certificate_verification_mode:e.certificateVerificationMode||`strict`,render_quality_profile:e.renderQualityProfile||`balanced`,clipboard_mode:e.clipboardMode||`disabled`,file_transfer_mode:e.fileTransferMode||`disabled`,metadata:e.metadata||{}})).resource}async upsertResourceSecret(e,t){await this.put(`/resources/${e}/secret`,{actor_user_id:this.actorUserId,payload:{username:t.username||``,password:t.password||``,domain:t.domain||``},metadata:{source:`web-admin`}})}async get(e){let t=e.includes(`?`)?`&`:`?`;return this.request(`${e}${t}actor_user_id=${encodeURIComponent(this.actorUserId)}`,{method:`GET`})}async post(e,t){return this.request(e,{method:`POST`,headers:{"Content-Type":`application/json`},body:JSON.stringify(t)})}async put(e,t){return this.request(e,{method:`PUT`,headers:{"Content-Type":`application/json`},body:JSON.stringify(t)})}async delete(e,t){return this.request(e,{method:`DELETE`,headers:{"Content-Type":`application/json`},body:JSON.stringify(t)})}async request(e,t){let n=await fetch(`${this.baseUrl}${e}`,t);if(!n.ok){let e=`Запрос завершился ошибкой HTTP ${n.status}`;try{let t=await n.json();e=t.error?.fallback_message||t.error?.code||e}catch{}throw Error(e)}return await n.json()}};function b(){let e=`rap.webAdmin.deviceFingerprint`,t=localStorage.getItem(e);if(t)return t;let n=`web-admin-${x()}`;return localStorage.setItem(e,n),n}function x(){if(typeof globalThis.crypto?.randomUUID==`function`)return globalThis.crypto.randomUUID();if(typeof globalThis.crypto?.getRandomValues==`function`){let e=new Uint8Array(16);globalThis.crypto.getRandomValues(e),e[6]=e[6]&15|64,e[8]=e[8]&63|128;let t=Array.from(e,e=>e.toString(16).padStart(2,`0`)).join(``);return`${t.slice(0,8)}-${t.slice(8,12)}-${t.slice(12,16)}-${t.slice(16,20)}-${t.slice(20)}`}return`${Date.now().toString(36)}-${Math.random().toString(36).slice(2)}`}var ee=o((e=>{var t=Symbol.for(`react.transitional.element`),n=Symbol.for(`react.fragment`);function r(e,n,r){var i=null;if(r!==void 0&&(i=``+r),n.key!==void 0&&(i=``+n.key),`key`in n)for(var a in r={},n)a!==`key`&&(r[a]=n[a]);else r=n;return n=r.ref,{$$typeof:t,type:e,key:i,ref:n===void 0?null:n,props:r}}e.Fragment=n,e.jsx=r,e.jsxs=r})),S=o(((e,t)=>{t.exports=ee()}))(),C={baseUrl:`rap.webAdmin.baseUrl`,actorUserId:`rap.webAdmin.actorUserId`,auth:`rap.webAdmin.auth`,language:`rap.webAdmin.language`,vpnDiagnosticDeviceId:`rap.webAdmin.vpnDiagnosticDeviceId`},te=`/api/v1`,ne=`http://192.168.200.61:8080/api/v1`,re={reporterNodeId:``,routeId:``,serviceClass:``,generation:``,feedbackSource:``,feedbackChannelId:``,feedbackViolationStatus:``,offset:0},ie=[`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`],w={"public-ingress":`Public HTTPS ingress`,"admin-ingress":`Admin HTTPS ingress`,"global-admin-runtime":`Global admin runtime`,"cluster-admin-runtime":`Cluster admin runtime`,"organization-portal-runtime":`Organization portal runtime`,"user-portal-runtime":`User portal runtime`,"identity-runtime":`Identity runtime`,"policy-authority":`Policy authority`,"audit-sink":`Audit sink`,"entry-node":`Entry node`,"relay-node":`Relay node`,"core-mesh":`Mesh core`,"rdp-worker":`RDP worker`,"vnc-worker":`VNC worker`,"vpn-exit":`VPN exit`,"vpn-connector":`VPN connector`,"vpn-client":`VPN client node`,"ipv4-egress":`IPv4 egress`,"file-storage-cache":`File/cache storage`,"update-cache":`Update cache`,"video-relay":`Video relay`},ae={"public-ingress":[`can_accept_client_ingress`,`fabric_service_channel_runtime`],"admin-ingress":[`can_accept_client_ingress`,`fabric_service_channel_runtime`],"global-admin-runtime":[`can_run_admin_runtime`,`platform_owner_trusted_node`],"cluster-admin-runtime":[`can_run_admin_runtime`],"organization-portal-runtime":[`can_run_admin_runtime`],"user-portal-runtime":[`can_run_admin_runtime`],"identity-runtime":[`can_run_identity_runtime`],"policy-authority":[`can_run_policy_authority`,`platform_owner_trusted_node`],"audit-sink":[`can_run_audit_sink`,`platform_owner_trusted_node`],"entry-node":[`can_accept_client_ingress`],"relay-node":[`mesh_rendezvous_relay_control_contract`,`mesh_peer_connection_manager`],"core-mesh":[`native_node_agent`,`mesh_peer_connection_manager`,`mesh_listener_diagnostics`],"rdp-worker":[`can_run_rdp_worker`],"vnc-worker":[`can_run_vnc_worker`],"vpn-exit":[`can_run_vpn_exit`],"vpn-connector":[`can_run_vpn_connector`],"vpn-client":[`can_run_vpn_client`,`fabric_service_channel_required`],"ipv4-egress":[`can_egress_internet`,`fabric_service_channel_required`],"file-storage-cache":[`can_run_file_cache`],"update-cache":[`can_run_update_cache`],"video-relay":[`can_run_video_relay`]},oe=[{id:`command`,ru:`Обзор`,en:`Command`},{id:`clusters`,ru:`Кластеры`,en:`Clusters`},{id:`cluster-settings`,ru:`Настройки кластера`,en:`Cluster Settings`},{id:`nodes`,ru:`Узлы`,en:`Nodes`},{id:`enrollment`,ru:`Новый узел`,en:`New Node`},{id:`roles`,ru:`Роли`,en:`Roles`},{id:`workloads`,ru:`Сервисы`,en:`Workloads`},{id:`fabric`,ru:`Связи Fabric`,en:`Fabric Links`},{id:`vpn`,ru:`VPN Control`,en:`VPN Control`},{id:`servers`,ru:`Серверы`,en:`Servers`},{id:`org-safe`,ru:`Организации`,en:`Organizations`},{id:`audit`,ru:`Аудит`,en:`Audit`}];function T(e){if(!e||typeof e!=`object`)return null;let t=e;return typeof t.userId!=`string`||typeof t.email!=`string`||typeof t.authSessionId!=`string`||typeof t.accessToken!=`string`||typeof t.refreshToken!=`string`||typeof t.accessTokenExpiresAt!=`string`||typeof t.refreshTokenExpiresAt!=`string`||!t.userId||!t.refreshToken?null:{userId:t.userId,email:t.email,authSessionId:t.authSessionId,accessToken:t.accessToken,refreshToken:t.refreshToken,accessTokenExpiresAt:t.accessTokenExpiresAt,refreshTokenExpiresAt:t.refreshTokenExpiresAt}}function se(e){let t=Date.parse(e);return!Number.isFinite(t)||t<=Date.now()}function ce(){try{let e=localStorage.getItem(C.auth);if(!e)return null;let t=T(JSON.parse(e));return!t||se(t.refreshTokenExpiresAt)?null:t}catch{return null}}var le={ttlHours:24,maxUses:1,roles:[`core-mesh`],nodeName:``,nodeGroupId:``,ownershipType:`platform_managed`,purpose:``,installMode:`docker`,dockerImage:`rap-node-agent:dev-enrollment-bootstrap-smoke`,dockerContainerName:``,dockerNetwork:`host`,windowsStartupMode:`auto`,windowsInstallDir:``,windowsNodeAgentSHA256:``,linuxInstallDir:``,linuxNodeAgentSHA256:``,meshListenAddr:`:19131`,meshListenPortMode:`auto`,meshListenAutoPortStart:19131,meshListenAutoPortEnd:19231,meshAdvertiseEndpoint:``,meshAdvertiseTransport:`direct_http`,meshConnectivityMode:`private_lan`,meshNATType:`none`,meshRegion:`docker-test`,controlPlaneEndpoint:``,artifactEndpoints:``,dockerImageArtifactSHA256:``,pullImage:!1,replace:!0,syntheticRuntime:!0},E={ru:{productOwner:`Владелец продукта`,controlPlane:`Панель управления`,sideText:`Главная панель владельца платформы для кластеров, узлов, доверия, ролей и безопасного desired state.`,signInTitle:`Вход`,signInText:`Введите учетные данные.`,bootstrapTitle:`Первый владелец`,bootstrapText:`Пустая установка принимает только подписанную активацию продукта.`,activationPayload:`Activation manifest JSON`,activationSignature:`Подпись manifest`,createOwner:`Создать владельца`,creatingOwner:`Создание...`,ownerCreated:`Владелец создан. Теперь можно войти.`,installationLocked:`Установка уже активирована`,insecureBootstrapDisabled:`Insecure bootstrap выключен. Нужна strict-активация с ключом продукта.`,email:`Логин`,password:`Пароль`,backendApi:`Backend API`,useLocalProxy:`Использовать локальный /api/v1 proxy`,language:`Язык`,deviceLabel:`Устройство`,rememberMe:`Запомнить меня`,trustDevice:`Доверять этому устройству`,signIn:`Войти`,signingIn:`Вход...`,logout:`Выйти`,profile:`Профиль`,refresh:`Обновить`,refreshing:`Обновление...`,autoRefresh:`Автообновление`,lastRefresh:`Данные обновлены`,activeCluster:`Активный кластер`,slugLabel:`Технический код`,slugHelp:`Slug — постоянный короткий технический идентификатор кластера для URL, скриптов, логов и интеграций. Его лучше не менять после создания.`,clusterCatalog:`Каталог кластеров`,clusterCatalogText:`Список реальных кластеров из Control Plane. Выберите активный кластер или раскройте карточку для подробностей.`,makeActive:`Сделать активным`,openSettings:`Открыть настройки`,selected:`Выбран`,createCluster:`Создать кластер`,clusterDetails:`Подробнее`,consoleTitle:`Панель владельца платформы`,boundary:`WEB является только представлением. Решения кластера проходят через Control Plane API, PostgreSQL как source of truth и аудит.`,noLoginError:`Войдите как владелец продукта или администратор платформы, чтобы загрузить панель.`,accessDenied:`Доступ к этой панели запрещен.`,sessionMode:`Режим сессии`,sessionModeAdmin:`Админ`,sessionModeUser:`Пользователь`,sessionRefreshedAt:`Сессия обновлена`,emptyLiveTitle:`Кластер пока пустой`,emptyLiveText:`Это реальные данные, не заглушка: в выбранном кластере ещё нет одобренных node-agent узлов. Создайте join token, запустите rap-node-agent и подтвердите join request.`,realDataNote:`Показываются только данные из PostgreSQL/Control Plane. Если значения нулевые, значит соответствующих узлов, ролей или сервисов пока нет.`,signedInAs:`Вход выполнен`,actorUser:`Actor user`,testMode:`Тестирование`,testModeText:`Включает тестовую телеметрию и синтетические наблюдения связей. Это не production mesh runtime.`,platformTestFlag:`Тестирование сервера`,nodeTelemetry:`Телеметрия узла`,heartbeatHistory:`История heartbeat`,noTelemetry:`Телеметрии пока нет`,enableTelemetry:`Включить телеметрию`,enableSyntheticLinks:`Включить тестовые связи`,saveTestFlag:`Сохранить флаг`,nodeManagement:`Управление узлом`,nodeScope:`Область просмотра`,currentClusterNodes:`Узлы активного кластера`,allNodes:`Все узлы платформы`,showAllPlatformNodes:`Показать все узлы платформы`,currentClusterMembership:`Участие в активном кластере`,clusterMemberships:`Участие по кластерам`,notMemberOfActiveCluster:`не состоит`,nodeIdentity:`Физическая идентичность узла`,activeClusterScope:`Область активного кластера`,activeClusterScopeText:`Один физический узел может состоять в нескольких кластерах. Роли и desired-сервисы ниже относятся только к выбранному активному кластеру.`,capabilityConfirmed:`способность подтверждена heartbeat`,capabilityMissing:`способность не заявлена узлом`,capabilityUnknown:`способность не подтверждена: нет heartbeat`,nodeGlobalInventoryText:`Один физический узел показан один раз. Участие и роли остаются кластерными: в разных кластерах этот же узел может иметь разные назначения.`,nodeSearch:`Поиск узлов`,groupNodesBy:`Группировать`,groupByMembership:`по участию`,groupByHealth:`по здоровью`,groupByOwnership:`по владению`,groupByClusterCount:`по числу кластеров`,nodeGroups:`Группы узлов`,nodeGroupTree:`Дерево групп`,nodeGroupFilter:`Фильтр по группе`,allNodeGroups:`Все группы`,nodeGroupCreatePanel:`Создание группы`,nodeGroupName:`Название группы`,parentNodeGroup:`Родительская группа`,rootNodeGroup:`Корень`,ungroupedNodes:`Без группы`,createNodeGroup:`Создать группу`,createSubgroup:`Создать подгруппу`,collapseGroup:`Свернуть`,expandGroup:`Развернуть`,assignNodeGroup:`Переместить в группу`,removeFromNodeGroup:`Убрать из группы`,connectExistingNode:`Подключить к активному кластеру`,connectExistingNodeTitle:`Подключить существующий узел`,connectExistingNodeText:`Будет создано или повторно включено участие конкретного физического узла в активном кластере. Роли ниже назначаются только в этом кластере.`,connectWithRoles:`Подключить с ролями`,nodeDetails:`Сведения`,manageNode:`Настроить`,nodeFunctions:`Функции узла`,nodeFunctionsText:`Одна строка управляет функцией целиком: роль задает разрешение в активном кластере, desired-сервис задает запуск, observed показывает факт от node-agent.`,rolePermission:`Разрешение`,permissionGranted:`разрешено`,permissionDenied:`нет разрешения`,organizationScopeForEnable:`Область организации для новых включений, опционально`,clusterWideRolePlaceholder:`пусто = роль на весь кластер`,desiredRuntime:`Желаемое состояние`,observedRuntime:`Фактически`,enableFunction:`Включить функцию`,disableFunction:`Выключить функцию`,close:`Закрыть`,nodeBriefList:`Краткий список узлов`,noActiveClusterMembership:`Узел не входит в активный кластер`,nodeBriefListHelp:`Список сгруппирован деревом активного кластера. Полные сведения, управление, роли, сервисы и статистика открываются из строки узла.`,nodeSearchPlaceholder:`имя, ключ, кластер, статус`,nodeGroupInventoryText:`Группы — это кластерная инвентарная структура. Перенос узла в группу меняет только его размещение внутри активного кластера, не роли и не членство.`,nodeGroupCreated:`Группа узлов создана.`,noNodesTitle:`Нет узлов`,noNodesByFilter:`По текущему фильтру узлы не найдены.`,cancel:`Отмена`,alreadyMember:`Уже в активном кластере`,revokedMembership:`Участие отозвано`,addNode:`Подключить узел`,addNodeText:`Подключение существующего физического узла к активному кластеру выполняется из списка узлов: включите общий режим и нажмите «Подключить к активному кластеру».`,joinTokenTitle:`Создать новый Docker-узел`,joinTokenText:`Сначала создается одноразовый install token и Docker install profile. Затем команда запускается на Docker-хосте, агент отправляет заявку, а владелец платформы подтверждает ее.`,ttlHours:`Срок действия, часов`,ttlHelp:`Через это время token станет недействительным, даже если им никто не воспользовался. Для ручного подключения обычно достаточно 1–24 часов.`,maxUses:`Максимум использований`,maxUsesHelp:`Сколько node-agent смогут использовать этот token. Самый безопасный вариант — 1 token на 1 новый узел.`,tokenPurpose:`Назначение token`,nodeOwnership:`Тип владения узлом`,suggestedRoles:`Разрешенные/ожидаемые роли`,generatedScope:`Сгенерированная область действия`,generatedScopeHelp:`JSON формируется автоматически из настроек выше. Оператор не должен писать его руками, чтобы не ошибиться синтаксисом или областью доступа.`,manualApprovalRequired:`Подтверждение заявки вручную обязательно`,nodeRoles:`Роли узла`,desiredServices:`Желаемые сервисы`,observedServices:`Наблюдаемые сервисы`,noRoles:`Ролей пока нет`,noServices:`Сервисов пока нет`,manageInCluster:`Управлять в кластере`,rolesAndServices:`Роли и сервисы`,links:`Связи`,fabricMap:`Карта трафика Fabric`,fabricNodeLayer:`Узлы кластера`,observedPeerLinks:`Наблюдаемые связи`,placementIntent:`control-plane назначение`,endpointName:`Название`,publicEndpoint:`Публичный адрес`,endpointType:`Тип входа`,description:`Описание`,routeScope:`Область маршрутов JSON`,endpointNodes:`Назначенные узлы`,assignEndpointNode:`Назначить узел`,selectNode:`Выберите узел`,assignedNodesEmpty:`Узлы пока не назначены`,addressNotSet:`адрес не задан`,descriptionNotSet:`описание не задано`,servicePlacement:`Размещение сервисов`,trafficFlow:`Потоки между узлами`,organizationTestFlag:`Тестирование организации`,organizationId:`ID организации`,saveOrganizationFlag:`Сохранить флаг организации`,noLinks:`Связей пока нет`,recentHeartbeats:`Последние heartbeat`,memory:`Память`,cpu:`Процессор`,processes:`Процессы`},en:{productOwner:`Product Owner`,controlPlane:`Control Plane`,sideText:`Full platform-owner panel for clusters, nodes, trust, placement, and safe service desired state.`,signInTitle:`Sign in`,signInText:`Enter your credentials.`,bootstrapTitle:`First owner`,bootstrapText:`An empty installation accepts only a signed product activation.`,activationPayload:`Activation manifest JSON`,activationSignature:`Manifest signature`,createOwner:`Create owner`,creatingOwner:`Creating...`,ownerCreated:`Owner created. You can sign in now.`,installationLocked:`Installation is already active`,insecureBootstrapDisabled:`Insecure bootstrap is disabled. Strict product-key activation is required.`,email:`Login`,password:`Password`,backendApi:`Backend API`,useLocalProxy:`Use local /api/v1 proxy`,language:`Language`,deviceLabel:`Device`,rememberMe:`Remember me`,trustDevice:`Trust this device`,signIn:`Sign in`,signingIn:`Signing in...`,logout:`Logout`,profile:`Profile`,refresh:`Refresh`,refreshing:`Refreshing...`,autoRefresh:`Auto refresh`,lastRefresh:`Data refreshed`,activeCluster:`Active cluster`,slugLabel:`Technical code`,slugHelp:`Slug is a stable short technical identifier for URLs, scripts, logs, and integrations. It should generally not change after creation.`,clusterCatalog:`Cluster catalog`,clusterCatalogText:`Real clusters from the Control Plane. Select the active cluster or expand a card for details.`,makeActive:`Make active`,openSettings:`Open settings`,selected:`Selected`,createCluster:`Create cluster`,clusterDetails:`Details`,consoleTitle:`Platform Owner Console`,boundary:`WEB is presentation only. Cluster decisions go through Control Plane APIs, PostgreSQL source of truth, and audit.`,noLoginError:`Sign in as a product owner or platform administrator to load the panel.`,accessDenied:`Access to this panel is denied.`,sessionMode:`Session mode`,sessionModeAdmin:`Admin`,sessionModeUser:`User`,sessionRefreshedAt:`Session refreshed`,emptyLiveTitle:`Cluster has no live nodes yet`,emptyLiveText:`These are real values, not placeholders: the selected cluster has no approved node-agent nodes yet. Create a join token, run rap-node-agent, and approve the join request.`,realDataNote:`Only PostgreSQL/Control Plane data is shown. Zero values mean the corresponding nodes, roles, or services do not exist yet.`,signedInAs:`Signed in`,actorUser:`Actor user`,testMode:`Testing`,testModeText:`Enables test telemetry and synthetic link observations. This is not production mesh runtime.`,platformTestFlag:`Server testing`,nodeTelemetry:`Node telemetry`,heartbeatHistory:`Heartbeat history`,noTelemetry:`No telemetry yet`,enableTelemetry:`Enable telemetry`,enableSyntheticLinks:`Enable test links`,saveTestFlag:`Save flag`,nodeManagement:`Node management`,nodeScope:`View scope`,currentClusterNodes:`Active cluster nodes`,allNodes:`All platform nodes`,showAllPlatformNodes:`Show all platform nodes`,currentClusterMembership:`Active cluster membership`,clusterMemberships:`Cluster memberships`,notMemberOfActiveCluster:`not a member`,nodeIdentity:`Physical node identity`,activeClusterScope:`Active cluster scope`,activeClusterScopeText:`One physical node may belong to multiple clusters. Roles and desired services below belong only to the selected active cluster.`,capabilityConfirmed:`capability confirmed by heartbeat`,capabilityMissing:`capability not reported by node`,capabilityUnknown:`capability unconfirmed: no heartbeat`,nodeGlobalInventoryText:`Each physical node is shown once. Membership and roles remain cluster-scoped, so the same node may have different assignments in different clusters.`,nodeSearch:`Node search`,groupNodesBy:`Group by`,groupByMembership:`membership`,groupByHealth:`health`,groupByOwnership:`ownership`,groupByClusterCount:`cluster count`,nodeGroups:`Node groups`,nodeGroupTree:`Group tree`,nodeGroupFilter:`Group filter`,allNodeGroups:`All groups`,nodeGroupCreatePanel:`Create group`,nodeGroupName:`Group name`,parentNodeGroup:`Parent group`,rootNodeGroup:`Root`,ungroupedNodes:`Ungrouped`,createNodeGroup:`Create group`,createSubgroup:`Create subgroup`,collapseGroup:`Collapse`,expandGroup:`Expand`,assignNodeGroup:`Move to group`,removeFromNodeGroup:`Remove from group`,connectExistingNode:`Connect to active cluster`,connectExistingNodeTitle:`Connect existing node`,connectExistingNodeText:`This creates or re-enables membership for one concrete physical node in the active cluster. Roles below are assigned only in this cluster.`,connectWithRoles:`Connect with roles`,nodeDetails:`Details`,manageNode:`Configure`,nodeFunctions:`Node functions`,nodeFunctionsText:`One row controls the whole function: role grants permission in the active cluster, desired service requests runtime start, observed state reports node-agent facts.`,rolePermission:`Permission`,permissionGranted:`granted`,permissionDenied:`not allowed`,organizationScopeForEnable:`Organization scope for new enables, optional`,clusterWideRolePlaceholder:`empty = cluster-wide role`,desiredRuntime:`Desired state`,observedRuntime:`Observed`,enableFunction:`Enable function`,disableFunction:`Disable function`,close:`Close`,nodeBriefList:`Compact node list`,noActiveClusterMembership:`Node is not a member of the active cluster`,nodeBriefListHelp:`The list is grouped as the active cluster tree. Full details, management, roles, services, and statistics open from the node row.`,nodeSearchPlaceholder:`name, key, cluster, status`,nodeGroupInventoryText:`Groups are a cluster inventory structure. Moving a node to a group changes only its placement inside the active cluster, not roles or membership.`,nodeGroupCreated:`Node group created.`,noNodesTitle:`No nodes`,noNodesByFilter:`No nodes match the current filter.`,cancel:`Cancel`,alreadyMember:`Already in active cluster`,revokedMembership:`Membership revoked`,addNode:`Add node`,addNodeText:`Connect an existing physical node to the active cluster from the node list: enable platform-wide view and click “Connect to active cluster”.`,joinTokenTitle:`Create new Docker node`,joinTokenText:`First create a one-time install token and Docker install profile. Then run the generated command on the Docker host; the agent submits a request and the platform owner approves it.`,ttlHours:`Lifetime, hours`,ttlHelp:`After this time the token becomes invalid even if unused. For manual enrollment, 1–24 hours is usually enough.`,maxUses:`Maximum uses`,maxUsesHelp:`How many node-agents may use this token. The safest default is one token for one new node.`,tokenPurpose:`Token purpose`,nodeOwnership:`Node ownership type`,suggestedRoles:`Allowed/expected roles`,generatedScope:`Generated scope`,generatedScopeHelp:`JSON is generated automatically from the settings above. Operators should not hand-write it and risk syntax or access-scope mistakes.`,manualApprovalRequired:`Manual request approval is required`,nodeRoles:`Node roles`,desiredServices:`Desired services`,observedServices:`Observed services`,noRoles:`No roles yet`,noServices:`No services yet`,manageInCluster:`Manage in cluster`,rolesAndServices:`Roles and services`,links:`Links`,fabricMap:`Fabric traffic map`,fabricNodeLayer:`Cluster nodes`,observedPeerLinks:`Observed links`,placementIntent:`control-plane placement`,endpointName:`Name`,publicEndpoint:`Public endpoint`,endpointType:`Entry type`,description:`Description`,routeScope:`Route scope JSON`,endpointNodes:`Assigned nodes`,assignEndpointNode:`Assign node`,selectNode:`Select node`,assignedNodesEmpty:`No nodes assigned yet`,addressNotSet:`address not set`,descriptionNotSet:`description not set`,servicePlacement:`Service placement`,trafficFlow:`Node traffic flows`,organizationTestFlag:`Organization testing`,organizationId:`Organization ID`,saveOrganizationFlag:`Save organization flag`,noLinks:`No links yet`,recentHeartbeats:`Recent heartbeats`,memory:`Memory`,cpu:`CPU`,processes:`Processes`}};function D(e){return{userId:e.user.id||e.user.ID||``,email:e.user.email||e.user.Email||``,authSessionId:e.auth_session.id||e.auth_session.ID||``,accessToken:e.tokens.access_token,refreshToken:e.tokens.refresh_token,accessTokenExpiresAt:e.tokens.access_token_expires_at,refreshTokenExpiresAt:e.tokens.refresh_token_expires_at}}async function ue(e){try{return await e.listClusterSummaries(),`admin`}catch{try{return await Promise.all([e.listOrganizations(),e.listResources()]),`user`}catch{return null}}}function de(){let[e,t]=(0,_.useState)(!1),[n,r]=(0,_.useState)(()=>!!ce()),[i]=(0,_.useState)(()=>{let e=localStorage.getItem(C.baseUrl)?.trim();return!e||e.startsWith(ne)?te:e}),[a,o]=(0,_.useState)(()=>ce()),[s,c]=(0,_.useState)(null),[l,u]=(0,_.useState)(``),[d,f]=(0,_.useState)(()=>localStorage.getItem(C.language)===`en`?`en`:`ru`),[p,m]=(0,_.useState)(a?.userId??localStorage.getItem(C.actorUserId)??``),[h,g]=(0,_.useState)({email:``,password:``,deviceLabel:`Панель владельца платформы`,trustDevice:!0,rememberMe:!0,showPassword:!1}),[v,b]=(0,_.useState)(null),[x,ee]=(0,_.useState)({email:``,password:``,activationPayload:``,activationSignature:``}),[w,ae]=(0,_.useState)(`command`),[T,de]=(0,_.useState)(``),[ge,ve]=(0,_.useState)([]),[Se,Ce]=(0,_.useState)([]),[we,Te]=(0,_.useState)(null),[j,Ee]=(0,_.useState)([]),[De,Oe]=(0,_.useState)([]),[ke,Ae]=(0,_.useState)({}),[je,Me]=(0,_.useState)([]),[Ne,Pe]=(0,_.useState)([]),[Re,ze]=(0,_.useState)([]),[Be,Ve]=(0,_.useState)({}),[He,Ue]=(0,_.useState)({}),[We,Ge]=(0,_.useState)({}),[Ke,qe]=(0,_.useState)({}),[Qe,nt]=(0,_.useState)({}),[rt,it]=(0,_.useState)({}),[ot,pt]=(0,_.useState)({}),[gt,yt]=(0,_.useState)([]),[bt,xt]=(0,_.useState)([]),[St,Ct]=(0,_.useState)({}),[Et,Dt]=(0,_.useState)([]),[zt,Ht]=(0,_.useState)([]),[I,Qt]=(0,_.useState)(null),[$t,en]=(0,_.useState)([]),[tn,nn]=(0,_.useState)(null),[rn,an]=(0,_.useState)(null),[on,sn]=(0,_.useState)(null),[cn,ln]=(0,_.useState)(null),[un,dn]=(0,_.useState)(null),[L,fn]=(0,_.useState)(null),[pn,R]=(0,_.useState)([]),[mn,hn]=(0,_.useState)(!1),[z,yn]=(0,_.useState)(re),[bn,wn]=(0,_.useState)(null),[Tn,En]=(0,_.useState)(null),[Dn,On]=(0,_.useState)([]),[kn,An]=(0,_.useState)([]),[jn,Nn]=(0,_.useState)([]),[Pn,zn]=(0,_.useState)({}),[Gn,Kn]=(0,_.useState)({}),[qn,Jn]=(0,_.useState)(()=>localStorage.getItem(C.vpnDiagnosticDeviceId)||``),[Yn,Xn]=(0,_.useState)([]),[Zn,Qn]=(0,_.useState)(null),[$n,er]=(0,_.useState)(`http://2ip.ru/`),[tr,nr]=(0,_.useState)(null),[rr,ir]=(0,_.useState)([]),[ar,or]=(0,_.useState)([]),[sr,cr]=(0,_.useState)([]),[lr,ur]=(0,_.useState)({}),[dr,fr]=(0,_.useState)([]),[pr,mr]=(0,_.useState)([]),[hr,gr]=(0,_.useState)(null),[_r,vr]=(0,_.useState)(``),[yr,br]=(0,_.useState)(`poll`),[xr,Sr]=(0,_.useState)(``),[Cr,wr]=(0,_.useState)(null),[Tr,Er]=(0,_.useState)(!1),[Dr,Or]=(0,_.useState)(``),[kr,Ar]=(0,_.useState)(``),[jr,Mr]=(0,_.useState)({slug:``,name:``,region:``}),[Nr,Pr]=(0,_.useState)({name:``,status:`active`,region:``,metadataJson:`{}`}),[Fr,Ir]=(0,_.useState)({name:``,parentGroupId:``}),[Lr,Rr]=(0,_.useState)({hysteresisPenalty:`150`,promotionMinSamples:`64`,demotionFailureThreshold:`1`,demotionDropThreshold:`1`,demotionSlowThreshold:`1`,demotionRebuildEnabled:!0,demotionFencedEnabled:!0}),[zr,Br]=(0,_.useState)({currentWindowSeconds:`1800`,historyWindowSeconds:`86400`}),[U,Vr]=(0,_.useState)(le),[Hr,Ur]=(0,_.useState)(null),[Wr,Gr]=(0,_.useState)({authorityState:`authoritative`,mutationMode:`normal`,notes:``}),[Kr,qr]=(0,_.useState)(``),[Jr,Yr]=(0,_.useState)(`cluster`),[Xr,Zr]=(0,_.useState)(``),[Qr,$r]=(0,_.useState)(``),[ei,ti]=(0,_.useState)([]),[ni,ri]=(0,_.useState)(`membership`),[ii,ai]=(0,_.useState)(null),[oi,si]=(0,_.useState)([]),[ci,li]=(0,_.useState)(null),[ui,di]=(0,_.useState)(`details`),[fi,pi]=(0,_.useState)({}),[mi,hi]=(0,_.useState)({}),[gi,_i]=(0,_.useState)({}),[vi,yi]=(0,_.useState)({}),[bi,xi]=(0,_.useState)(``),[Si,Ci]=(0,_.useState)({telemetry:!0,links:!0}),[wi,Ti]=(0,_.useState)({nodeId:``,serviceType:`entry-node`,desiredState:`enabled`,runtimeMode:`container`,version:``,configJson:`{}`,environmentJson:`{}`}),[W,Ei]=(0,_.useState)({organizationId:``,name:``,protocolFamily:`generic`,desiredState:`disabled`,credentialRef:``,targetEndpointJson:`{}`,allowedNodePolicyJson:`{"mode":"explicit","node_ids":[]}`,routingUsageJson:`[]`,routePolicyJson:`{}`,qosPolicyJson:`{}`,placementPolicyJson:`{}`}),[Di,Oi]=(0,_.useState)({slug:``,name:``}),[G,ki]=(0,_.useState)({email:``,password:``,platformRole:`user`}),[Ai,ji]=(0,_.useState)({organizationId:``,userId:``,roleId:`org_member`}),[Mi,Ni]=(0,_.useState)(null),[Pi,Fi]=(0,_.useState)({username:``,password:``,domain:``}),[K,Ii]=(0,_.useState)({organizationId:``,name:``,address:``,protocol:`rdp`,routeMode:`vpn_exit`,entryNode:``,exitNode:``,tags:``,username:``,password:``,domain:``}),[Li,Ri]=(0,_.useState)(``),[zi,Bi]=(0,_.useState)(``),[Vi,Hi]=(0,_.useState)(``),Ui=`rap-android-vpn-latest-release.apk`,[Wi,Gi]=(0,_.useState)(Ui),q=(0,_.useMemo)(()=>new y({baseUrl:i,actorUserId:p}),[i,p]),Ki=(0,_.useMemo)(()=>new y({baseUrl:i,actorUserId:``}),[i]),qi=(0,_.useRef)(0),Ji=(0,_.useRef)(!1),J=E[d],Yi=ge.find(e=>e.id===T)||null,Xi=Se.find(e=>e.cluster_id===T)||null,Zi=(0,_.useMemo)(()=>In(i),[i]),Qi=(0,_.useCallback)((e,t)=>{if(!e)return t;let n=e.trim();return n?/^https?:\/\//i.test(n)||n.startsWith(`/`)?n.startsWith(`/`)?n.substring(1):n:n.startsWith(`downloads/`)?n:`downloads/${n.replace(/^\.\/+/,``).replace(/^\/+/,``)}`:t},[]),$i=Qi(Wi,Ui),ea=Vi?Qi(Vi,$i):$i,ta=Vi?ea:$i,na=`${/^https?:\/\//i.test(ta)?ta:`${Zi}/${ta}`}${zi?`?_v=${encodeURIComponent(zi)}`:``}`,ra=(0,_.useMemo)(()=>It(U),[U]),ia=(0,_.useMemo)(()=>Hr?Lt(Hr.scope,U):U,[Hr,U]),aa=(0,_.useMemo)(()=>{let e=new Map;for(let t of ge)for(let n of ke[t.id]||[]){let r=e.get(n.id);r?(r.memberships.push({cluster:t,node:n}),(n.last_seen_at||``)>(r.node.last_seen_at||``)&&(r.node=n)):e.set(n.id,{node:n,memberships:[{cluster:t,node:n}]})}return Array.from(e.values()).sort((e,t)=>e.node.name.localeCompare(t.node.name))},[ke,ge]);(0,_.useMemo)(()=>_n(aa,T,Xr,ni,d),[aa,ni,Xr,d,T]);let oa=(0,_.useMemo)(()=>{let e=Xr.trim().toLowerCase(),t=Qr?new Set([Qr,...Nt(Qr,De)]):null;return aa.filter(n=>{let r=n.memberships.some(e=>e.cluster.id===T);if(Jr!==`all`&&!r)return!1;if(t){let e=n.memberships.find(e=>e.cluster.id===T);if(!e?.node.node_group_id||!t.has(e.node.node_group_id))return!1}return!e||vn(n,e)})},[aa,Xr,Qr,De,Jr,T]),sa=(0,_.useCallback)((e,t=!1)=>{if(e&&t){localStorage.setItem(C.auth,JSON.stringify(e)),localStorage.setItem(C.actorUserId,e.userId),r(!0);return}r(!1),localStorage.removeItem(C.auth),localStorage.removeItem(C.actorUserId)},[]),ca=(0,_.useCallback)(async()=>{try{let e=`${Zi}/downloads/rap-android-vpn-build.json?_cb=${Date.now()}`,t=await fetch(e,{cache:`no-store`});if(!t.ok){Ri(``),Bi(new Date().toISOString()),Hi(``),Gi(Ui);return}let n=await t.json();Ri(n.version?.name||``),Bi(n.published?.timestamp_utc||``),Hi(n.release_paths?.versioned||``),Gi(n.published?.path||n.release_paths?.latest||Ui)}catch{Ri(``),Bi(new Date().toISOString()),Hi(``),Gi(Ui)}},[Zi]),la=(0,_.useMemo)(()=>Pt(oa,De,T,J,new Set(ei)),[ei,De,T,J,oa]),ua=(0,_.useMemo)(()=>pr.slice(0,8),[pr]);(0,_.useEffect)(()=>{if(e)return;t(!0);let n=ce();if(n){if(se(n.refreshTokenExpiresAt)){localStorage.removeItem(C.auth),localStorage.removeItem(C.actorUserId),r(!1);return}(async()=>{try{let e=D(await Ki.refresh({refreshToken:n.refreshToken}));if(!e.userId||!e.authSessionId)throw Error(`Не удалось восстановить сессию.`);let t=await ue(new y({baseUrl:i,actorUserId:e.userId}));if(!t)throw Error(`Доступ к этой панели запрещен.`);m(e.userId),sa(e,!0),o(e),u(new Date().toISOString()),g(t=>({...t,email:e.email})),c(t)}catch{localStorage.removeItem(C.auth),localStorage.removeItem(C.actorUserId),r(!1),u(``),o(null),m(``),c(null)}})()}},[Ki,e,i,sa]),(0,_.useEffect)(()=>{let e=!1;return Ki.getInstallationStatus().then(t=>{e||b(t)}).catch(t=>{e||Or(t instanceof Error?t.message:`Не удалось загрузить статус установки.`)}),()=>{e=!0}},[Ki]),(0,_.useEffect)(()=>{if(!Yi){Pr({name:``,status:`active`,region:``,metadataJson:`{}`});return}Pr({name:Yi.name,status:Yi.status||`active`,region:Yi.region||``,metadataJson:JSON.stringify(Yi.metadata||{},null,2)})},[Yi]),(0,_.useEffect)(()=>{$r(``),Ir({name:``,parentGroupId:``}),ti([])},[T]),(0,_.useEffect)(()=>{ai(null),si([])},[T]),(0,_.useEffect)(()=>{localStorage.setItem(C.baseUrl,i),localStorage.setItem(C.language,d),a&&localStorage.setItem(`${C.language}.${a.userId}`,d),(!a||!n)&&(localStorage.removeItem(C.auth),localStorage.removeItem(C.actorUserId))},[i,d,n,a]),(0,_.useEffect)(()=>{if(!a)return;let e=localStorage.getItem(`${C.language}.${a.userId}`);(e===`ru`||e===`en`)&&f(e)},[a?.userId]),(0,_.useEffect)(()=>{a&&da()},[a?.userId]),(0,_.useEffect)(()=>{if(!a||s!==`admin`||!T)return;let e=!1,t=()=>{e||Tr||Ji.current||document.visibilityState===`hidden`||(Ji.current=!0,pa(T).catch(t=>{e||Or(t instanceof Error?t.message:`Не удалось автообновить данные панели.`)}).finally(()=>{Ji.current=!1}))},n=null;typeof window.EventSource==`function`&&(n=new EventSource(q.clusterEventsURL(T)),n.onopen=()=>{e||br(`sse`)},n.onerror=()=>{e||br(`poll`)},n.addEventListener(`cluster.changed`,t));let r=window.setInterval(t,n?3e4:1e4);return()=>{e=!0,n?.close(),window.clearInterval(r)}},[q,s,Tr,T,a?.userId]);async function da(e=T){if(!p.trim()){Or(J.noLoginError);return}if(s===`user`){await fa();return}Er(!0),Or(``),Ar(``);try{let[t,n,r,i,a]=await Promise.all([q.listClusters(),q.listClusterSummaries(),q.listOrganizations(),q.listUsers(),q.listResources()]);ve(t),Ce(n),ir(r),or(i),cr(a),!xr&&r[0]?.id&&Sr(r[0].id),ji(e=>({...e,organizationId:e.organizationId||r[0]?.id||``})),Ii(e=>({...e,organizationId:e.organizationId||r[0]?.id||``}));let o=await Promise.all(r.map(async e=>[e.id,await q.listOrganizationMemberships(e.id)]));ur(Object.fromEntries(o));let s=await Promise.all(t.map(async e=>[e.id,await q.listNodes(e.id)]));Ae(Object.fromEntries(s));let c=e||t[0]?.id||``;de(c),c&&await ma(c),vr(new Date().toISOString())}catch(e){Or(e instanceof Error?e.message:`Неизвестная ошибка панели управления платформой.`)}finally{Er(!1)}}async function fa(){if(!p.trim()){Or(`Войдите, чтобы загрузить личный кабинет.`);return}Er(!0),Or(``),Ar(``);try{await ca();let[e,t]=await Promise.all([q.listOrganizations(),q.listResources()]);ir(e),cr(t),!xr&&e[0]?.id&&Sr(e[0].id);let n=await Promise.all(e.map(async e=>[e.id,await q.listOrganizationMemberships(e.id)]));ur(Object.fromEntries(n)),vr(new Date().toISOString())}catch(e){Or(e instanceof Error?e.message:`Не удалось загрузить личный кабинет.`)}finally{Er(!1)}}async function pa(e){if(!p.trim())return;let[t,n,r,i,a]=await Promise.all([q.listClusterSummaries(),q.listNodes(e),q.listOrganizations(),q.listUsers(),q.listResources()]);Ce(t),ir(r),or(i),cr(a),Ae(t=>({...t,[e]:n})),await ma(e,{preserveEditableForms:!0}),vr(new Date().toISOString())}async function ma(e,t={}){let n=++qi.current,r=mn?20:10,i=mn?z.offset:0,a={reporterNodeId:z.reporterNodeId||void 0,routeId:z.routeId||void 0,serviceClass:z.serviceClass||void 0,generation:z.generation||void 0,feedbackSource:z.feedbackSource||void 0,feedbackChannelId:z.feedbackChannelId||void 0,feedbackViolationStatus:z.feedbackViolationStatus||void 0,limit:r,offset:i,enrichment:mn?`deep`:`summary`},[o,s,c,l,u,d,f,p,m,h,g,_,v,y,b,x,ee,S,te,ne,re,ie,w,ae,oe]=await Promise.all([q.listNodes(e),q.listNodeGroups(e),q.listJoinRequests(e),q.listJoinTokens(e),q.listReleaseVersions(e,`rap-node-agent`,`dev`),q.getClusterAuthority(e),q.listAudit(e),q.getFabricServiceChannelRebuildInvestigationBreadcrumbs(e,{limit:20}),q.listMeshLinks(e),q.listRouteIntents(e),q.listFabricServiceChannelRouteFeedback(e,{includeExpired:!0}),q.listFabricServiceChannelRouteRebuildAttempts(e,a),q.getFabricServiceChannelRouteRebuildHealthSummary(e,{limit:5}),q.listFabricServiceChannelRouteRebuildAlertSilences(e),q.getFabricServiceChannelReadiness(e,{limit:5}),q.getFabricServiceChannelSchemaStatus(e),q.getFabricServiceChannelRebuildSnapshotMaintenanceHealth(e,{limit:50,minAgeSeconds:60,heartbeatThreshold:2}),q.getFabricServiceChannelLeaseMaintenance(e,{limit:20,includeExpired:!0}),q.getFabricServiceChannelAccessTelemetry(e,{limit:20}),q.listFabricServiceChannelRouteRebuildIncidents(e,{limit:5}),q.getFabricServiceChannelRecoveryPolicy(e),q.getFabricServiceChannelBreadcrumbWindowPolicy(e),q.listQoSPolicies(e),q.listVPNConnections(e),q.listFabricTestingFlags()]);if(n!==qi.current)return;Ee(o),Oe(s),Me(c),Pe(l),ze(u),Te(d),t.preserveEditableForms||Gr({authorityState:d.authority_state,mutationMode:d.mutation_mode,notes:d.notes||``}),fr(f),mr(p.events),gr(p.summary||null),yt(m),xt(h),Dt(g),Ht(_),Qt(v),en(y),nn(b),an(x),sn(ee),dn(S),fn(te),R(ne),wn(re),En(ie),t.preserveEditableForms||Br({currentWindowSeconds:String(ie.current_window_seconds||1800),historyWindowSeconds:String(ie.history_window_seconds||86400)}),Rr({hysteresisPenalty:String(re.hysteresis_penalty),promotionMinSamples:String(re.promotion_min_samples),demotionFailureThreshold:String(re.demotion_failure_threshold),demotionDropThreshold:String(re.demotion_drop_threshold),demotionSlowThreshold:String(re.demotion_slow_threshold),demotionRebuildEnabled:re.demotion_rebuild_enabled,demotionFencedEnabled:re.demotion_fenced_enabled}),On(w),Nn(ae),An(oe);let T=await q.listVPNClientDiagnosticStatuses(e);if(n!==qi.current)return;Xn(T);let se=T.find(e=>e.device_id===qn.trim())||T[0]||null;Qn(se),!qn.trim()&&se&&(Jn(se.device_id),localStorage.setItem(C.vpnDiagnosticDeviceId,se.device_id));let ce=await Promise.all(o.map(async t=>[t.id,await q.listNodeRoles(e,t.id)]));if(n!==qi.current)return;Ge(Object.fromEntries(ce));let le=await Promise.all(o.map(async t=>[t.id,await q.listDesiredWorkloads(e,t.id)]));if(n!==qi.current)return;qe(Object.fromEntries(le));let E=await Promise.all(o.map(async t=>[t.id,await q.listWorkloadStatuses(e,t.id)]));if(n!==qi.current)return;nt(Object.fromEntries(E));let D=await Promise.all(o.map(async t=>[t.id,await q.listNodeHeartbeats(e,t.id,60)]));if(n!==qi.current)return;it(Object.fromEntries(D));let ue=await Promise.all(o.map(async t=>[t.id,await q.getNodeUpdatePlan(e,t.id,{currentVersion:t.reported_version})]));if(n!==qi.current)return;Ve(Object.fromEntries(ue));let de=await Promise.all(o.map(async t=>[t.id,await q.listNodeUpdateStatuses(e,t.id,80)]));if(n!==qi.current)return;Ue(Object.fromEntries(de));let fe=await Promise.all(o.map(async t=>[t.id,await q.listNodeTelemetry(e,t.id,120)]));if(n!==qi.current)return;pt(Object.fromEntries(fe));let O=await Promise.all(o.map(async t=>[t.id,await q.getNodeSyntheticMeshConfig(e,t.id)]));if(n!==qi.current)return;Ct(Object.fromEntries(O));let k=await Promise.all(ae.map(async t=>[t.id,await q.getActiveVPNLease(e,t.id)]));if(n!==qi.current)return;zn(Object.fromEntries(k));let pe=await Promise.all(ae.map(async t=>[t.id,await q.getVPNPacketStats(e,t.id)]));n===qi.current&&Kn(Object.fromEntries(pe))}async function ha(e=mn,t=z){if(T){Er(!0),Or(``),Ar(``);try{let n=await q.listFabricServiceChannelRouteRebuildAttempts(T,{reporterNodeId:t.reporterNodeId||void 0,routeId:t.routeId||void 0,serviceClass:t.serviceClass||void 0,generation:t.generation||void 0,feedbackSource:t.feedbackSource||void 0,feedbackChannelId:t.feedbackChannelId||void 0,feedbackViolationStatus:t.feedbackViolationStatus||void 0,limit:e?20:10,offset:e?t.offset:0,enrichment:e?`deep`:`summary`});hn(e),yn(t),Ht(n),Ar(e?`Deep rebuild ledger loaded.`:`Fast rebuild ledger loaded.`)}catch(e){Or(e instanceof Error?e.message:`Не удалось загрузить rebuild ledger.`)}finally{Er(!1)}}}async function ga(){if(!T)return;let[e,t,n,r,i,a,o,s,c,l]=await Promise.all([q.getFabricServiceChannelRouteRebuildHealthSummary(T,{limit:5}),q.listFabricServiceChannelRouteRebuildAlertSilences(T),q.getFabricServiceChannelReadiness(T,{limit:5}),q.getFabricServiceChannelSchemaStatus(T),q.getFabricServiceChannelRebuildSnapshotMaintenanceHealth(T,{limit:50,minAgeSeconds:60,heartbeatThreshold:2}),q.getFabricServiceChannelLeaseMaintenance(T,{limit:20,includeExpired:!0}),q.getFabricServiceChannelAccessTelemetry(T,{limit:20}),q.listFabricServiceChannelRouteRebuildIncidents(T,{limit:5}),q.getFabricServiceChannelRebuildInvestigationBreadcrumbs(T,{limit:20}),q.getFabricServiceChannelBreadcrumbWindowPolicy(T)]);Qt(e),en(t),nn(n),an(r),sn(i),dn(a),fn(o),R(s),mr(c.events),gr(c.summary||null),En(l),Br({currentWindowSeconds:String(l.current_window_seconds||1800),historyWindowSeconds:String(l.history_window_seconds||86400)})}async function _a(){if(T)try{Er(!0);let e=await q.warmupFabricServiceChannelRebuildSnapshots(T,{limit:10,staleAfterSeconds:60});ln(e),await ga(),Ar(`Snapshot warmup: warmed ${e.warmed_count}, fresh ${e.already_fresh_count}, errors ${e.error_count}.`)}catch(e){Or(e instanceof Error?e.message:`Не удалось прогреть rebuild snapshots.`)}finally{Er(!1)}}async function va(){if(T)try{Er(!0);let e=await q.cleanupFabricServiceChannelLeases(T,{limit:100});dn(e),Ar(`Service-channel lease cleanup: deleted ${e.deleted_expired_count||0}, active ${e.active_count}, expired ${e.expired_count}.`)}catch(e){Or(e instanceof Error?e.message:`Не удалось очистить service-channel leases.`)}finally{Er(!1)}}async function ya(e){let t={reporterNodeId:e.reporter_node_id,routeId:e.route_id,serviceClass:e.service_class,generation:e.generation||``,feedbackSource:``,feedbackChannelId:e.channel_id||``,feedbackViolationStatus:``,offset:0};await q.recordFabricServiceChannelRouteRebuildInvestigation(T,{reporterNodeId:e.reporter_node_id,routeId:e.route_id,serviceClass:e.service_class,generation:e.generation||``,guardStatus:e.guard_status,incidentId:e.fingerprint});let n=await q.getFabricServiceChannelRebuildInvestigationBreadcrumbs(T,{limit:20});mr(n.events),gr(n.summary||null),yn(t),await ha(!0,t)}function ba(e){let t=new Set(e.affected_reporter_node_ids||[]),n=new Set(e.affected_route_ids||[]);return pn.filter(r=>{let i=!e.feedback_channel_id||r.channel_id===e.feedback_channel_id,a=t.size===0||t.has(r.reporter_node_id),o=n.size===0||n.has(r.route_id);return i&&a&&o})}function xa(e){let t=N(e.payload)||{},n=P(t,`feedback_source`,``),r=P(t,`feedback_channel_id`,``),i=P(t,`feedback_violation_status`,``),a=P(t,`reporter_node_id`,``),o=P(t,`route_id`,``);return!n&&!r&&!i?null:(I?.feedback_breakdowns||[]).find(e=>!(n&&e.feedback_source!==n||r&&e.feedback_channel_id!==r||i&&e.feedback_violation_status!==i||a&&!(e.affected_reporter_node_ids||[]).includes(a)||o&&!(e.affected_route_ids||[]).includes(o)))||null}function Sa(e){let t=N(e.payload)||{},n=P(t,`reporter_node_id`,``),r=P(t,`route_id`,e.target_type===`fabric_service_channel_route_rebuild_incident`&&e.target_id||``),i=P(t,`service_class`,``),a=P(t,`generation`,``),o=P(t,`guard_status`,``);return pn.find(e=>n&&e.reporter_node_id!==n||r&&e.route_id!==r||i&&e.service_class!==i||a&&e.generation!==a||o&&e.guard_status!==o?!1:!!(n||r||i||a||o))||null}async function Ca(e){let t={...re,feedbackSource:e.feedback_source||``,feedbackChannelId:e.feedback_channel_id||``,feedbackViolationStatus:e.feedback_violation_status||``,offset:0};await q.recordFabricServiceChannelRouteRebuildInvestigation(T,{reporterNodeId:(e.affected_reporter_node_ids||[])[0]||``,routeId:(e.affected_route_ids||[])[0]||``,feedbackSource:e.feedback_source||``,feedbackChannelId:e.feedback_channel_id||``,feedbackViolationStatus:e.feedback_violation_status||``,drilldownSource:`rebuild_health_feedback_breakdown`,reason:`operator opened rebuild-health feedback breakdown ledger`});let n=await q.getFabricServiceChannelRebuildInvestigationBreadcrumbs(T,{limit:20});mr(n.events),gr(n.summary||null),ae(`fabric`),yn(t),await ha(!0,t)}async function wa(e){await q.silenceFabricServiceChannelRouteRebuildAlert(T,{incidentSource:e.incident_source||``,channelId:e.channel_id||``,reporterNodeId:e.reporter_node_id,routeId:e.route_id,guardStatus:e.guard_status||`unknown`,generation:e.generation||``,reason:`operator acknowledged rebuild incident`,ttlSeconds:21600}),await ga()}async function Ta(e){await q.unsilenceFabricServiceChannelRouteRebuildAlert(T,e.id,`operator removed rebuild alert silence`),await ga()}function Ea(){Ee([]),Oe([]),Me([]),Pe([]),ze([]),Ve({}),Te(null),Ge({}),qe({}),nt({}),it({}),Ue({}),pt({}),yt([]),xt([]),Ct({}),Dt([]),Ht([]),Qt(null),en([]),nn(null),an(null),ln(null),R([]),hn(!1),yn(re),On([]),An([]),Nn([]),zn({}),Kn({}),Xn([]),Qn(null),ir([]),or([]),cr([]),ur({}),fr([]),mr([]),gr(null)}async function Y(e,t){Er(!0),Or(``),Ar(``);try{await e(),Ar(t),await da()}catch(e){Or(e instanceof Error?e.message:`Действие не выполнено.`)}finally{Er(!1)}}async function Da(){if(!T){Qn(null);return}let e=await q.listVPNClientDiagnosticStatuses(T);Xn(e);let t=qn.trim()||e[0]?.device_id||``;t&&(localStorage.setItem(C.vpnDiagnosticDeviceId,t),Jn(t));let n=e.find(e=>e.device_id===t)||(t?await q.getVPNClientDiagnosticStatus(T,t):null);Qn(n),Ar(n?`Диагностика VPN-клиента обновлена.`:`Диагностика VPN-клиента не найдена.`)}async function Oa(e,t){if(!T){Or(`Выбери кластер перед отправкой команды.`);return}let n=qn.trim();if(!n){Or(`Укажи Android device id или выбери найденный клиент.`);return}Er(!0),Or(``),Ar(``);try{nr(await q.enqueueVPNClientDiagnosticCommand(T,n,e)),Ar(`${t}: команда поставлена в очередь. Клиент заберет ее через диагностический канал.`),window.setTimeout(()=>{Da()},3500)}catch(e){Or(e instanceof Error?e.message:`Команда VPN-клиенту не отправлена.`)}finally{Er(!1)}}async function ka(){Er(!0),Or(``),Ar(``);try{let e=D(await Ki.login({email:h.email,password:h.password,deviceLabel:h.deviceLabel,trustDevice:h.trustDevice}));if(!e.userId||!e.authSessionId)throw Error(`Ответ входа не содержит пользователя или сессию.`);let t=new y({baseUrl:i,actorUserId:e.userId}),n=`admin`;try{await t.listClusterSummaries(),n=`admin`}catch{try{let[e,r]=await Promise.all([t.listOrganizations(),t.listResources()]);ir(e),cr(r),e[0]?.id&&Sr(e[0].id);let i=await Promise.all(e.map(async e=>[e.id,await t.listOrganizationMemberships(e.id)]));ur(Object.fromEntries(i)),n=`user`}catch{try{await Ki.revokeAuthSession({userId:e.userId,authSessionId:e.authSessionId,reason:`user_portal_access_denied`})}catch{}throw Error(J.accessDenied)}}r(h.rememberMe),sa(e,h.rememberMe),o(e),m(e.userId),g(t=>({...t,email:e.email,password:``})),u(new Date().toISOString()),c(n),Ar(`${J.signedInAs}: ${e.email}`)}catch(e){Or(e instanceof Error?e.message:`Вход не выполнен.`)}finally{Er(!1)}}async function Aa(){Er(!0),Or(``),Ar(``);try{let e;if(v?.strict_authority){if(!x.activationPayload.trim()||!x.activationSignature.trim())throw Error(J.bootstrapText);e=JSON.parse(x.activationPayload)}b((await Ki.bootstrapOwner({email:x.email,password:x.password,activationPayload:e,activationSignature:x.activationSignature})).installation),g({...h,email:x.email,password:x.password}),Ar(J.ownerCreated)}catch(e){Or(e instanceof Error?e.message:`Создание владельца не выполнено.`)}finally{Er(!1)}}async function ja(){let e=a;if(o(null),r(!1),u(``),sa(null),c(null),m(``),ve([]),Ce([]),Ea(),Ae({}),de(``),e?.userId&&e.authSessionId)try{await Ki.revokeAuthSession({userId:e.userId,authSessionId:e.authSessionId,reason:`platform_owner_console_logout`})}catch{}}async function Ma(e){de(e),Ea(),Er(!0),Or(``),Ar(``);try{await ma(e)}catch(e){Or(e instanceof Error?e.message:`Не удалось загрузить кластер.`)}finally{Er(!1)}}let Na=je.filter(e=>e.status===`pending`).length,Pa=j.filter(e=>e.health_status===`healthy`).length,Fa=j.filter(e=>e.health_status!==`healthy`||e.membership_status!==`active`).length,Ia=Object.values(We).flat().filter(e=>e.status===`active`).length,La=kn.find(e=>e.scope_type===`platform`&&!e.scope_id)||null;kn.find(e=>e.scope_type===`organization`&&e.scope_id===bi&&(!e.cluster_id||e.cluster_id===T));let Ra=Object.values(St),za=Ra.filter(e=>e.enabled).length,Ba=Ra.reduce((e,t)=>e+t.routes.length,0),Va=Ra.reduce((e,t)=>e+Object.keys(t.peer_endpoints||{}).length,0),Ha=Ra.reduce((e,t)=>e+Ot(t),0);Ra.reduce((e,t)=>e+(t.peer_directory?.length??0),0),Ra.reduce((e,t)=>e+(t.recovery_seeds?.length??0),0);let Ua=Ra.filter(e=>e.production_forwarding).length,Wa=Ie(j,rt),Ga=bt.filter(e=>lt(e)===`active`),Ka=bt.filter(e=>lt(e)===`expired`),qa=bt.filter(e=>lt(e)===`disabled`),Ja=Et.filter(e=>{let t=Date.parse(e.expires_at||``),n=Date.parse(e.retry_cooldown_until||``);return Number.isFinite(t)&&t>Date.now()||Number.isFinite(n)&&n>Date.now()}),Ya=Ja.filter(e=>e.feedback_status===`fenced`),Xa=Ja.filter(e=>e.feedback_status===`degraded`),Za=Ja.filter(e=>e.feedback_status===`healthy`),Qa=Ja.filter(e=>e.recovery_state===`recovered`||e.recovery_hysteresis_active),$a=Ja.filter(e=>e.recovery_promoted),eo=Ja.filter(e=>e.recovery_demoted),to=Ja.filter(e=>e.feedback_status===`operator_retry_cooldown`||e.retry_cooldown_until),no=Ra.flatMap(e=>e.route_path_decisions?.decisions||[]),X=no.filter(e=>e.decision_source===`service_channel_feedback_no_alternate`),ro=no.filter(e=>e.decision_source===`service_channel_feedback_replacement`),io=no.filter(e=>e.rebuild_status),ao=io.filter(e=>e.rebuild_status===`applied`),oo=zt.filter(e=>e.rebuild_status===`applied`),so=zt.filter(e=>e.rebuild_status&&e.rebuild_status!==`applied`),co=zt.filter(e=>e.guard_severity===`bad`),lo=no.filter(e=>(e.score_reasons||[]).includes(`service_channel_recovery_hysteresis`)),uo=no.filter(e=>(e.score_reasons||[]).includes(`service_channel_recovery_promoted`)),fo=no.filter(e=>(e.score_reasons||[]).includes(`service_channel_recovery_demoted`)),po=v?.bootstrapped===!1,mo=po&&!v?.strict_authority&&!v?.insecure_bootstrap_allowed,ho=s===`admin`?J.sessionModeAdmin:J.sessionModeUser;if(!a)return(0,S.jsxs)(`main`,{className:`loginShell`,children:[v&&(0,S.jsxs)(`section`,{className:`loginCard`,children:[(0,S.jsx)(`h1`,{children:v.bootstrapped?J.installationLocked:J.bootstrapTitle}),(0,S.jsx)(A,{label:`Authority`,value:`${v.authority_mode}/${v.authority_state}`}),(0,S.jsx)(A,{label:`Strict`,value:v.strict_authority?`enabled`:`legacy`}),v.root_fingerprint&&(0,S.jsx)(A,{label:`Root key`,value:B(v.root_fingerprint)})]}),po?(0,S.jsxs)(`section`,{className:`loginCard`,children:[(0,S.jsx)(`h1`,{children:J.bootstrapTitle}),(0,S.jsx)(`p`,{className:`loginHint`,children:mo?J.insecureBootstrapDisabled:J.bootstrapText}),(0,S.jsxs)(`label`,{children:[J.email,(0,S.jsx)(`input`,{value:x.email,onChange:e=>ee({...x,email:e.target.value}),autoComplete:`username`})]}),(0,S.jsxs)(`label`,{children:[J.password,(0,S.jsx)(`input`,{value:x.password,onChange:e=>ee({...x,password:e.target.value}),type:`password`,autoComplete:`new-password`})]}),v?.strict_authority&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`label`,{children:[J.activationPayload,(0,S.jsx)(`textarea`,{value:x.activationPayload,onChange:e=>ee({...x,activationPayload:e.target.value}),spellCheck:!1})]}),(0,S.jsxs)(`label`,{children:[J.activationSignature,(0,S.jsx)(`input`,{value:x.activationSignature,onChange:e=>ee({...x,activationSignature:e.target.value}),spellCheck:!1})]})]}),Dr&&(0,S.jsx)(`div`,{className:`errorPanel`,children:Dr}),kr&&(0,S.jsx)(`div`,{className:`noticePanel`,children:kr}),(0,S.jsx)(`button`,{className:`primary wide`,onClick:()=>void Aa(),disabled:Tr||mo||!x.email||x.password.length<12||v?.strict_authority&&(!x.activationPayload||!x.activationSignature),children:Tr?J.creatingOwner:J.createOwner})]}):(0,S.jsxs)(`section`,{className:`loginCard`,children:[(0,S.jsx)(`h1`,{children:J.signInTitle}),(0,S.jsxs)(`label`,{children:[J.email,(0,S.jsx)(`input`,{value:h.email,onChange:e=>g({...h,email:e.target.value.trim()}),autoComplete:`username`,autoCapitalize:`none`,autoCorrect:`off`,spellCheck:!1})]}),(0,S.jsxs)(`label`,{children:[J.password,(0,S.jsx)(`input`,{value:h.password,onChange:e=>g({...h,password:e.target.value}),type:h.showPassword?`text`:`password`,autoComplete:`current-password`,autoCapitalize:`none`,autoCorrect:`off`,spellCheck:!1,onKeyDown:e=>{e.key===`Enter`&&ka()}})]}),(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:h.showPassword,onChange:e=>g({...h,showPassword:e.target.checked})}),`Показать пароль`]}),(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:h.trustDevice,onChange:e=>g({...h,trustDevice:e.target.checked})}),J.trustDevice]}),(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:h.rememberMe,onChange:e=>g({...h,rememberMe:e.target.checked})}),J.rememberMe]}),Dr&&(0,S.jsx)(`div`,{className:`errorPanel`,children:Dr}),kr&&(0,S.jsx)(`div`,{className:`noticePanel`,children:kr}),(0,S.jsx)(`button`,{className:`primary wide`,onClick:()=>void ka(),disabled:Tr||!h.email||!h.password,children:Tr?J.signingIn:J.signIn})]})]});if(a&&!s)return(0,S.jsx)(`main`,{className:`loginShell`,children:(0,S.jsx)(`section`,{className:`loginCard`,children:(0,S.jsx)(`p`,{children:Tr?J.lastRefresh:`Восстанавливаем сессию...`})})});if(s===`user`){let e=rr.find(e=>e.id===xr)||rr[0]||null,t=e?sr.filter(t=>t.organization_id===e.id):sr,n=e?(lr[e.id]||[]).find(e=>e.user_id===a.userId):null,r=t.reduce((e,t)=>(e[t.protocol]=(e[t.protocol]||0)+1,e),{});return(0,S.jsxs)(`main`,{className:`portalShell`,children:[(0,S.jsxs)(`aside`,{className:`portalRail`,children:[(0,S.jsx)(`div`,{className:`brandMark`,children:`RAP`}),(0,S.jsx)(`p`,{className:`sideKicker`,children:`Личный кабинет`}),(0,S.jsx)(`h1`,{children:`Мой доступ`}),(0,S.jsx)(`p`,{className:`sideText`,children:`Установки, доступные серверы и состояние рабочей области пользователя.`}),(0,S.jsx)(A,{label:J.sessionMode,value:`${ho} • ${l?Rn(l):`н/д`}`}),(0,S.jsx)(A,{label:J.actorUser,value:a.email}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>void ja(),disabled:Tr,children:J.logout})]}),(0,S.jsxs)(`section`,{className:`portalWorkspace`,children:[(0,S.jsxs)(`header`,{className:`portalTop`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`p`,{className:`eyebrow`,children:`Secure Access Fabric`}),(0,S.jsx)(`h2`,{children:e?.name||`Личный кабинет`}),(0,S.jsx)(`p`,{className:`muted`,children:a.email})]}),(0,S.jsxs)(`label`,{children:[`Организация`,(0,S.jsx)(`select`,{value:e?.id||``,onChange:e=>Sr(e.target.value),children:rr.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))})]}),(0,S.jsx)(`button`,{className:`primary`,onClick:()=>void fa(),disabled:Tr,children:Tr?J.refreshing:J.refresh})]}),Dr&&(0,S.jsx)(`div`,{className:`errorPanel`,children:Dr}),kr&&(0,S.jsx)(`div`,{className:`noticePanel`,children:kr}),(0,S.jsxs)(`section`,{className:`grid three`,children:[(0,S.jsx)(fe,{label:`Организации`,value:rr.length,tone:`steel`}),(0,S.jsx)(fe,{label:`Серверы`,value:t.length,tone:`green`}),(0,S.jsx)(fe,{label:`Установки`,value:2,tone:`amber`})]}),(0,S.jsxs)(`section`,{className:`grid two`,children:[(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:`Установки`}),(0,S.jsx)(`p`,{className:`muted`,children:Li?`Актуальная версия Android: ${Li}`:`Скачивайте актуальные клиенты только отсюда, чтобы не ловить старую сборку.`})]}),(0,S.jsx)(`span`,{className:`status active`,children:`latest`})]}),(0,S.jsxs)(`div`,{className:`portalInstallList`,children:[(0,S.jsxs)(`a`,{className:`installTile primaryInstall`,href:na,children:[(0,S.jsx)(`strong`,{children:`Android VPN`}),(0,S.jsx)(`span`,{children:`Последняя сборка RAP HOME VPN для телефона`}),(0,S.jsx)(`small`,{children:Vi||ta})]}),(0,S.jsxs)(`a`,{className:`installTile`,href:`${Zi}/downloads/rap-windows-rdp-client-latest-win-x64.zip`,children:[(0,S.jsx)(`strong`,{children:`Windows RDP клиент`}),(0,S.jsx)(`span`,{children:`Клиент удаленного рабочего стола, когда нужен доступ к серверам`}),(0,S.jsx)(`small`,{children:`latest win-x64`})]})]})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Профиль`}),(0,S.jsx)(A,{label:`Пользователь`,value:a.email}),(0,S.jsx)(A,{label:`Роль в организации`,value:n?.role_id||`участник`}),(0,S.jsx)(A,{label:`Организация`,value:e?.name||`нет`}),(0,S.jsx)(A,{label:`Последнее обновление`,value:_r?V(_r):`нет`})]}),(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsx)(`div`,{className:`cardHead`,children:(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:`Доступные серверы`}),(0,S.jsx)(`p`,{className:`muted`,children:`Список ресурсов, которые уже разрешены пользователю через организацию.`})]})}),(0,S.jsx)(M,{columns:[`имя`,`адрес`,`протокол`,`секрет`,`передача файлов`],rows:t.map(e=>[e.name,e.address,e.protocol,e.has_secret?`настроен`:`нет`,H(e.file_transfer_mode||`disabled`)])})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Сервисы`}),(0,S.jsx)(M,{columns:[`тип`,`количество`],rows:Object.entries(r).map(([e,t])=>[e,String(t)])})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Что здесь будет дальше`}),(0,S.jsxs)(`div`,{className:`portalRoadmap`,children:[(0,S.jsx)(`span`,{children:`Устройства и доверенные входы`}),(0,S.jsx)(`span`,{children:`Активные VPN-сессии`}),(0,S.jsx)(`span`,{children:`Обновление профиля VPN без ручных ключей`}),(0,S.jsx)(`span`,{children:`Самостоятельная смена пароля`})]})]})]})]})]})}return(0,S.jsxs)(`main`,{className:`consoleShell`,children:[(0,S.jsxs)(`aside`,{className:`sideRail`,children:[(0,S.jsx)(`div`,{className:`brandMark`,children:`SAF`}),(0,S.jsx)(`p`,{className:`sideKicker`,children:J.productOwner}),(0,S.jsx)(`h1`,{children:J.controlPlane}),(0,S.jsx)(`p`,{className:`sideText`,children:J.sideText}),(0,S.jsx)(`nav`,{className:`railNav`,children:oe.filter(e=>e.id!==`roles`).map(e=>(0,S.jsx)(`button`,{className:w===e.id?`active`:``,onClick:()=>ae(e.id),children:e[d]},e.id))})]}),(0,S.jsxs)(`section`,{className:`workspace`,children:[(0,S.jsxs)(`header`,{className:`topBar`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`p`,{className:`eyebrow`,children:`Secure Access Fabric`}),(0,S.jsx)(`h2`,{children:Yi?Yi.name:J.consoleTitle}),(0,S.jsx)(`p`,{className:`muted`,children:J.boundary})]}),(0,S.jsxs)(`div`,{className:`clusterPicker`,children:[(0,S.jsxs)(`label`,{children:[J.activeCluster,(0,S.jsx)(`select`,{value:T,onChange:e=>void Ma(e.target.value),children:ge.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))})]}),(0,S.jsxs)(`span`,{children:[J.slugLabel,`: `,Yi?.slug||`н/д`]})]}),(0,S.jsx)(`button`,{className:`primary`,onClick:()=>void da(),disabled:Tr,children:Tr?J.refreshing:J.refresh}),(0,S.jsxs)(`div`,{className:`refreshStatus`,children:[(0,S.jsx)(`strong`,{children:J.autoRefresh}),(0,S.jsx)(`span`,{children:_r?`${J.lastRefresh}: ${Rn(_r)} / ${yr.toUpperCase()}`:yr.toUpperCase()})]}),(0,S.jsxs)(`div`,{className:`profilePanel`,children:[(0,S.jsx)(`strong`,{children:J.profile}),(0,S.jsx)(`span`,{children:a.email}),(0,S.jsxs)(`span`,{children:[J.sessionMode,`: `,ho,` | `,J.sessionRefreshedAt,`: `,l?Rn(l):`н/д`]}),(0,S.jsxs)(`label`,{children:[J.language,(0,S.jsxs)(`select`,{value:d,onChange:e=>f(e.target.value),children:[(0,S.jsx)(`option`,{value:`ru`,children:`Русский`}),(0,S.jsx)(`option`,{value:`en`,children:d===`ru`?`Английский`:`English`})]})]}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>void ja(),disabled:Tr,children:J.logout})]})]}),Dr&&(0,S.jsx)(`div`,{className:`errorPanel`,children:Dr}),kr&&(0,S.jsx)(`div`,{className:`noticePanel`,children:kr}),Yi&&j.length===0&&(0,S.jsxs)(`div`,{className:`noticePanel`,children:[(0,S.jsxs)(`strong`,{children:[J.emptyLiveTitle,`.`]}),` `,J.emptyLiveText]}),w===`command`&&(0,S.jsxs)(`section`,{className:`grid five`,children:[(0,S.jsx)(fe,{label:`Кластеры`,value:ge.length,tone:`steel`}),(0,S.jsx)(fe,{label:`Узлы в области`,value:j.length,tone:`green`}),(0,S.jsx)(fe,{label:`Здоровые узлы`,value:Pa,tone:`green`}),(0,S.jsx)(fe,{label:`Ожидают подключения`,value:Na,tone:`amber`}),(0,S.jsx)(fe,{label:`Рискованные состояния`,value:Fa,tone:`red`}),(0,S.jsxs)(`article`,{className:`card span3`,children:[(0,S.jsx)(`h3`,{children:`Общее состояние кластеров`}),(0,S.jsx)(M,{columns:[`кластер`,`authority`,`ключ`,`режим изменений`,`узлы`,`заявки`,`роли`,`последний сигнал`],rows:Se.map(e=>[e.name,e.authority_state,B(e.cluster_key_fingerprint),e.mutation_mode,`${e.healthy_node_count}/${e.node_count}`,String(e.pending_join_count),String(e.active_role_assignment_count),V(e.last_node_seen_at)])})]}),(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsx)(`h3`,{children:`Authority выбранного кластера`}),we?(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Authority`,value:we.authority_state}),(0,S.jsx)(A,{label:`Режим изменений`,value:we.mutation_mode}),(0,S.jsx)(A,{label:`Терм`,value:String(we.term)}),(0,S.jsx)(A,{label:`Cluster key`,value:B(Xi?.cluster_key_fingerprint)}),(0,S.jsx)(A,{label:`Обновлено`,value:V(we.updated_at)})]}):(0,S.jsx)(me,{title:`Нет состояния authority`,text:`Выберите кластер, чтобы загрузить состояние authority.`})]}),(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsx)(`h3`,{children:`Граница платформы`}),(0,S.jsx)(`p`,{className:`muted`,children:`Эта панель предназначена для владельца продукта / владельца платформы. Панели организаций должны использовать безопасные проекции и не раскрывать mesh internals, peer cache, route cache, секреты или данные других tenants.`})]}),(0,S.jsxs)(`article`,{className:`card span3`,children:[(0,S.jsx)(`h3`,{children:`Текущие сигналы кластера`}),(0,S.jsxs)(`div`,{className:`signalStrip`,children:[(0,S.jsx)(O,{label:`Активные роли`,value:String(Ia)}),(0,S.jsx)(O,{label:`Отчеты сервисов`,value:String(Object.values(Qe).filter(e=>e.length>0).length)}),(0,S.jsx)(O,{label:`Наблюдения связей`,value:String(gt.length)}),(0,S.jsx)(O,{label:`Synthetic configs`,value:`${za}/${j.length}`})]})]})]}),w===`clusters`&&(0,S.jsxs)(`section`,{className:`grid two`,children:[(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:J.clusterCatalog}),(0,S.jsx)(`p`,{className:`muted`,children:J.clusterCatalogText})]}),(0,S.jsx)(`span`,{className:`pill`,children:Mn(ge.length,d)})]}),(0,S.jsxs)(`div`,{className:`clusterCatalog`,children:[ge.map(e=>{let t=Se.find(t=>t.cluster_id===e.id),n=e.id===T;return(0,S.jsxs)(`article`,{className:`clusterCard ${n?`selected`:``}`,children:[(0,S.jsxs)(`div`,{className:`clusterCardMain`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`p`,{className:`eyebrow`,children:e.region||`регион не задан`}),(0,S.jsx)(`h4`,{children:e.name}),(0,S.jsxs)(`p`,{className:`muted`,children:[J.slugLabel,`: `,(0,S.jsx)(`strong`,{children:e.slug})]})]}),(0,S.jsxs)(`div`,{className:`clusterCardActions`,children:[(0,S.jsx)(k,{value:e.status}),n?(0,S.jsx)(`span`,{className:`pill good`,children:J.selected}):(0,S.jsx)(`button`,{onClick:()=>void Ma(e.id),children:J.makeActive}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>{Ma(e.id),ae(`cluster-settings`)},children:J.openSettings})]})]}),(0,S.jsxs)(`div`,{className:`signalStrip compact`,children:[(0,S.jsx)(O,{label:`Узлы`,value:t?`${t.healthy_node_count}/${t.node_count}`:`н/д`}),(0,S.jsx)(O,{label:`Заявки`,value:String(t?.pending_join_count??`н/д`)}),(0,S.jsx)(O,{label:`Роли`,value:String(t?.active_role_assignment_count??`н/д`)}),(0,S.jsx)(O,{label:`Последний сигнал`,value:V(t?.last_node_seen_at)})]}),(0,S.jsxs)(`details`,{children:[(0,S.jsx)(`summary`,{children:J.clusterDetails}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`ID`,value:e.id}),(0,S.jsx)(A,{label:J.slugLabel,value:e.slug}),(0,S.jsx)(A,{label:`Статус`,value:H(e.status)}),(0,S.jsx)(A,{label:`Authority`,value:t?`${t.authority_state}/${t.mutation_mode}`:`неизвестно`}),(0,S.jsx)(A,{label:`Создан`,value:V(e.created_at)}),(0,S.jsx)(A,{label:`Обновлен`,value:V(e.updated_at||e.created_at)})]})]})]},e.id)}),ge.length===0&&(0,S.jsx)(me,{title:`Кластеров нет`,text:`Создайте первый кластер, затем подключите стартовый node-agent.`})]})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:J.createCluster}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[J.slugLabel,(0,S.jsx)(`input`,{value:jr.slug,onChange:e=>Mr({...jr,slug:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Название`,(0,S.jsx)(`input`,{value:jr.name,onChange:e=>Mr({...jr,name:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Регион`,(0,S.jsx)(`input`,{value:jr.region,onChange:e=>Mr({...jr,region:e.target.value})})]})]}),(0,S.jsx)(`p`,{className:`muted`,children:J.slugHelp}),(0,S.jsx)(`button`,{className:`primary`,disabled:!jr.slug||!jr.name,onClick:()=>void Y(async()=>{await q.createCluster({slug:jr.slug,name:jr.name,region:jr.region||null}),Mr({slug:``,name:``,region:``})},`Кластер создан.`),children:J.createCluster})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Что такое технический код?`}),(0,S.jsx)(`p`,{className:`muted`,children:J.slugHelp}),(0,S.jsx)(`p`,{className:`muted`,children:`Для человека основное поле — название. Для системы и операторов — технический код. Он нужен, чтобы сценарии, логи и будущие endpoint-адреса не зависели от переименования кластера.`})]})]}),w===`cluster-settings`&&(0,S.jsxs)(`section`,{className:`grid two`,children:[!Yi&&(0,S.jsx)(me,{title:`Кластер не выбран`,text:`Выберите активный кластер, чтобы открыть настройки.`}),Yi&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Identity кластера`}),(0,S.jsx)(`p`,{className:`muted`,children:`Базовые параметры хранятся в PostgreSQL. Slug остается неизменяемым идентификатором для операторов и скриптов.`}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[`ID`,(0,S.jsx)(`input`,{value:Yi.id,readOnly:!0})]}),(0,S.jsxs)(`label`,{children:[`Slug`,(0,S.jsx)(`input`,{value:Yi.slug,readOnly:!0})]}),(0,S.jsxs)(`label`,{children:[`Название`,(0,S.jsx)(`input`,{value:Nr.name,onChange:e=>Pr({...Nr,name:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Статус`,(0,S.jsxs)(`select`,{value:Nr.status,onChange:e=>Pr({...Nr,status:e.target.value}),children:[(0,S.jsx)(`option`,{value:`active`,children:`active, работает`}),(0,S.jsx)(`option`,{value:`disabled`,children:`disabled, отключен`})]})]}),(0,S.jsxs)(`label`,{children:[`Регион`,(0,S.jsx)(`input`,{value:Nr.region,onChange:e=>Pr({...Nr,region:e.target.value}),placeholder:`например ru-msk-1`})]}),(0,S.jsxs)(`label`,{children:[`Обновлен`,(0,S.jsx)(`input`,{value:V(Yi.updated_at||Yi.created_at),readOnly:!0})]})]}),(0,S.jsxs)(`label`,{className:`wideLabel`,children:[`Metadata JSON`,(0,S.jsx)(`textarea`,{value:Nr.metadataJson,onChange:e=>Pr({...Nr,metadataJson:e.target.value}),rows:8,spellCheck:!1})]}),(0,S.jsx)(`button`,{className:`primary`,disabled:!Nr.name.trim(),onClick:()=>Fn(`Сохранить базовые настройки кластера`)&&void Y(async()=>{let e=Ye(Nr.metadataJson||`{}`,`Metadata JSON`);await q.updateCluster(Yi.id,{name:Nr.name,status:Nr.status,region:Nr.region||null,metadata:e})},`Настройки кластера сохранены.`),children:`Сохранить настройки кластера`})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Authority и режим изменений`}),(0,S.jsx)(`p`,{className:`muted`,children:`Эта секция защищает кластер от split-brain: minority/read-only сегменты не должны принимать изменения политик.`}),(0,S.jsxs)(`div`,{className:`stateGrid`,children:[(0,S.jsx)(A,{label:`Authority`,value:we?.authority_state||`неизвестно`}),(0,S.jsx)(A,{label:`Mutation mode`,value:we?.mutation_mode||`неизвестно`}),(0,S.jsx)(A,{label:`Term`,value:String(we?.term??`н/д`)}),(0,S.jsx)(A,{label:`Cluster key`,value:B(Xi?.cluster_key_fingerprint)}),(0,S.jsx)(A,{label:`Последнее изменение`,value:V(we?.updated_at)})]}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[`Состояние authority`,(0,S.jsxs)(`select`,{value:Wr.authorityState,onChange:e=>Gr({...Wr,authorityState:e.target.value}),children:[(0,S.jsx)(`option`,{value:`authoritative`,children:`authoritative, основной`}),(0,S.jsx)(`option`,{value:`minority`,children:`minority, меньшинство`}),(0,S.jsx)(`option`,{value:`isolated`,children:`isolated, изолирован`}),(0,S.jsx)(`option`,{value:`recovery`,children:`recovery, восстановление`})]})]}),(0,S.jsxs)(`label`,{children:[`Режим изменений`,(0,S.jsxs)(`select`,{value:Wr.mutationMode,onChange:e=>Gr({...Wr,mutationMode:e.target.value}),children:[(0,S.jsx)(`option`,{value:`normal`,children:`normal, обычный`}),(0,S.jsx)(`option`,{value:`read_only`,children:`read_only, только чтение`}),(0,S.jsx)(`option`,{value:`recovery_override`,children:`recovery_override, восстановление`})]})]}),(0,S.jsxs)(`label`,{children:[`Примечание`,(0,S.jsx)(`input`,{value:Wr.notes,onChange:e=>Gr({...Wr,notes:e.target.value})})]})]}),(0,S.jsx)(`button`,{disabled:!T,onClick:()=>Fn(`Изменить authority state кластера`)&&void Y(()=>q.updateClusterAuthority(T,{authorityState:Wr.authorityState,mutationMode:Wr.mutationMode,notes:Wr.notes}),`Authority кластера обновлен.`),children:`Обновить authority`})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Safety / quorum`}),(0,S.jsxs)(`div`,{className:`stateGrid`,children:[(0,S.jsx)(A,{label:`Узлы`,value:String(Xi?.node_count??j.length)}),(0,S.jsx)(A,{label:`Healthy`,value:String(Xi?.healthy_node_count??Pa)}),(0,S.jsx)(A,{label:`Pending join`,value:String(Xi?.pending_join_count??je.filter(e=>e.status===`pending`).length)}),(0,S.jsx)(A,{label:`Последний узел`,value:V(Xi?.last_node_seen_at)})]}),(0,S.jsx)(`p`,{className:`muted`,children:`Минимальный размер, quorum policy и split-brain rules пока не имеют отдельного runtime-переключателя. Сейчас защита выполняется через authority/mutation mode, explicit node approval и аудит.`})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Telemetry / testing`}),(0,S.jsxs)(`div`,{className:`stateGrid`,children:[(0,S.jsx)(A,{label:`Telemetry flag`,value:La?.telemetry_enabled?`включен`:`выключен`}),(0,S.jsx)(A,{label:`Synthetic links`,value:La?.synthetic_links_enabled?`включены`:`выключены`}),(0,S.jsx)(A,{label:`Хранение истории, часов`,value:String(La?.history_retention_hours??`н/д`)})]}),(0,S.jsx)(`p`,{className:`muted`,children:`Это тестовый контур наблюдаемости: heartbeat/telemetry реальные, а связи Fabric сейчас synthetic. Production mesh traffic здесь пока не отображается.`})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Storage / updates`}),(0,S.jsxs)(`div`,{className:`stateGrid`,children:[(0,S.jsx)(A,{label:`Version Storage`,value:`архитектура зафиксирована, runtime не реализован`}),(0,S.jsx)(A,{label:`Update cache`,value:`${Ft(`update-cache`,We).length} узл.`}),(0,S.jsx)(A,{label:`File/config cache`,value:`${Ft(`file-storage-cache`,We).length} узл.`})]}),(0,S.jsx)(`p`,{className:`muted`,children:`Version Storage будет хранить stable/current/candidate и signed artifacts. Сейчас это не production updater runtime.`})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Admin endpoints`}),(0,S.jsxs)(`div`,{className:`stateGrid`,children:[(0,S.jsx)(A,{label:`Entry nodes`,value:`${Ft(`entry-node`,We).length} узл.`}),(0,S.jsx)(A,{label:`Relay nodes`,value:`${Ft(`relay-node`,We).length} узл.`}),(0,S.jsx)(A,{label:`Core mesh`,value:`${Ft(`core-mesh`,We).length} узл.`})]}),(0,S.jsx)(`p`,{className:`muted`,children:`Панель кластера не переезжает автоматически на storage-узел. Cluster Admin Endpoint должен быть назначен отдельной explicit ролью на ingress/admin-capable узле.`})]})]})]}),w===`nodes`&&(0,S.jsxs)(`section`,{className:`grid two`,children:[(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:J.nodeManagement}),(0,S.jsx)(`p`,{className:`muted`,children:`Единый краткий список узлов. По умолчанию показан активный кластер; включите общий режим, чтобы увидеть весь инвентарь платформы.`})]}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:Jr===`all`,onChange:e=>Yr(e.target.checked?`all`:`cluster`)}),J.showAllPlatformNodes]}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>{Yr(`all`),Zr(``)},children:J.showAllPlatformNodes})]})]}),(0,S.jsxs)(`div`,{className:`signalStrip compact`,children:[(0,S.jsx)(O,{label:`Узлы активного кластера`,value:String(j.length)}),(0,S.jsx)(O,{label:`Все узлы`,value:String(aa.length)}),(0,S.jsx)(O,{label:`Заявки`,value:String(Na)}),(0,S.jsx)(O,{label:`Активные роли`,value:String(Ia)})]}),(0,S.jsx)(`p`,{className:`muted`,children:J.addNodeText})]}),(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:J.nodeBriefList}),(0,S.jsx)(`p`,{className:`muted`,children:J.nodeBriefListHelp})]}),(0,S.jsx)(`span`,{className:`pill`,children:oa.length})]}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[J.nodeSearch,(0,S.jsx)(`input`,{value:Xr,onChange:e=>Zr(e.target.value),placeholder:J.nodeSearchPlaceholder})]}),(0,S.jsxs)(`label`,{children:[J.nodeGroupFilter,(0,S.jsxs)(`select`,{value:Qr,onChange:e=>$r(e.target.value),children:[(0,S.jsx)(`option`,{value:``,children:J.allNodeGroups}),De.map(e=>(0,S.jsx)(`option`,{value:e.id,children:jt(e,De)},e.id))]})]})]}),(0,S.jsx)(`p`,{className:`muted`,children:J.nodeGroupInventoryText}),(0,S.jsx)(`h4`,{children:J.nodeGroupCreatePanel}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[J.nodeGroupName,(0,S.jsx)(`input`,{value:Fr.name,onChange:e=>Ir({...Fr,name:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[J.parentNodeGroup,(0,S.jsxs)(`select`,{value:Fr.parentGroupId,onChange:e=>Ir({...Fr,parentGroupId:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:J.rootNodeGroup}),De.map(e=>(0,S.jsx)(`option`,{value:e.id,children:jt(e,De)},e.id))]})]}),(0,S.jsxs)(`label`,{children:[J.createNodeGroup,(0,S.jsx)(`button`,{className:`primary`,disabled:!Fr.name.trim(),onClick:()=>void Y(async()=>{await q.createNodeGroup(T,{name:Fr.name,parentGroupId:Fr.parentGroupId||null}),Ir({name:``,parentGroupId:``})},J.nodeGroupCreated),children:J.createNodeGroup})]})]}),(0,S.jsxs)(`div`,{className:`nodeList`,children:[la.map(e=>{if(e.kind===`group`){let t=ei.includes(e.key);return(0,S.jsxs)(`div`,{className:`nodeListGroup`,style:{paddingLeft:`${e.depth*18}px`},children:[(0,S.jsxs)(`div`,{className:`nodeListMain`,children:[(0,S.jsx)(`strong`,{children:e.label}),e.groupId&&(0,S.jsx)(`span`,{children:Mt(e.groupId,De)})]}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`span`,{className:`pill`,children:e.count}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>ti(gn(ei,e.key)),children:t?J.expandGroup:J.collapseGroup}),e.groupId&&(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>Ir({name:``,parentGroupId:e.groupId||``}),children:J.createSubgroup})]})]},e.key)}let t=e.entry,n=t.memberships.find(e=>e.cluster.id===T),r=n?.node||t.node,i=wt(r,rt[r.id]||[],gt),a=tt(r,Be[r.id],Re),o=ft(He[r.id]||[]),s=n?.node.membership_status===`active`,c=n?.node.membership_status===`revoked`;return(0,S.jsxs)(`div`,{className:`nodeListRow`,style:{marginLeft:`${e.depth*18}px`},children:[(0,S.jsxs)(`div`,{className:`nodeListMain`,children:[(0,S.jsx)(`strong`,{children:r.name}),(0,S.jsx)(`span`,{children:r.node_key}),(0,S.jsx)(`small`,{className:`muted`,children:i.address})]}),(0,S.jsx)(k,{value:r.health_status}),(0,S.jsx)(ye,{runtime:i}),(0,S.jsxs)(`div`,{className:`nodeEndpointCell`,children:[(0,S.jsx)(`strong`,{children:r.reported_version||`версия неизвестна`}),(0,S.jsx)(`small`,{children:a.targetLabel})]}),(0,S.jsx)(k,{value:a.status}),(0,S.jsxs)(`div`,{className:`nodeEndpointCell`,children:[(0,S.jsx)(`strong`,{className:`pill ${o.tone}`,children:o.label}),(0,S.jsx)(`small`,{children:o.detail})]}),(0,S.jsx)(`span`,{className:`muted`,children:V(r.last_seen_at)}),n?(0,S.jsx)(k,{value:n.node.membership_status}):(0,S.jsx)(`span`,{className:`muted`,children:J.notMemberOfActiveCluster}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{onClick:()=>{li(t),di(`details`)},children:J.nodeDetails}),s?(0,S.jsxs)(S.Fragment,{children:[(0,S.jsx)(`button`,{className:`primary`,onClick:()=>{li(t),di(`manage`)},children:J.manageNode}),(0,S.jsx)(`button`,{className:`danger`,onClick:()=>Fn(`Удалить узел ${r.name} из кластера`)&&void Y(()=>q.deleteClusterNode(T,r.id,`Удалено из списка узлов панели владельца платформы.`),`Узел удален из кластера.`),children:`Удалить`})]}):c?(0,S.jsx)(`span`,{className:`muted`,children:J.revokedMembership}):(0,S.jsx)(`button`,{className:`primary`,onClick:()=>{ai(t),si([])},children:J.connectExistingNode})]})]},e.key)}),la.length===0&&(0,S.jsx)(me,{title:J.noNodesTitle,text:J.noNodesByFilter})]})]}),ii&&(0,S.jsx)(`div`,{className:`modalBackdrop`,role:`presentation`,children:(0,S.jsxs)(`div`,{className:`modalCard`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`attach-node-title`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{id:`attach-node-title`,children:J.connectExistingNodeTitle}),(0,S.jsx)(`p`,{className:`muted`,children:J.connectExistingNodeText})]}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>ai(null),children:J.cancel})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Узел`,value:ii.node.name}),(0,S.jsx)(A,{label:`Node key`,value:ii.node.node_key}),(0,S.jsx)(A,{label:J.activeCluster,value:Yi?.name||T})]}),(0,S.jsx)(`div`,{className:`checkGrid`,children:ie.map(e=>(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:oi.includes(e),onChange:()=>si(gn(oi,e))}),$e(e)]},e))}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{className:`primary`,onClick:()=>void Y(async()=>{await q.attachExistingNode(T,ii.node.id,oi),ai(null),si([]),Yr(`cluster`)},`Узел подключен к активному кластеру.`),children:J.connectWithRoles}),(0,S.jsx)(`button`,{onClick:()=>ai(null),children:J.cancel})]})]})}),ci&&(()=>{let e=ci.memberships.find(e=>e.cluster.id===T),t=e?.node||ci.node,n=e?(rt[t.id]||[])[0]:void 0,r=e?(We[t.id]||[]).filter(e=>e.status===`active`):[],i=e&&Ke[t.id]||[],a=e&&Qe[t.id]||[];return(0,S.jsx)(`div`,{className:`modalBackdrop`,role:`presentation`,children:(0,S.jsxs)(`div`,{className:`modalCard wide`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`node-info-title`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsxs)(`h3`,{id:`node-info-title`,children:[ui===`manage`?J.manageNode:J.nodeDetails,`: `,t.name]}),(0,S.jsx)(`p`,{className:`muted`,children:t.node_key})]}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>{li(null),di(`details`)},children:J.close})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:J.nodeIdentity}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Node ID`,value:B(t.id)}),(0,S.jsx)(A,{label:`Ключ узла`,value:t.node_key}),(0,S.jsx)(A,{label:`Тип владения`,value:H(t.ownership_type)}),(0,S.jsx)(A,{label:`Owner org`,value:B(t.owner_organization_id)}),(0,S.jsx)(A,{label:`Регистрация`,value:H(t.registration_status)}),(0,S.jsx)(A,{label:`Здоровье`,value:H(t.health_status)}),(0,S.jsx)(A,{label:`Версия`,value:t.reported_version||`неизвестно`}),(0,S.jsx)(A,{label:`Последний сигнал`,value:V(t.last_seen_at)})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:J.clusterMemberships}),(0,S.jsx)(`div`,{className:`membershipList`,children:ci.memberships.map(e=>(0,S.jsxs)(`span`,{className:e.cluster.id===T?`pill good`:`pill`,children:[e.cluster.name,`: `,H(e.node.membership_status)]},e.cluster.id))})]}),e?(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:J.activeClusterScope}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Участие`,value:H(t.membership_status)}),(0,S.jsx)(A,{label:`Сегмент`,value:H(t.partition_state)}),(0,S.jsx)(A,{label:`Группа`,value:t.node_group_name||J.ungroupedNodes}),(0,S.jsx)(A,{label:`Ролей`,value:String(r.length)}),(0,S.jsx)(A,{label:`Desired-сервисов`,value:String(i.length)}),(0,S.jsx)(A,{label:`Observed-сервисов`,value:String(a.length)})]})]}),ui===`details`&&(0,S.jsx)(_e,{node:t,memberships:ci.memberships,activeRoles:r,desiredWorkloads:i,observedWorkloads:a,heartbeats:rt[t.id]||[],telemetry:ot[t.id]||[],updatePlan:Be[t.id],updateStatuses:He[t.id]||[],meshLinks:gt.filter(e=>e.source_node_id===t.id||e.target_node_id===t.id),syntheticConfig:St[t.id],allNodes:j,onSetUpdatePolicy:(e,t,n)=>void Y(async()=>{await q.upsertNodeUpdatePolicy(T,e.id,{product:t,channel:`dev`,targetVersion:n,strategy:`rolling`,enabled:!0,rollbackAllowed:!0,healthWindowSeconds:180})},n?`${t} поставлен в target ${n}.`:`${t} будет следовать latest dev.`),labels:J}),ui===`manage`&&(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:J.nodeFunctions}),(0,S.jsx)(`p`,{className:`muted`,children:J.nodeFunctionsText}),(0,S.jsxs)(`label`,{className:`wideLabel`,children:[J.organizationScopeForEnable,(0,S.jsx)(`input`,{value:Kr,onChange:e=>qr(e.target.value),placeholder:J.clusterWideRolePlaceholder})]}),(0,S.jsx)(`div`,{className:`functionList`,children:ie.map(e=>{let o=r.find(t=>t.role===e),s=i.find(t=>t.service_type===e),c=a.find(t=>t.service_type===e),l=xn(e,n),u=s?.desired_state||`not_configured`,f=c?.reported_state||`missing`,p=!!o&&u===`enabled`;return(0,S.jsxs)(`div`,{className:`functionRow`,children:[(0,S.jsxs)(`div`,{className:`nodeListMain`,children:[(0,S.jsx)(`strong`,{children:$e(e)}),(0,S.jsx)(`span`,{children:Cn(e,n,d)})]}),(0,S.jsx)(pe,{label:J.rolePermission,value:o?J.permissionGranted:J.permissionDenied,tone:o?`info`:``}),(0,S.jsx)(pe,{label:J.desiredRuntime,value:H(u),tone:u===`enabled`?`good`:``}),(0,S.jsx)(pe,{label:J.observedRuntime,value:H(f),tone:f===`running`?`good`:f===`missing`?`warn`:``}),(0,S.jsx)(`span`,{className:`pill ${l}`,children:Sn(e,n,J)}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{className:p?``:`primary`,disabled:p,onClick:()=>void Y(async()=>{o||await q.setRoleStatus(T,t.id,e,`active`,Kr||void 0),await q.setDesiredWorkload(T,t.id,e,{desiredState:`enabled`,runtimeMode:`container`,config:{},environment:{}})},`${e}: функция включена.`),children:J.enableFunction}),(0,S.jsx)(`button`,{disabled:!o&&u!==`enabled`,onClick:()=>void Y(async()=>{await q.setDesiredWorkload(T,t.id,e,{desiredState:`disabled`,runtimeMode:s?.runtime_mode||`container`,config:s?.config||{},environment:s?.environment||{}}),o&&await q.setRoleStatus(T,t.id,e,`disabled`,o.organization_id||void 0)},`${e}: функция выключена.`),children:J.disableFunction})]})]},e)})}),(()=>{let e=i.find(e=>e.service_type===`mesh-listener`)?.config||{},n=gi[t.id]||{listenAddr:String(e.listen_addr||`:19131`),mode:String(e.listen_port_mode||`auto`),autoRange:`${Number(e.auto_port_start||19131)}-${Number(e.auto_port_end||19231)}`,advertiseEndpoint:String(e.advertise_endpoint||``),advertiseTransport:String(e.advertise_transport||`direct_http`),connectivity:String(e.connectivity_mode||`private_lan`),nat:String(e.nat_type||`none`),region:String(e.region||``)},r=e=>_i({...gi,[t.id]:{...n,...e}});return(0,S.jsxs)(`section`,{className:`nodePanel nestedPanel`,children:[(0,S.jsx)(`h4`,{children:`Mesh listener`}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[`Listen addr`,(0,S.jsx)(`input`,{value:n.listenAddr,onChange:e=>r({listenAddr:e.target.value}),placeholder:`0.0.0.0:19131 или :19131`})]}),(0,S.jsxs)(`label`,{children:[`Port mode`,(0,S.jsxs)(`select`,{value:n.mode,onChange:e=>r({mode:e.target.value}),children:[(0,S.jsx)(`option`,{value:`auto`,children:`auto`}),(0,S.jsx)(`option`,{value:`manual`,children:`manual`}),(0,S.jsx)(`option`,{value:`disabled`,children:`disabled`})]})]}),(0,S.jsxs)(`label`,{children:[`Auto ports`,(0,S.jsx)(`input`,{value:n.autoRange,onChange:e=>r({autoRange:e.target.value}),placeholder:`19131-19231`})]}),(0,S.jsxs)(`label`,{children:[`Advertise endpoint`,(0,S.jsx)(`input`,{value:n.advertiseEndpoint,onChange:e=>r({advertiseEndpoint:e.target.value}),placeholder:`http://external-or-lan-ip:19131`})]}),(0,S.jsxs)(`label`,{children:[`Advertise transport`,(0,S.jsxs)(`select`,{value:n.advertiseTransport,onChange:e=>r({advertiseTransport:e.target.value}),children:[(0,S.jsx)(`option`,{value:`direct_http`,children:`direct_http`}),(0,S.jsx)(`option`,{value:`direct_https`,children:`direct_https`}),(0,S.jsx)(`option`,{value:`wss`,children:`wss`})]})]}),(0,S.jsxs)(`label`,{children:[`Connectivity`,(0,S.jsxs)(`select`,{value:n.connectivity,onChange:e=>r({connectivity:e.target.value}),children:[(0,S.jsx)(`option`,{value:`private_lan`,children:`private_lan`}),(0,S.jsx)(`option`,{value:`direct`,children:`direct`}),(0,S.jsx)(`option`,{value:`outbound_only`,children:`outbound_only`}),(0,S.jsx)(`option`,{value:`relay_required`,children:`relay_required`})]})]}),(0,S.jsxs)(`label`,{children:[`NAT`,(0,S.jsxs)(`select`,{value:n.nat,onChange:e=>r({nat:e.target.value}),children:[(0,S.jsx)(`option`,{value:`none`,children:`none`}),(0,S.jsx)(`option`,{value:`unknown`,children:`unknown`}),(0,S.jsx)(`option`,{value:`port_restricted`,children:`port_restricted`}),(0,S.jsx)(`option`,{value:`symmetric`,children:`symmetric`})]})]}),(0,S.jsxs)(`label`,{children:[`Region/site`,(0,S.jsx)(`input`,{value:n.region,onChange:e=>r({region:e.target.value}),placeholder:`dc1, office, docker-test`})]})]}),(0,S.jsx)(`div`,{className:`actions`,children:(0,S.jsx)(`button`,{className:`primary`,onClick:()=>void Y(async()=>{let[e,r]=n.autoRange.split(`-`).map(e=>Number(e.trim())),i=Number.isFinite(e)?e:19131,a=Number.isFinite(r)?r:i;await q.setDesiredWorkload(T,t.id,`mesh-listener`,{desiredState:n.mode===`disabled`?`disabled`:`enabled`,version:`listener-${Date.now()}`,runtimeMode:`container`,config:{listen_addr:n.listenAddr,listen_port_mode:n.mode,auto_port_start:i,auto_port_end:a,advertise_endpoint:n.advertiseEndpoint.trim().replace(/\/$/,``)||null,advertise_transport:n.advertiseTransport||`direct_http`,connectivity_mode:n.connectivity,nat_type:n.nat,region:n.region||null},environment:{}})},`Mesh listener config обновлен.`),children:`Применить listener`})})]})})(),(0,S.jsx)(`div`,{className:`actions`,children:(0,S.jsxs)(`select`,{value:t.node_group_id||``,onChange:e=>void Y(()=>q.assignNodeGroup(T,t.id,e.target.value||null),e.target.value?`Узел перемещен в группу.`:`Узел убран из группы.`),children:[(0,S.jsx)(`option`,{value:``,children:J.ungroupedNodes}),De.map(e=>(0,S.jsx)(`option`,{value:e.id,children:jt(e,De)},e.id))]})}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{onClick:()=>Fn(`Отключить участие узла ${t.name}`)&&void Y(()=>q.disableMembership(T,t.id,`Отключено из панели владельца платформы.`),`Участие узла отключено.`),children:`Отключить участие`}),(0,S.jsx)(`button`,{className:`danger`,onClick:()=>Fn(`Отозвать identity узла ${t.name}`)&&void Y(()=>q.revokeNodeIdentity(T,t.id,`Отозвано из панели владельца платформы.`),`Identity узла отозван.`),children:`Отозвать identity`})]})]})]}):(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:J.noActiveClusterMembership}),(0,S.jsx)(`div`,{className:`actions`,children:(0,S.jsx)(`button`,{className:`primary`,onClick:()=>{ai(ci),si([]),li(null)},children:J.connectExistingNode})})]})]})})})(),!1]}),w===`enrollment`&&(0,S.jsxs)(`section`,{className:`grid two`,children:[(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:J.joinTokenTitle}),(0,S.jsx)(`p`,{className:`muted`,children:J.joinTokenText}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[J.ttlHours,(0,S.jsx)(`input`,{type:`number`,min:1,max:720,value:U.ttlHours,onChange:e=>Vr({...U,ttlHours:Number(e.target.value)})}),(0,S.jsx)(`small`,{children:J.ttlHelp})]}),(0,S.jsxs)(`label`,{children:[J.maxUses,(0,S.jsx)(`input`,{type:`number`,min:1,max:100,value:U.maxUses,onChange:e=>Vr({...U,maxUses:Number(e.target.value)})}),(0,S.jsx)(`small`,{children:J.maxUsesHelp})]}),(0,S.jsxs)(`label`,{children:[J.nodeOwnership,(0,S.jsxs)(`select`,{value:U.ownershipType,onChange:e=>Vr({...U,ownershipType:e.target.value}),children:[(0,S.jsx)(`option`,{value:`platform_managed`,children:`platform_managed, управляется платформой`}),(0,S.jsx)(`option`,{value:`customer_managed`,children:`customer_managed, управляется клиентом`})]})]}),(0,S.jsxs)(`label`,{children:[J.tokenPurpose,(0,S.jsx)(`input`,{value:U.purpose,onChange:e=>Vr({...U,purpose:e.target.value}),placeholder:`например: стартовый entry-node в ru-msk-1`})]}),(0,S.jsxs)(`label`,{children:[`Имя нового узла`,(0,S.jsx)(`input`,{value:U.nodeName,onChange:e=>Vr({...U,nodeName:e.target.value}),placeholder:Gt(U,Yi)}),(0,S.jsx)(`small`,{children:`Если оставить пустым, панель подставит имя автоматически.`})]}),(0,S.jsxs)(`label`,{children:[`Группа узла`,(0,S.jsxs)(`select`,{value:U.nodeGroupId,onChange:e=>Vr({...U,nodeGroupId:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:`Без группы`}),De.map(e=>(0,S.jsx)(`option`,{value:e.id,children:jt(e,De)},e.id))]})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Install profile`}),(0,S.jsx)(`p`,{className:`muted`,children:`Эти поля попадут в install profile. Для Windows без админ-прав будет создан user startup task, с админ-правами - system startup task.`}),(0,S.jsx)(`div`,{className:`segmented`,children:[[`docker`,`Docker Linux`],[`linux_binary`,`Ubuntu service`],[`windows_service`,`Windows`]].map(([e,t])=>(0,S.jsx)(`button`,{type:`button`,className:U.installMode===e?`active`:``,onClick:()=>Vr({...U,installMode:e}),children:t},e))}),(0,S.jsx)(`div`,{className:`segmented`,children:[[`private_lan`,`LAN`],[`direct`,`Public`],[`nat_forward`,`NAT`],[`outbound_only`,`Outbound`]].map(([e,t])=>(0,S.jsx)(`button`,{type:`button`,className:Ut(U)===e?`active`:``,onClick:()=>Vr(Wt(U,e)),children:t},e))}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[`Control-plane endpoint`,(0,S.jsx)(`input`,{value:U.controlPlaneEndpoint,onChange:e=>Vr({...U,controlPlaneEndpoint:e.target.value}),placeholder:Bt()})]}),(0,S.jsxs)(`label`,{children:[U.installMode===`windows_service`?`Windows node-agent artifact`:U.installMode===`linux_binary`?`Linux node-agent artifact`:`Docker image`,(0,S.jsx)(`input`,{value:U.dockerImage,onChange:e=>Vr({...U,dockerImage:e.target.value})})]}),U.installMode===`windows_service`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`label`,{children:[`Windows startup`,(0,S.jsxs)(`select`,{value:U.windowsStartupMode,onChange:e=>Vr({...U,windowsStartupMode:e.target.value}),children:[(0,S.jsx)(`option`,{value:`auto`,children:`auto: system task, fallback user task`}),(0,S.jsx)(`option`,{value:`system-task`,children:`system task, admin required`}),(0,S.jsx)(`option`,{value:`user-task`,children:`user task, no admin`}),(0,S.jsx)(`option`,{value:`none`,children:`none`})]})]}),(0,S.jsxs)(`label`,{children:[`Install dir`,(0,S.jsx)(`input`,{value:U.windowsInstallDir,onChange:e=>Vr({...U,windowsInstallDir:e.target.value}),placeholder:`C:\\\\Program Files\\\\RAP\\\\node-name`})]}),(0,S.jsxs)(`label`,{children:[`Windows node-agent SHA256`,(0,S.jsx)(`input`,{value:U.windowsNodeAgentSHA256,onChange:e=>Vr({...U,windowsNodeAgentSHA256:e.target.value}),placeholder:`опционально, но желательно для production`})]})]}),U.installMode===`linux_binary`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`label`,{children:[`Linux install dir`,(0,S.jsx)(`input`,{value:U.linuxInstallDir,onChange:e=>Vr({...U,linuxInstallDir:e.target.value}),placeholder:`/opt/rap/node-name`})]}),(0,S.jsxs)(`label`,{children:[`Linux node-agent SHA256`,(0,S.jsx)(`input`,{value:U.linuxNodeAgentSHA256,onChange:e=>Vr({...U,linuxNodeAgentSHA256:e.target.value}),placeholder:`опционально, но желательно для production`})]})]}),U.installMode===`docker`&&(0,S.jsxs)(`label`,{children:[`Container name`,(0,S.jsx)(`input`,{value:U.dockerContainerName,onChange:e=>Vr({...U,dockerContainerName:e.target.value}),placeholder:Kt(U,Yi)})]}),(0,S.jsxs)(`label`,{children:[`Artifact endpoints`,(0,S.jsx)(`input`,{value:U.artifactEndpoints,onChange:e=>Vr({...U,artifactEndpoints:e.target.value}),placeholder:Vt()}),(0,S.jsx)(`small`,{children:`Через запятую: public/LAN/cache узлы, где host-agent сможет скачать image tar до входа в mesh.`})]}),U.installMode===`docker`&&(0,S.jsxs)(`label`,{children:[`Docker image tar SHA256`,(0,S.jsx)(`input`,{value:U.dockerImageArtifactSHA256,onChange:e=>Vr({...U,dockerImageArtifactSHA256:e.target.value}),placeholder:`опционально, но желательно для production`})]}),U.installMode===`docker`&&(0,S.jsxs)(`label`,{children:[`Docker network`,(0,S.jsxs)(`select`,{value:U.dockerNetwork,onChange:e=>Vr({...U,dockerNetwork:e.target.value}),children:[(0,S.jsx)(`option`,{value:`host`,children:`host`}),(0,S.jsx)(`option`,{value:`bridge`,children:`bridge`})]})]}),(0,S.jsxs)(`label`,{children:[`Listen addr`,(0,S.jsx)(`input`,{value:U.meshListenAddr,onChange:e=>Vr({...U,meshListenAddr:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Listen mode`,(0,S.jsxs)(`select`,{value:U.meshListenPortMode,onChange:e=>Vr({...U,meshListenPortMode:e.target.value}),children:[(0,S.jsx)(`option`,{value:`auto`,children:`auto`}),(0,S.jsx)(`option`,{value:`manual`,children:`manual`}),(0,S.jsx)(`option`,{value:`disabled`,children:`disabled`})]})]}),(0,S.jsxs)(`label`,{children:[`Auto ports`,(0,S.jsx)(`input`,{value:`${U.meshListenAutoPortStart}-${U.meshListenAutoPortEnd}`,onChange:e=>{let[t,n]=e.target.value.split(`-`).map(e=>Number(e.trim()));Vr({...U,meshListenAutoPortStart:Number.isFinite(t)?t:U.meshListenAutoPortStart,meshListenAutoPortEnd:Number.isFinite(n)?n:U.meshListenAutoPortEnd})}})]}),(0,S.jsxs)(`label`,{children:[`Advertise endpoint`,(0,S.jsx)(`input`,{value:U.meshAdvertiseEndpoint,onChange:e=>Vr({...U,meshAdvertiseEndpoint:e.target.value}),placeholder:`http://public-or-private-ip:19131`})]}),(0,S.jsxs)(`label`,{children:[`Connectivity`,(0,S.jsxs)(`select`,{value:U.meshConnectivityMode,onChange:e=>Vr({...U,meshConnectivityMode:e.target.value}),children:[(0,S.jsx)(`option`,{value:`direct`,children:`direct`}),(0,S.jsx)(`option`,{value:`private_lan`,children:`private_lan`}),(0,S.jsx)(`option`,{value:`outbound_only`,children:`outbound_only`}),(0,S.jsx)(`option`,{value:`relay_required`,children:`relay_required`})]})]}),(0,S.jsxs)(`label`,{children:[`NAT`,(0,S.jsxs)(`select`,{value:U.meshNATType,onChange:e=>Vr({...U,meshNATType:e.target.value}),children:[(0,S.jsx)(`option`,{value:`none`,children:`none`}),(0,S.jsx)(`option`,{value:`unknown`,children:`unknown`}),(0,S.jsx)(`option`,{value:`full_cone`,children:`full_cone`}),(0,S.jsx)(`option`,{value:`port_restricted`,children:`port_restricted`}),(0,S.jsx)(`option`,{value:`symmetric`,children:`symmetric`})]})]}),(0,S.jsxs)(`label`,{children:[`Region/site`,(0,S.jsx)(`input`,{value:U.meshRegion,onChange:e=>Vr({...U,meshRegion:e.target.value})})]}),U.installMode===`docker`&&(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:U.pullImage,onChange:e=>Vr({...U,pullImage:e.target.checked})}),`Pull image`]}),(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:U.replace,onChange:e=>Vr({...U,replace:e.target.checked})}),`Replace existing install`]})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:J.suggestedRoles}),(0,S.jsx)(`p`,{className:`muted`,children:`Роли записываются в install token и автоматически назначаются узлу при approval. После создания token изменение чекбоксов не меняет уже выданный token.`}),(0,S.jsx)(`div`,{className:`checkGrid`,children:ie.map(e=>(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:U.roles.includes(e),onChange:()=>Vr({...U,roles:gn(U.roles,e)})}),$e(e)]},e))})]}),(0,S.jsxs)(`details`,{children:[(0,S.jsx)(`summary`,{children:J.generatedScope}),(0,S.jsx)(`p`,{className:`muted`,children:J.generatedScopeHelp}),(0,S.jsx)(`pre`,{className:`codePreview`,children:JSON.stringify(ra,null,2)})]}),(0,S.jsxs)(`p`,{className:`muted`,children:[J.manualApprovalRequired,`.`]}),(0,S.jsx)(`button`,{className:`primary`,disabled:!T,onClick:()=>void Y(async()=>{Ur(await q.createJoinToken(T,{ttlHours:U.ttlHours,maxUses:U.maxUses,scope:ra}))},`Join token создан.`),children:`Создать install token`}),Hr&&(0,S.jsxs)(`div`,{className:`secretOnce`,children:[(0,S.jsx)(`strong`,{children:`Исходный token, возвращается один раз`}),(0,S.jsx)(`code`,{children:Hr.token}),(0,S.jsxs)(`span`,{className:`muted`,children:[`Authority key: `,B(Hr.authority_signature?.key_fingerprint)]}),(0,S.jsx)(`strong`,{children:`Scope выданного token`}),(0,S.jsx)(`pre`,{className:`codePreview`,children:JSON.stringify(Hr.scope,null,2)}),(0,S.jsx)(`strong`,{children:`Docker host-agent install`}),(0,S.jsx)(`pre`,{className:`codePreview`,children:qt(Hr,Yi,ia)}),(0,S.jsx)(`strong`,{children:`Profile-based Docker install`}),(0,S.jsx)(`pre`,{className:`codePreview`,children:Jt(Hr,Yi,ia)}),(0,S.jsx)(`strong`,{children:`Profile-based Ubuntu service install`}),(0,S.jsx)(`pre`,{className:`codePreview`,children:Yt(Hr,Yi,ia)}),(0,S.jsx)(`strong`,{children:`Profile-based Windows PowerShell install`}),(0,S.jsx)(`pre`,{className:`codePreview`,children:Xt(Hr,Yi,ia)}),(0,S.jsx)(`strong`,{children:`Profile-based Windows CMD install`}),(0,S.jsx)(`pre`,{className:`codePreview`,children:Zt(Hr,Yi,ia)})]})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Как добавить узел`}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsxs)(`div`,{className:`stateLine`,children:[(0,S.jsx)(`span`,{children:`1`}),(0,S.jsx)(`strong`,{children:`Заполните Docker install profile слева.`})]}),(0,S.jsxs)(`div`,{className:`stateLine`,children:[(0,S.jsx)(`span`,{children:`2`}),(0,S.jsx)(`strong`,{children:`Нажмите “Создать install token”.`})]}),(0,S.jsxs)(`div`,{className:`stateLine`,children:[(0,S.jsx)(`span`,{children:`3`}),(0,S.jsx)(`strong`,{children:`Скопируйте “Profile-based Docker install” и выполните на Docker-хосте.`})]}),(0,S.jsxs)(`div`,{className:`stateLine`,children:[(0,S.jsx)(`span`,{children:`4`}),(0,S.jsx)(`strong`,{children:`Подтвердите join request в этой же вкладке.`})]})]})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Install tokens`}),(0,S.jsx)(M,{columns:[`scope`,`status`,`uses`,`expires`,`created`,`action`],rows:Ne.map(e=>[mt(e),H(e.status),`${e.used_count}/${e.max_uses}`,V(e.expires_at),V(e.created_at),e.status===`active`?(0,S.jsx)(`button`,{className:`danger`,onClick:()=>Fn(`Отозвать install token ${B(e.id)}`)&&void Y(()=>q.revokeJoinToken(T,e.id),`Install token отозван.`),children:`Отозвать`}):(0,S.jsx)(`span`,{className:`muted`,children:H(e.status)})])})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Заявки на подключение`}),(0,S.jsxs)(`div`,{className:`stack`,children:[je.map(e=>(0,S.jsxs)(`div`,{className:`requestCard`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`strong`,{children:e.node_name}),(0,S.jsx)(`p`,{children:e.node_fingerprint}),(0,S.jsx)(k,{value:e.status}),e.approval_signature?.key_fingerprint&&(0,S.jsxs)(`small`,{className:`muted`,children:[`approval key `,B(e.approval_signature.key_fingerprint)]})]}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{disabled:e.status!==`pending`,onClick:()=>void Y(()=>q.approveJoinRequest(T,e.id),`Заявка одобрена.`),children:`Одобрить`}),(0,S.jsx)(`button`,{disabled:e.status!==`pending`,onClick:()=>void Y(()=>q.rejectJoinRequest(T,e.id,`Отклонено из панели владельца платформы.`),`Заявка отклонена.`),children:`Отклонить`})]})]},e.id)),je.length===0&&(0,S.jsx)(me,{title:`Нет заявок`,text:`Новые подключения node-agent появятся здесь.`})]})]})]}),w===`roles`&&(0,S.jsxs)(`section`,{className:`stack`,children:[(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Область ролей`}),(0,S.jsx)(`p`,{className:`muted`,children:`Capabilities — технические факты. Роли — явные разрешения. Область организации может ограничивать сервисные роли.`}),(0,S.jsxs)(`label`,{children:[`UUID организации для новых назначений ролей, опционально`,(0,S.jsx)(`input`,{value:Kr,onChange:e=>qr(e.target.value),placeholder:`пусто = роль на весь кластер`})]})]}),j.map(e=>(0,S.jsxs)(`article`,{className:`card roleRow`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:e.name}),(0,S.jsx)(`p`,{children:Ze(We[e.id]||[])})]}),(0,S.jsxs)(`select`,{defaultValue:``,onChange:t=>{let n=t.target.value;t.currentTarget.value=``,n&&Y(()=>q.assignRole(T,e.id,n,Kr||void 0),`${n} назначена узлу ${e.name}.`)},children:[(0,S.jsx)(`option`,{value:``,children:`Назначить роль...`}),ie.map(e=>(0,S.jsx)(`option`,{value:e,children:$e(e)},e))]})]},e.id))]}),w===`workloads`&&(0,S.jsxs)(`section`,{className:`grid two`,children:[(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Желаемое состояние сервиса`}),(0,S.jsx)(`p`,{className:`muted`,children:`Здесь задается только желаемое состояние. Runtime-исполнение остается под контролем node-agent и политик.`}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[`Узел`,(0,S.jsxs)(`select`,{value:wi.nodeId,onChange:e=>Ti({...wi,nodeId:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:`Выберите узел...`}),j.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,S.jsxs)(`label`,{children:[`Сервис`,(0,S.jsx)(`select`,{value:wi.serviceType,onChange:e=>Ti({...wi,serviceType:e.target.value}),children:ie.map(e=>(0,S.jsx)(`option`,{value:e,children:$e(e)},e))})]}),(0,S.jsxs)(`label`,{children:[`Желаемое состояние`,(0,S.jsxs)(`select`,{value:wi.desiredState,onChange:e=>Ti({...wi,desiredState:e.target.value}),children:[(0,S.jsx)(`option`,{value:`enabled`,children:`включено`}),(0,S.jsx)(`option`,{value:`disabled`,children:`выключено`})]})]}),(0,S.jsxs)(`label`,{children:[`Режим runtime`,(0,S.jsxs)(`select`,{value:wi.runtimeMode,onChange:e=>Ti({...wi,runtimeMode:e.target.value}),children:[(0,S.jsx)(`option`,{value:`container`,children:`контейнер`}),(0,S.jsx)(`option`,{value:`native`,children:`нативно`})]})]}),(0,S.jsxs)(`label`,{children:[`Версия`,(0,S.jsx)(`input`,{value:wi.version,onChange:e=>Ti({...wi,version:e.target.value})})]})]}),(0,S.jsxs)(`label`,{children:[`Config JSON`,(0,S.jsx)(`textarea`,{value:wi.configJson,onChange:e=>Ti({...wi,configJson:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Environment JSON`,(0,S.jsx)(`textarea`,{value:wi.environmentJson,onChange:e=>Ti({...wi,environmentJson:e.target.value})})]}),(0,S.jsx)(`button`,{className:`primary`,disabled:!wi.nodeId||!T,onClick:()=>void Y(()=>q.setDesiredWorkload(T,wi.nodeId,wi.serviceType,{desiredState:wi.desiredState,runtimeMode:wi.runtimeMode,version:wi.version,config:Ye(wi.configJson,`config сервиса`),environment:Ye(wi.environmentJson,`environment сервиса`)}),`Желаемое состояние сервиса обновлено.`),children:`Задать желаемое состояние`})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Отчеты сервисов`}),(0,S.jsx)(`div`,{className:`stack`,children:j.map(e=>(0,S.jsxs)(`div`,{className:`workloadBlock`,children:[(0,S.jsx)(`strong`,{children:e.name}),(Qe[e.id]||[]).length===0?(0,S.jsx)(`p`,{className:`muted`,children:`Статус пока не получен.`}):(0,S.jsx)(M,{columns:[`сервис`,`состояние`,`runtime`,`наблюдение`],rows:(Qe[e.id]||[]).map(e=>[e.service_type,e.reported_state,e.runtime_mode,V(e.observed_at)])})]},e.id))})]})]}),w===`fabric`&&(0,S.jsxs)(`section`,{className:`fabricTransportView`,children:[(0,S.jsxs)(`article`,{className:`card fabricMapCard`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:`Транспортный слой Fabric`}),(0,S.jsx)(`p`,{className:`muted`,children:`Карта показывает реальные свежие QUIC-соседства и проверенные relay/route-health маршруты. Прямые связи рисуются сплошной линией, relay и route-health отделены пунктиром, чтобы не смешивать физического соседа и достижимый маршрут.`})]}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[(0,S.jsxs)(`span`,{className:`pill good`,children:[`direct `,gt.length]}),(0,S.jsx)(`span`,{className:`pill`,children:Wa.label}),(0,S.jsx)(k,{value:La?.synthetic_links_enabled?`enabled`:`disabled`})]})]}),(0,S.jsx)(xe,{nodes:j,links:gt,heartbeatsByNode:rt,rolesByNode:We,workloadsByNode:Qe,labels:J,emptyText:J.noLinks}),(0,S.jsxs)(`details`,{className:`sectionBlock fabricDiagnostics`,children:[(0,S.jsx)(`summary`,{children:`Диагностика transport/runtime receivers`}),(0,S.jsxs)(`div`,{className:`signalStrip compact`,children:[(0,S.jsx)(O,{label:`Synthetic configs`,value:`${za}/${j.length}`}),(0,S.jsx)(O,{label:`Routes`,value:String(Ba)}),(0,S.jsx)(O,{label:`Endpoints / candidates`,value:`${Va}/${Ha}`}),(0,S.jsx)(O,{label:`Scoped production`,value:Ua===0?`false`:`true:${Ua}`})]}),(0,S.jsx)(M,{columns:[`узел`,`status`,`reason`,`trusted keys`,`service classes`,`QUIC addr`,`ошибка`],rows:j.map(e=>{let t=N(rt[e.id]?.[0]?.metadata?.web_ingress_runtime_receiver_report),n=Fe(rt[e.id]?.[0]),r=t?Rt(t.service_classes):[],i=Le(We[e.id]||[]),a=i.length>0&&!i.every(e=>r.includes(e));return[e.name,(0,S.jsx)(`span`,{className:`pill ${n===`ready`?`good`:n===`degraded`?`warn`:n===`blocked`?`bad`:``}`,children:n}),t?P(t,`reason`,`н/д`):`no report`,t?String(Tt(t,`trusted_key_count`)):`0`,(0,S.jsx)(`span`,{className:a?`pill warn`:``,children:t&&r.join(`, `)||`н/д`}),t?P(t,`quic_fabric_listen_addr`,`н/д`):`н/д`,a?`expected: ${i.join(`, `)}`:t&&(P(t,`quic_fabric_error`,``)||P(t,`error`,``))||`—`]})})]})]}),(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:`Synthetic mesh config`}),(0,S.jsx)(`p`,{className:`muted`,children:`Node-scoped config from Control Plane. Endpoint candidates and scoring inputs are visible to the platform owner only; production forwarding for service traffic must remain disabled here.`})]}),(0,S.jsxs)(`span`,{className:Ua===0?`pill good`:`pill bad`,children:[`production_forwarding=`,Ua===0?`false`:`true`]})]}),(0,S.jsx)(M,{columns:[`узел`,`config`,`routes`,`peer endpoints`,`candidates`,`peer dir`,`recovery seeds`,`rendezvous leases`,`relay policy`,`path decisions`,`authority`,`scoped production`],rows:j.map(e=>{let t=St[e.id];return[e.name,t?t.enabled?`enabled`:`disabled`:`не загружен`,String(t?.routes.length??0),String(Object.keys(t?.peer_endpoints||{}).length),String(t?Ot(t):0),String(t?.peer_directory?.length??0),String(t?.recovery_seeds?.length??0),String(t?.rendezvous_leases?.length??0),kt(t),At(t),t?.authority_required?B(t.authority_signature?.key_fingerprint):`не требуется`,t?.production_forwarding?`true`:`false`]})}),(0,S.jsx)(`p`,{className:`muted`,children:`Health-aware scoring не выбирает service route и не открывает service-соединения. C17Z19 показывает control-plane route/path decisions, route generation status, synthetic route-health effective path и relay feedback scoring, но не переносит RDP/VPN/file/video/service payload.`})]}),(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:`Route intents lifecycle`}),(0,S.jsx)(`p`,{className:`muted`,children:`Operator view for temporary fabric routes. Expired and disabled intents are not emitted into node-scoped synthetic config.`})]}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[(0,S.jsxs)(`span`,{className:`pill good`,children:[`active `,Ga.length]}),(0,S.jsxs)(`span`,{className:Ka.length>0?`pill warn`:`pill`,children:[`expired `,Ka.length]}),(0,S.jsxs)(`span`,{className:`pill`,children:[`disabled `,qa.length]})]})]}),(0,S.jsx)(M,{columns:[`route`,`life`,`service`,`priority`,`source`,`destination`,`expires`,`updated`,`actions`],rows:bt.slice(0,120).map(e=>{let t=lt(e);return[B(e.id),(0,S.jsx)(`span`,{className:`pill ${ut(e)}`,children:t}),e.service_class,String(e.priority),dt(e.source_selector||{}),dt(e.destination_selector||{}),e.policy_expires_at?V(e.policy_expires_at):`нет`,V(e.updated_at),(0,S.jsxs)(`div`,{className:`inlineActions`,children:[t===`active`?(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>q.expireRouteIntent(T,e.id,`operator expired stale route intent`),`Route intent expired.`),children:`expire`}):(0,S.jsx)(`span`,{className:`muted`,children:`expire`}),t===`disabled`?(0,S.jsx)(`span`,{className:`muted`,children:`disable`}):(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>q.disableRouteIntent(T,e.id,`operator disabled route intent`),`Route intent disabled.`),children:`disable`})]})]})}),bt.length===0&&(0,S.jsx)(me,{title:`Route intents отсутствуют`,text:`Нет настроенных fabric route intents для текущего кластера.`})]}),(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:`Service-channel route feedback`}),(0,S.jsx)(`p`,{className:`muted`,children:`Cluster-level runtime feedback from the shared fabric channel. Fenced and no-alternate cases affect route selection for any service class.`})]}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[(0,S.jsxs)(`span`,{className:Ya.length>0?`pill bad`:`pill good`,children:[`fenced `,Ya.length]}),(0,S.jsxs)(`span`,{className:Xa.length>0?`pill warn`:`pill`,children:[`degraded `,Xa.length]}),(0,S.jsxs)(`span`,{className:to.length>0?`pill warn`:`pill`,children:[`retry `,to.length]}),(0,S.jsxs)(`span`,{className:Qa.length>0?`pill warn`:`pill`,children:[`recovered `,Qa.length]}),(0,S.jsxs)(`span`,{className:$a.length>0?`pill good`:`pill`,children:[`promoted `,$a.length]}),(0,S.jsxs)(`span`,{className:eo.length>0?`pill bad`:`pill`,children:[`demoted `,eo.length]}),(0,S.jsxs)(`span`,{className:`pill good`,children:[`healthy `,Za.length]}),(0,S.jsxs)(`span`,{className:X.length>0?`pill bad`:`pill`,children:[`no alternate `,X.length]}),(0,S.jsxs)(`span`,{className:lo.length>0?`pill warn`:`pill`,children:[`hysteresis `,lo.length]}),(0,S.jsxs)(`span`,{className:uo.length>0?`pill good`:`pill`,children:[`promoted paths `,uo.length]}),(0,S.jsxs)(`span`,{className:fo.length>0?`pill bad`:`pill`,children:[`demoted paths `,fo.length]}),(0,S.jsxs)(`span`,{className:(bn?.fingerprint||``).length>0?`pill good`:`pill warn`,children:[`policy fp `,bn?.fingerprint?B(bn.fingerprint):`нет`]}),(0,S.jsxs)(`span`,{className:io.length>ao.length?`pill warn`:`pill good`,children:[`rebuild `,ao.length,`/`,io.length]}),(0,S.jsxs)(`span`,{className:so.length>0?`pill warn`:`pill good`,children:[`ledger `,oo.length,`/`,zt.length]}),(0,S.jsxs)(`span`,{className:co.length>0?`pill bad`:`pill good`,children:[`guard `,co.length]}),(0,S.jsx)(`span`,{className:mn?`pill info`:`pill`,children:mn?`deep ledger`:`fast ledger`})]})]}),X.length>0&&(0,S.jsx)(`div`,{className:`noticePanel`,children:`Есть service-channel route без unfenced alternate. Для production-сервиса это означает деградацию: fabric не нашел безопасную замену и будет ждать нового маршрута или операторского решения.`}),rn&&(0,S.jsxs)(`div`,{className:`noticePanel ${rn.status===`blocked`?`badPanel`:`goodPanel`}`,children:[(0,S.jsxs)(`div`,{className:`cardHead compact`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h4`,{children:`Fabric schema preflight`}),(0,S.jsx)(`p`,{className:`muted`,children:`Backend/runtime compatibility check for manual deploys before diagnostics or service channels depend on new DB fields.`})]}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[(0,S.jsx)(`span`,{className:`pill ${rn.status===`blocked`?`bad`:`good`}`,children:H(rn.status)}),(0,S.jsxs)(`span`,{className:rn.missing_check_count>0?`pill bad`:`pill good`,children:[rn.passed_check_count,`/`,rn.required_check_count]}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>void _a(),disabled:Tr,children:`warm snapshots`})]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`reason`,value:H(rn.reason)}),(0,S.jsx)(A,{label:`required`,value:rn.required_migration}),(0,S.jsx)(A,{label:`missing`,value:(rn.missing_checks||[]).map(e=>e.check_id).slice(0,4).join(`, `)||`нет`}),(0,S.jsx)(A,{label:`action`,value:rn.recommended_operator_action||`schema is compatible`}),cn&&(0,S.jsx)(A,{label:`warmup`,value:`warmed ${cn.warmed_count}, fresh ${cn.already_fresh_count}, missing ${cn.missing_snapshot_count}, stale ${cn.stale_snapshot_count}, deferred ${cn.deferred_stale_count}, errors ${cn.error_count}`})]})]}),on&&(0,S.jsxs)(`div`,{className:`noticePanel ${on.status===`degraded`?`warnPanel`:`goodPanel`}`,children:[(0,S.jsxs)(`div`,{className:`cardHead compact`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h4`,{children:`Snapshot maintenance`}),(0,S.jsx)(`p`,{className:`muted`,children:`Auto-warmup visibility for rebuild snapshot cache after node heartbeats.`})]}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[(0,S.jsx)(`span`,{className:`pill ${on.status===`degraded`?`warn`:`good`}`,children:H(on.status)}),(0,S.jsxs)(`span`,{className:on.overdue_missing_snapshot_count>0?`pill bad`:`pill good`,children:[`overdue `,on.overdue_missing_snapshot_count]}),(0,S.jsxs)(`span`,{className:on.auto_warmup_error_count>0?`pill bad`:`pill good`,children:[`auto errors `,on.auto_warmup_error_count]})]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`reason`,value:H(on.reason)}),(0,S.jsx)(A,{label:`snapshots`,value:`valid ${on.valid_snapshot_count}, missing ${on.missing_snapshot_count}, attempts ${on.recent_attempt_count}`}),(0,S.jsx)(A,{label:`auto-warmup`,value:`events ${on.auto_warmup_event_count}, warmed ${on.auto_warmup_warmed_count}, fresh ${on.auto_warmup_already_fresh_count}, latest ${V(on.latest_auto_warmup_at)}`}),(0,S.jsx)(A,{label:`guard`,value:`age ${on.min_age_seconds}s, heartbeats ${on.heartbeat_threshold}`}),(0,S.jsx)(A,{label:`action`,value:on.recommended_operator_action||`snapshot maintenance is current`})]}),(on.nodes||[]).length>0&&(0,S.jsx)(M,{columns:[`node`,`snapshots`,`heartbeat`,`auto-warmup`,`latest`],rows:(on.nodes||[]).slice(0,6).map(e=>[F(j,e.node_id),(0,S.jsxs)(`span`,{className:e.overdue_missing_snapshot_count>0?`pill bad`:e.missing_snapshot_count>0?`pill warn`:`pill good`,children:[e.valid_snapshot_count,`/`,e.recent_attempt_count,` overdue `,e.overdue_missing_snapshot_count]}),e.heartbeat_after_attempt_count,`${e.auto_warmup_warmed_count}/${e.auto_warmup_event_count} errors ${e.auto_warmup_error_count}`,V(e.latest_auto_warmup_at||e.last_heartbeat_at)])})]}),un&&(0,S.jsxs)(`div`,{className:`noticePanel ${un.status===`degraded`?`warnPanel`:`goodPanel`}`,children:[(0,S.jsxs)(`div`,{className:`cardHead compact`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h4`,{children:`Service-channel leases`}),(0,S.jsx)(`p`,{className:`muted`,children:`Durable compatibility lease records for introspection after backend restarts.`})]}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[(0,S.jsx)(`span`,{className:`pill ${un.status===`degraded`?`warn`:`good`}`,children:H(un.status)}),(0,S.jsxs)(`span`,{className:`pill good`,children:[`active `,un.active_count]}),(0,S.jsxs)(`span`,{className:un.expired_count>0?`pill warn`:`pill`,children:[`expired `,un.expired_count]}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>void va(),disabled:Tr,children:`cleanup`})]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`reason`,value:H(un.reason)}),(0,S.jsx)(A,{label:`scanned`,value:`${un.scanned_count}/${un.window_limit}`}),(0,S.jsx)(A,{label:`deleted`,value:String(un.deleted_expired_count||0)}),(0,S.jsx)(A,{label:`action`,value:un.recommended_operator_action||`lease maintenance is current`})]}),(un.leases||[]).length>0&&(0,S.jsx)(M,{columns:[`expires`,`resource`,`entry`,`exit`,`route`,`data plane`,`state`],rows:(un.leases||[]).slice(0,8).map(e=>[V(e.expires_at),e.resource_id||B(e.channel_id),F(j,e.selected_entry_node_id||``),F(j,e.selected_exit_node_id||``),e.primary_route_id?`${B(e.primary_route_id)} / ${H(e.primary_route_status||``)}`:`backend fallback`,`${H(e.data_plane?.working_data_transport||`unknown`)} / ${H(e.data_plane?.backend_relay_policy||`unknown`)}`,(0,S.jsx)(`span`,{className:`pill ${e.expired||e.force_backend_fallback?`warn`:`good`}`,children:e.expired?`expired`:e.force_backend_fallback?`fallback`:H(e.status)})])})]}),L&&(0,S.jsxs)(`div`,{className:`noticePanel ${L.status===`degraded`?`warnPanel`:`goodPanel`}`,children:[(0,S.jsxs)(`div`,{className:`cardHead compact`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h4`,{children:`Service-channel access`}),(0,S.jsx)(`p`,{className:`muted`,children:`Live accepted_by visibility from node telemetry and heartbeat metadata.`})]}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[(0,S.jsx)(`span`,{className:`pill ${L.status===`degraded`?`warn`:`good`}`,children:H(L.status)}),(0,S.jsxs)(`span`,{className:`pill good`,children:[`accepted `,L.total_accepted]}),(0,S.jsxs)(`span`,{className:L.backend_fallback_count>0?`pill warn`:`pill`,children:[`backend `,L.backend_fallback_count]}),(0,S.jsxs)(`span`,{className:(L.backend_fallback_blocked_count||0)>0?`pill bad`:`pill`,children:[`blocked `,L.backend_fallback_blocked_count||0]}),(0,S.jsxs)(`span`,{className:`pill ${L.last_working_data_transport===`fabric_service_channel`?`good`:L.data_plane_contract_count?`warn`:``}`,children:[`data-plane `,L.data_plane_contract_count||0]}),(0,S.jsxs)(`span`,{className:`pill ${L.last_backend_relay_policy===`disabled`?`good`:L.last_backend_relay_policy===`degraded_fallback_only`?`info`:``}`,children:[`relay `,H(L.last_backend_relay_policy||`unknown`)]}),(0,S.jsxs)(`span`,{className:L.degraded_fallback_channel_count>0||L.degraded_route_count>0?`pill warn`:`pill good`,children:[`channels `,L.active_channel_count]}),(0,S.jsxs)(`span`,{className:L.no_safe_recovery_decision_count?`pill warn`:L.route_decision_channel_count?`pill info`:`pill`,children:[`decisions `,L.route_decision_channel_count||0,L.replacement_decision_count?` / repl ${L.replacement_decision_count}`:``,L.applied_rebuild_decision_count?` / applied ${L.applied_rebuild_decision_count}`:``,L.recovery_decision_count?` / recovery ${L.recovery_decision_count}`:``,L.no_safe_recovery_decision_count?` / no-safe ${L.no_safe_recovery_decision_count}`:``]}),(0,S.jsx)(`span`,{className:`pill ${Hn(L.flow_health_status,L.flow_dropped)}`,children:Bn(L.traffic_class_counts)}),(0,S.jsxs)(`span`,{className:`pill ${Hn(L.flow_health_status,L.flow_dropped)}`,children:[`flow `,H(L.flow_health_status||`healthy`)]}),(0,S.jsxs)(`span`,{className:`pill ${L.adaptive_backpressure_active?`info`:`good`}`,children:[`windows `,Vn(L.recommended_parallel_windows)]})]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`reason`,value:H(L.reason)}),(0,S.jsx)(A,{label:`reporting nodes`,value:`${L.reporting_node_count}/${L.node_count}`}),(0,S.jsx)(A,{label:`accepted by`,value:`signed ${L.signed_accepted}, introspection ${L.introspection_accepted}, legacy ${L.legacy_unsigned_accepted}`}),(0,S.jsx)(A,{label:`data plane`,value:`${L.data_plane_contract_count||0} contracts, mode ${H(L.last_data_plane_mode||`unknown`)}, working ${H(L.last_working_data_transport||`unknown`)}, steady ${H(L.last_steady_state_transport||`unknown`)}, relay ${H(L.last_backend_relay_policy||`unknown`)}, flows ${H(L.last_logical_flow_mode||`unknown`)}, blocked ${L.backend_fallback_blocked_count||0}, route failures ${L.fabric_route_send_failure_count||0}`}),(0,S.jsx)(A,{label:`data-plane violation`,value:L.last_data_plane_violation_status?`${H(L.last_data_plane_violation_status)} / ${L.last_data_plane_violation_reason||`n/a`}`:`none`}),(0,S.jsx)(A,{label:`active channels`,value:`${L.active_channel_count||0}, fallback ${L.degraded_fallback_channel_count||0}, correlated routes ${L.correlated_route_count||0}, degraded routes ${L.degraded_route_count||0}`}),(0,S.jsx)(A,{label:`route decisions`,value:`channels ${L.route_decision_channel_count||0}, replacement ${L.replacement_decision_count||0}, applied ${L.applied_rebuild_decision_count||0}, recovery ${L.recovery_decision_count||0}, no-safe ${L.no_safe_recovery_decision_count||0}`}),(0,S.jsx)(A,{label:`flow QoS`,value:`${H(L.flow_health_status||`healthy`)} / ${H(L.flow_health_reason||`flow_health_ready`)}, ${Bn(L.traffic_class_counts)}, flows ${L.flow_channel_count||0}, in-flight ${L.flow_max_in_flight||0}, dropped ${L.flow_dropped||0}`}),(0,S.jsx)(A,{label:`adaptive windows`,value:`${L.adaptive_backpressure_active?H(L.adaptive_backpressure_reason||`adaptive`):`off`}, ${Vn(L.recommended_parallel_windows)}, policy ${L.adaptive_policy_fingerprint?B(L.adaptive_policy_fingerprint):`n/a`}`}),(0,S.jsx)(A,{label:`latest accepted`,value:V(L.latest_accepted_at)}),(0,S.jsx)(A,{label:`action`,value:L.recommended_operator_action||`access telemetry is current`})]}),(L.active_channels||[]).length>0&&(0,S.jsx)(M,{columns:[`resource`,`entry -> exit`,`route`,`decision`,`entry access`,`data plane`,`flow health`,`windows`,`flow QoS`,`route quality`,`remediation`,`guard`,`execution`,`expires`],rows:(L.active_channels||[]).slice(0,10).map(e=>[e.resource_id||B(e.channel_id),`${F(j,e.selected_entry_node_id||``)} -> ${F(j,e.selected_exit_node_id||``)}`,e.primary_route_id?`${B(e.primary_route_id)} / ${H(e.primary_route_status||``)}`:`backend fallback`,(0,S.jsx)(`span`,{className:`pill ${Wn(e.route_decision_source,e.route_decision_rebuild_status,e.route_decision_score_reasons)}`,children:e.route_decision_source?`${H(e.route_decision_source)}${e.route_decision_route_id?` ${B(e.route_decision_route_id)}`:``}${e.route_decision_replacement_route_id?` -> ${B(e.route_decision_replacement_route_id)}`:``}${e.route_decision_rebuild_status?` / ${H(e.route_decision_rebuild_status)}`:``}`:`n/a`}),`accepted ${e.entry_node_total_accepted}, introspection ${e.entry_node_introspection_accepted}, backend ${e.entry_node_backend_fallback_count}`,(0,S.jsx)(`span`,{className:`pill ${e.entry_node_last_working_data_transport===`fabric_service_channel`?`good`:e.entry_node_data_plane_contract_count?`warn`:``}`,children:`${e.entry_node_data_plane_contract_count||0} / ${H(e.entry_node_last_working_data_transport||`unknown`)} / ${H(e.entry_node_last_backend_relay_policy||`unknown`)}${e.entry_node_backend_fallback_blocked_count?` / blocked ${e.entry_node_backend_fallback_blocked_count}`:``}`}),(0,S.jsxs)(`span`,{className:`pill ${Hn(e.entry_node_flow_health_status,e.entry_node_flow_dropped)}`,children:[H(e.entry_node_flow_health_status||`healthy`),e.entry_node_flow_health_reason?` / ${H(e.entry_node_flow_health_reason)}`:``]}),(0,S.jsx)(`span`,{className:`pill ${e.entry_node_adaptive_backpressure_active?`info`:`good`}`,children:Vn(e.entry_node_recommended_parallel_windows)}),(0,S.jsxs)(`span`,{className:`pill ${Hn(e.entry_node_flow_health_status,e.entry_node_flow_dropped)}`,children:[Bn(e.entry_node_traffic_class_counts),` / flows ${e.entry_node_flow_channel_count||0} / in ${e.entry_node_flow_max_in_flight||0}`]}),(0,S.jsx)(`span`,{className:`pill ${e.force_backend_fallback||e.route_feedback_status===`degraded`||e.route_feedback_status===`fenced`?`warn`:e.route_feedback_status?`good`:``}`,children:e.force_backend_fallback?`backend fallback`:e.route_feedback_status?`${H(e.route_feedback_status)} / ${e.last_send_duration_ms||0}ms / q ${e.route_quality_window_sample_count||0}`:`no route feedback`}),(0,S.jsx)(`span`,{className:`pill ${e.remediation_action===`none`?`good`:e.remediation_action===`prefer_alternate_route`?`warn`:e.remediation_action?`bad`:``}`,children:e.remediation_action?`${e.remediation_command?`cmd `:``}${H(e.remediation_action)}${e.remediation_command?.replacement_route_id?` -> ${B(e.remediation_command.replacement_route_id)}`:e.remediation_route_id?` -> ${B(e.remediation_route_id)}`:``}`:`n/a`}),(0,S.jsxs)(`span`,{className:`pill ${e.remediation_guard_status===`rejected`?`bad`:e.pool_policy_fingerprint?`good`:``}`,children:[e.remediation_guard_status?H(e.remediation_guard_status):e.pool_policy_fingerprint?`pool policy`:`n/a`,e.remediation_guard_reason?` / ${H(e.remediation_guard_reason)}`:``]}),(0,S.jsxs)(`span`,{className:`pill ${Un(e.remediation_execution_status)}`,children:[e.remediation_execution_status?H(e.remediation_execution_status):`n/a`,e.remediation_execution_generation?` / ${B(e.remediation_execution_generation)}`:``,e.remediation_execution_reason?` / ${H(e.remediation_execution_reason)}`:``]}),V(e.expires_at)])}),(L.nodes||[]).length>0&&(0,S.jsx)(M,{columns:[`node`,`accepted`,`signed`,`introspection`,`legacy`,`backend`,`data plane`,`flow health`,`windows`,`flow QoS`,`latest`],rows:(L.nodes||[]).slice(0,10).map(e=>[F(j,e.node_id)||e.node_name||B(e.node_id),e.total_accepted,e.signed_accepted,e.introspection_accepted,e.legacy_unsigned_accepted,(0,S.jsx)(`span`,{className:e.backend_fallback_count>0?`pill warn`:`pill`,children:e.backend_fallback_count}),(0,S.jsx)(`span`,{className:`pill ${e.last_working_data_transport===`fabric_service_channel`?`good`:e.data_plane_contract_count?`warn`:``}`,children:`${e.data_plane_contract_count||0} / ${H(e.last_working_data_transport||`unknown`)} / ${H(e.last_backend_relay_policy||`unknown`)}${e.backend_fallback_blocked_count?` / blocked ${e.backend_fallback_blocked_count}`:``}`}),(0,S.jsxs)(`span`,{className:`pill ${Hn(e.flow_health_status,e.flow_dropped)}`,children:[H(e.flow_health_status||`healthy`),e.flow_health_reason?` / ${H(e.flow_health_reason)}`:``]}),(0,S.jsx)(`span`,{className:`pill ${e.adaptive_backpressure_active?`info`:`good`}`,children:Vn(e.recommended_parallel_windows)}),(0,S.jsxs)(`span`,{className:`pill ${Hn(e.flow_health_status,e.flow_dropped)}`,children:[Bn(e.traffic_class_counts),` / flows ${e.flow_channel_count||0} / in ${e.flow_max_in_flight||0}`]}),V(e.last_accepted_at||e.observed_at)])})]}),tn&&(0,S.jsxs)(`div`,{className:`noticePanel ${tn.status===`blocked`?`badPanel`:tn.status===`degraded`?`warnPanel`:`goodPanel`}`,children:[(0,S.jsxs)(`div`,{className:`cardHead compact`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h4`,{children:`Fabric service-channel readiness`}),(0,S.jsx)(`p`,{className:`muted`,children:`Verdict for production service-channel use. Working service payloads should not depend on this fabric while the gate is blocked.`})]}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[(0,S.jsx)(`span`,{className:`pill ${tn.status===`blocked`?`bad`:tn.status===`degraded`?`warn`:`good`}`,children:H(tn.status)}),(0,S.jsxs)(`span`,{className:tn.active_alert_count>0?`pill bad`:`pill`,children:[`active `,tn.active_alert_count]}),(0,S.jsxs)(`span`,{className:tn.resurfaced_count>0?`pill bad`:`pill`,children:[`resurfaced `,tn.resurfaced_count]})]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`reason`,value:H(tn.reason)}),(0,S.jsx)(A,{label:`blocking`,value:(tn.blocking_reasons||[]).map(H).join(`, `)||`нет`}),(0,S.jsx)(A,{label:`degraded`,value:(tn.degraded_reasons||[]).map(H).join(`, `)||`нет`}),(0,S.jsx)(A,{label:`missing/post`,value:`transition ${tn.missing_transition_count}, route-gen ${tn.missing_route_generation_count}, traffic ${tn.missing_post_rebuild_traffic_count}`})]})]}),pn.length>0&&(0,S.jsxs)(`div`,{className:`subPanel`,children:[(0,S.jsxs)(`div`,{className:`cardHead compact`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h4`,{children:`Rebuild incidents`}),(0,S.jsx)(`p`,{className:`muted`,children:`Grouped recent rebuild attempts by reporter, route, service, generation, and guard. Open an incident to load the exact deep ledger slice.`})]}),(0,S.jsx)(`span`,{className:`pill`,children:pn.length})]}),(0,S.jsx)(M,{columns:[`last`,`source`,`reporter`,`route`,`service`,`guard`,`count`,`replacement`,`action`],rows:pn.slice(0,10).map(e=>[V(e.last_seen_at),e.incident_source?H(e.incident_source):`ledger`,F(j,e.reporter_node_id),B(e.route_id),e.service_class,(0,S.jsxs)(`span`,{className:`pill ${e.alert_resurfaced||e.guard_severity===`bad`?`bad`:e.guard_severity===`warn`?`warn`:`good`}`,children:[H(e.guard_status),e.alert_silenced?` / silenced`:e.alert_resurfaced?` / resurfaced`:``]}),String(e.attempt_count),e.latest_replacement_route_id?B(e.latest_replacement_route_id):`нет`,(0,S.jsxs)(`div`,{className:`inlineActions`,children:[(0,S.jsxs)(`span`,{children:[H(e.recommended_operator_action||`inspect`),e.alert_resurfaced&&e.alert_resurfaced_cause?` (${H(e.alert_resurfaced_cause)})`:``,e.alert_resurfaced&&e.alert_resurfaced_previous_generation?` from ${B(e.alert_resurfaced_previous_generation)} until ${V(e.alert_resurfaced_previous_until)}`:``]}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>ya(e),`Deep rebuild investigation opened.`),children:`open deep`}),e.alert_silenced?(0,S.jsx)(`span`,{className:`muted`,children:`silenced`}):(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>wa(e),`Rebuild incident silenced for 6 hours.`),children:`silence 6h`})]})])})]}),(Tn||ua.length>0)&&(0,S.jsxs)(`div`,{className:`subPanel`,children:[(0,S.jsxs)(`div`,{className:`cardHead compact`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h4`,{children:`Recent investigations`}),(0,S.jsx)(`p`,{className:`muted`,children:`Recent operator drilldowns opened from rebuild incidents or feedback breakdown rows.`})]}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[(0,S.jsx)(`span`,{className:`pill info`,children:hr?.total_count||ua.length}),(0,S.jsxs)(`span`,{className:`pill good`,children:[`linked `,hr?.correlated_count||0]}),(0,S.jsxs)(`span`,{className:(hr?.not_visible_count||0)>0?`pill warn`:`pill`,children:[`not visible `,hr?.not_visible_count||0]}),Object.entries(hr?.counts_by_breadcrumb_status||{}).map(([e,t])=>(0,S.jsxs)(`span`,{className:e===`current`?`pill good`:e===`stale`?`pill warn`:`pill bad`,children:[H(e),` `,t]},e)),Object.entries(hr?.counts_by_current_diagnostic_status||{}).slice(0,3).map(([e,t])=>(0,S.jsxs)(`span`,{className:e===`breakdown_active`||e===`incident_visible`?`pill good`:e===`not_visible`?`pill warn`:`pill`,children:[H(e),` `,t]},e))]})]}),Tn&&(0,S.jsxs)(`div`,{className:`inlineForm`,children:[(0,S.jsxs)(`label`,{children:[`current window, sec`,(0,S.jsx)(`input`,{type:`number`,min:`60`,value:zr.currentWindowSeconds,onChange:e=>Br(t=>({...t,currentWindowSeconds:e.target.value}))})]}),(0,S.jsxs)(`label`,{children:[`history window, sec`,(0,S.jsx)(`input`,{type:`number`,min:`60`,value:zr.historyWindowSeconds,onChange:e=>Br(t=>({...t,historyWindowSeconds:e.target.value}))})]}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(async()=>{En(await q.updateFabricServiceChannelBreadcrumbWindowPolicy(T,{currentWindowSeconds:Number(zr.currentWindowSeconds),historyWindowSeconds:Number(zr.historyWindowSeconds)}));let e=await q.getFabricServiceChannelRebuildInvestigationBreadcrumbs(T,{limit:20});mr(e.events),gr(e.summary||null)},`Breadcrumb window policy updated.`),children:`apply windows`}),(0,S.jsxs)(`span`,{className:`muted`,children:[`source `,Tn.source,`, fp `,B(Tn.fingerprint||``)]})]}),hr&&(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`latest`,value:V(hr.latest_at)}),(0,S.jsx)(A,{label:`windows`,value:`${Tn?.current_window_seconds||`n/a`}s current / ${Tn?.history_window_seconds||`n/a`}s history`}),(0,S.jsx)(A,{label:`sources`,value:Object.entries(hr.counts_by_feedback_source||{}).slice(0,3).map(([e,t])=>`${H(e)} ${t}`).join(`, `)||`нет`}),(0,S.jsx)(A,{label:`violations`,value:Object.entries(hr.counts_by_feedback_violation_status||{}).slice(0,3).map(([e,t])=>`${H(e)} ${t}`).join(`, `)||`нет`})]}),(0,S.jsx)(M,{columns:[`time`,`freshness`,`source`,`feedback`,`target`,`current`,`actor`,`reason`],rows:ua.map(e=>{let t=N(e.payload)||{},n=P(t,`feedback_channel_id`,``),r=P(t,`feedback_violation_status`,``),i=P(t,`feedback_source`,``),a=P(t,`reporter_node_id`,``),o=P(t,`route_id`,``),s=P(t,`drilldown_source`,``),c=e.correlation_hints?.current_diagnostic_status||``,l=e.correlation_hints?.breadcrumb_status||`current`,u=e.correlation_hints?.breadcrumb_age_seconds,d=e.correlation_hints?.feedback_breakdown||xa(e),f=e.correlation_hints?.rebuild_incident||Sa(e);return[V(e.created_at),(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{className:l===`current`?`pill good`:l===`stale`?`pill warn`:`pill bad`,children:H(l)}),(0,S.jsx)(`span`,{className:`muted`,children:Ln(u)})]}),(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{children:e.event_type.includes(`feedback_breakdown`)?`feedback breakdown`:`incident`}),(0,S.jsx)(`span`,{className:`muted`,children:H(s||e.target_type)})]}),i||n||r?(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{children:H(i||`feedback`)}),(0,S.jsx)(`span`,{className:`muted`,children:n?`ch ${B(n)}`:`any channel`}),(0,S.jsx)(`span`,{className:`muted`,children:H(r||`any violation`)})]}):(0,S.jsx)(`span`,{className:`muted`,children:`нет`}),(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{children:a?F(j,a):`any reporter`}),(0,S.jsx)(`span`,{className:`muted`,children:o?B(o):e.target_id?B(e.target_id):`any route`})]}),d?(0,S.jsxs)(`div`,{className:`inlineActions`,children:[(0,S.jsx)(`span`,{className:d.active_bad_count?`pill bad`:d.active_warn_count?`pill warn`:`pill good`,children:H(c||`breakdown_active`)}),(0,S.jsxs)(`span`,{className:`muted`,children:[`bad `,d.active_bad_count||0,` / warn `,d.active_warn_count||0]}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>Ca(d),`Rebuild ledger opened for current feedback breakdown.`),children:`open`})]}):f?(0,S.jsxs)(`div`,{className:`inlineActions`,children:[(0,S.jsx)(`span`,{className:`pill ${f.guard_severity===`bad`?`bad`:f.guard_severity===`warn`?`warn`:`good`}`,children:H(c||`incident_visible`)}),(0,S.jsx)(`span`,{className:`muted`,children:H(f.guard_status)}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>ya(f),`Deep rebuild investigation opened for current incident.`),children:`open`})]}):(0,S.jsx)(`span`,{className:`muted`,children:H(c||`not_visible`)}),e.actor_user_id?B(e.actor_user_id):`system`,P(t,`reason`,`operator opened investigation`)]})})]}),$t.length>0&&(0,S.jsxs)(`div`,{className:`subPanel`,children:[(0,S.jsxs)(`div`,{className:`cardHead compact`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h4`,{children:`Active rebuild silences`}),(0,S.jsx)(`p`,{className:`muted`,children:`Operator acknowledgements currently suppressing rebuild/access-decision alerts. Remove a silence to let the incident become active again.`})]}),(0,S.jsx)(`span`,{className:`pill info`,children:$t.length})]}),(0,S.jsx)(M,{columns:[`until`,`source`,`channel`,`reporter`,`route`,`guard`,`reason`,`action`],rows:$t.slice(0,10).map(e=>[V(e.expires_at),e.incident_source?H(e.incident_source):`ledger`,e.channel_id?B(e.channel_id):`нет`,F(j,e.reporter_node_id),B(e.display_route_id||e.route_id),H(e.guard_status),e.reason||`acknowledged`,(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>Ta(e),`Rebuild alert silence removed.`),children:`unsilence`})])})]}),I&&(0,S.jsxs)(`div`,{className:`subPanel`,children:[(0,S.jsxs)(`div`,{className:`cardHead compact`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h4`,{children:`Rebuild health`}),(0,S.jsxs)(`p`,{className:`muted`,children:[`Сводка по последним `,I.total_attempts,` rebuild попыткам. Данные помогают быстро увидеть, где backend уже принял решение, но node-agent или post-rebuild traffic не подтвердили результат.`]})]}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[(0,S.jsxs)(`span`,{className:`pill good`,children:[`ok `,I.good_count]}),(0,S.jsxs)(`span`,{className:I.active_warn_count>0?`pill warn`:`pill`,children:[`warn `,I.active_warn_count,`/`,I.warn_count]}),(0,S.jsxs)(`span`,{className:I.active_bad_count>0?`pill bad`:`pill`,children:[`bad `,I.active_bad_count,`/`,I.bad_count]}),(0,S.jsxs)(`span`,{className:I.resurfaced_count>0?`pill bad`:`pill`,children:[`resurfaced `,I.resurfaced_count]}),(0,S.jsxs)(`span`,{className:I.silenced_count>0?`pill info`:`pill`,children:[`silenced `,I.silenced_count]}),(0,S.jsxs)(`span`,{className:`pill`,children:[`applied `,I.applied_count]}),(0,S.jsxs)(`span`,{className:I.access_no_safe_count?`pill bad`:I.access_route_decision_count?`pill info`:`pill`,children:[`access `,I.access_route_decision_count||0,I.access_no_safe_count?` / no-safe ${I.access_no_safe_count}`:``]})]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`observed`,value:V(I.observed_at)}),(0,S.jsx)(A,{label:`affected nodes`,value:(I.affected_reporter_node_ids||[]).map(e=>F(j,e)).join(`, `)||`нет`}),(0,S.jsx)(A,{label:`affected routes`,value:(I.affected_route_ids||[]).map(B).join(`, `)||`нет`}),(0,S.jsx)(A,{label:`action`,value:H(I.recommended_operator_action||`no_operator_action_required`)})]}),(I.feedback_breakdowns||[]).length>0&&(0,S.jsx)(M,{columns:[`feedback`,`active`,`total`,`affected`,`incidents`,`latest`,`action`],rows:(I.feedback_breakdowns||[]).slice(0,8).map(e=>{let t=ba(e);return[(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{children:H(e.feedback_source||`feedback`)}),(0,S.jsx)(`span`,{className:`muted`,children:e.feedback_channel_id?`ch ${B(e.feedback_channel_id)}`:`any channel`}),(0,S.jsx)(`span`,{className:`muted`,children:H(e.feedback_violation_status||`unknown`)})]}),(0,S.jsxs)(`span`,{className:e.active_bad_count?`pill bad`:e.active_warn_count?`pill warn`:`pill`,children:[`bad `,e.active_bad_count||0,` / warn `,e.active_warn_count||0]}),`total ${e.total_count} / bad ${e.bad_count||0} / warn ${e.warn_count||0} / silenced ${e.silenced_count||0}`,(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{children:(e.affected_reporter_node_ids||[]).map(e=>F(j,e)).join(`, `)||`нет узлов`}),(0,S.jsx)(`span`,{className:`muted`,children:(e.affected_route_ids||[]).map(B).join(`, `)||`нет routes`})]}),t.length>0?(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{className:`pill warn`,children:t.length}),(0,S.jsx)(`span`,{className:`muted`,children:t.slice(0,2).map(e=>H(e.guard_status)).join(`, `)})]}):(0,S.jsx)(`span`,{className:`muted`,children:`нет`}),V(e.latest_observed_at),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>Ca(e),`Rebuild ledger opened for feedback breakdown.`),children:`open ledger`})]})}),(I.most_recent_bad_attempts||[]).length>0&&(0,S.jsx)(M,{columns:[`time`,`reporter`,`route`,`guard`,`reason`],rows:(I.most_recent_bad_attempts||[]).slice(0,5).map(e=>[V(e.updated_at),F(j,e.reporter_node_id),B(e.route_id),(0,S.jsx)(`span`,{className:`pill bad`,children:H(e.guard_status||`bad`)}),(0,S.jsxs)(`div`,{className:`inlineActions`,children:[(0,S.jsx)(`span`,{children:H(e.guard_reason||`unknown`)}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>q.silenceFabricServiceChannelRouteRebuildAlert(T,{reporterNodeId:e.reporter_node_id,routeId:e.route_id,guardStatus:e.guard_status||`unknown`,generation:e.generation||``,reason:`operator acknowledged known rebuild alert`,ttlSeconds:21600}),`Rebuild alert silenced for this route generation.`),children:`silence 6h`})]})])}),(I.resurfaced_attempts||[]).length>0&&(0,S.jsx)(M,{columns:[`time`,`reporter`,`route`,`guard`,`previous`,`action`],rows:(I.resurfaced_attempts||[]).slice(0,5).map(e=>[V(e.updated_at),F(j,e.reporter_node_id),B(e.route_id),(0,S.jsx)(`span`,{className:`pill bad`,children:H(e.guard_status||`bad`)}),`${B(e.alert_resurfaced_previous_generation)} until ${V(e.alert_resurfaced_previous_until)}`,(0,S.jsxs)(`div`,{className:`inlineActions`,children:[(0,S.jsx)(`span`,{children:H(e.guard_reason||`unknown`)}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>q.silenceFabricServiceChannelRouteRebuildAlert(T,{reporterNodeId:e.reporter_node_id,routeId:e.route_id,guardStatus:e.guard_status||`unknown`,generation:e.generation||``,reason:`operator acknowledged resurfaced rebuild alert`,ttlSeconds:21600}),`Resurfaced rebuild alert silenced for this generation.`),children:`silence 6h`})]})])})]}),bn&&(0,S.jsxs)(`div`,{className:`inlineForm`,children:[(0,S.jsxs)(`label`,{children:[`penalty`,(0,S.jsx)(`input`,{type:`number`,min:`0`,value:Lr.hysteresisPenalty,onChange:e=>Rr(t=>({...t,hysteresisPenalty:e.target.value}))})]}),(0,S.jsxs)(`label`,{children:[`promote samples`,(0,S.jsx)(`input`,{type:`number`,min:`1`,value:Lr.promotionMinSamples,onChange:e=>Rr(t=>({...t,promotionMinSamples:e.target.value}))})]}),(0,S.jsxs)(`label`,{children:[`fail`,(0,S.jsx)(`input`,{type:`number`,min:`1`,value:Lr.demotionFailureThreshold,onChange:e=>Rr(t=>({...t,demotionFailureThreshold:e.target.value}))})]}),(0,S.jsxs)(`label`,{children:[`drop`,(0,S.jsx)(`input`,{type:`number`,min:`1`,value:Lr.demotionDropThreshold,onChange:e=>Rr(t=>({...t,demotionDropThreshold:e.target.value}))})]}),(0,S.jsxs)(`label`,{children:[`slow`,(0,S.jsx)(`input`,{type:`number`,min:`1`,value:Lr.demotionSlowThreshold,onChange:e=>Rr(t=>({...t,demotionSlowThreshold:e.target.value}))})]}),(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:Lr.demotionRebuildEnabled,onChange:e=>Rr(t=>({...t,demotionRebuildEnabled:e.target.checked}))}),`rebuild`]}),(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:Lr.demotionFencedEnabled,onChange:e=>Rr(t=>({...t,demotionFencedEnabled:e.target.checked}))}),`fenced`]}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(async()=>{wn(await q.updateFabricServiceChannelRecoveryPolicy(T,{hysteresisPenalty:Number(Lr.hysteresisPenalty),promotionMinSamples:Number(Lr.promotionMinSamples),demotionFailureThreshold:Number(Lr.demotionFailureThreshold),demotionDropThreshold:Number(Lr.demotionDropThreshold),demotionSlowThreshold:Number(Lr.demotionSlowThreshold),demotionRebuildEnabled:Lr.demotionRebuildEnabled,demotionFencedEnabled:Lr.demotionFencedEnabled}))},`Recovery policy updated.`),children:`apply policy`}),(0,S.jsxs)(`span`,{className:`muted`,children:[`source `,bn.source]})]}),(0,S.jsx)(M,{columns:[`route`,`reporter`,`service`,`status`,`recovery`,`score`,`reasons`,`failures`,`retry/cooldown`,`expires`,`action`],rows:Ja.slice(0,80).map(e=>[B(e.route_id),F(j,e.reporter_node_id),e.service_class,(0,S.jsx)(`span`,{className:`pill ${st(e.feedback_status)}`,children:H(e.feedback_status)}),e.recovery_state?(0,S.jsxs)(`span`,{className:`pill ${ct(e.recovery_state)}`,children:[e.recovery_demoted?`demoted ${e.recovery_reason?H(e.recovery_reason):``}`:e.recovery_promoted?`promoted`:H(e.recovery_state),e.recovery_hysteresis_penalty?` -${e.recovery_hysteresis_penalty}`:``]}):e.stale_policy||e.stale_generation?(0,S.jsx)(`span`,{className:`pill warn`,children:H(e.stale_reason||`stale`)}):e.provenance_missing?(0,S.jsx)(`span`,{className:`pill warn`,children:`provenance missing`}):`нет`,String(e.score_adjustment),(e.reasons||[]).join(`, `)||`нет`,String(e.consecutive_failures||0),e.retry_cooldown_until?V(e.retry_cooldown_until):`нет`,V(e.expires_at),e.feedback_status===`healthy`||e.feedback_status===`operator_retry_cooldown`?(0,S.jsx)(`span`,{className:`muted`,children:`нет`}):(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>q.expireFabricServiceChannelRouteFeedback(T,{routeId:e.route_id,reporterNodeId:e.reporter_node_id,serviceClass:e.service_class,reason:`operator expired stale service-channel feedback`}),`Service-channel feedback expired.`),children:`expire`})])}),Ja.length===0&&(0,S.jsx)(me,{title:`Feedback отсутствует`,text:`Нет свежих route feedback наблюдений от fabric service-channel runtime.`}),(0,S.jsx)(M,{columns:[`local node`,`route`,`replacement`,`rebuild`,`attempt`,`feedback`,`source`,`destination`,`decision`,`score`,`expires`],rows:[...ro,...X,...io].filter((e,t,n)=>n.findIndex(t=>t.decision_id===e.decision_id)===t).slice(0,80).map(e=>[F(j,e.local_node_id),B(e.route_id),e.replacement_route_id?B(e.replacement_route_id):`нет`,e.rebuild_status||`нет`,e.rebuild_attempt==null?`н/д`:String(e.rebuild_attempt),e.feedback_observation_id?(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{children:H(e.feedback_source||`feedback`)}),(0,S.jsxs)(`span`,{className:`muted`,children:[B(e.feedback_observation_id),` `,e.feedback_channel_id?`ch ${B(e.feedback_channel_id)}`:``]}),(0,S.jsx)(`span`,{className:`muted`,children:H(e.feedback_violation_status||``)})]}):`нет`,F(j,e.source_node_id),F(j,e.destination_node_id),e.decision_source,e.path_score==null?`н/д`:String(e.path_score),V(e.expires_at)])}),(0,S.jsxs)(`div`,{className:`inlineForm`,children:[(0,S.jsxs)(`label`,{children:[`reporter`,(0,S.jsxs)(`select`,{value:z.reporterNodeId,onChange:e=>yn(t=>({...t,reporterNodeId:e.target.value,offset:0})),children:[(0,S.jsx)(`option`,{value:``,children:`all`}),j.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,S.jsxs)(`label`,{children:[`route`,(0,S.jsx)(`input`,{value:z.routeId,onChange:e=>yn(t=>({...t,routeId:e.target.value.trim(),offset:0})),placeholder:`route id`})]}),(0,S.jsxs)(`label`,{children:[`generation`,(0,S.jsx)(`input`,{value:z.generation,onChange:e=>yn(t=>({...t,generation:e.target.value.trim(),offset:0})),placeholder:`route generation`})]}),(0,S.jsxs)(`label`,{children:[`service`,(0,S.jsx)(`input`,{value:z.serviceClass,onChange:e=>yn(t=>({...t,serviceClass:e.target.value.trim(),offset:0})),placeholder:`vpn_packets`})]}),(0,S.jsxs)(`label`,{children:[`feedback source`,(0,S.jsx)(`input`,{value:z.feedbackSource,onChange:e=>yn(t=>({...t,feedbackSource:e.target.value.trim(),offset:0})),placeholder:`fabric_service_channel_access_report`})]}),(0,S.jsxs)(`label`,{children:[`channel`,(0,S.jsx)(`input`,{value:z.feedbackChannelId,onChange:e=>yn(t=>({...t,feedbackChannelId:e.target.value.trim(),offset:0})),placeholder:`feedback channel id`})]}),(0,S.jsxs)(`label`,{children:[`violation`,(0,S.jsx)(`input`,{value:z.feedbackViolationStatus,onChange:e=>yn(t=>({...t,feedbackViolationStatus:e.target.value.trim(),offset:0})),placeholder:`fabric_route_send_failed_backend_fallback_blocked`})]}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void ha(mn,{...z,offset:0}),children:`apply`}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>{let e={...re};yn(e),ha(!1,e)},children:`clear`})]}),(0,S.jsx)(M,{columns:[`time`,`reporter`,`route`,`replacement`,`feedback`,`guard`,`outcome`,`backend`,`agent`,`route-gen`,`traffic`,`policy`,`hops`],rows:zt.slice(0,80).map(e=>[V(e.updated_at),F(j,e.reporter_node_id),B(e.route_id),e.replacement_route_id?B(e.replacement_route_id):`нет`,e.feedback_observation_id?(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{children:H(e.feedback_source||`feedback`)}),(0,S.jsxs)(`span`,{className:`muted`,children:[B(e.feedback_observation_id),` `,e.feedback_channel_id?`ch ${B(e.feedback_channel_id)}`:``]}),(0,S.jsx)(`span`,{className:`muted`,children:H(e.feedback_violation_status||``)})]}):e.feedback_status?H(e.feedback_status):`нет`,mn?(0,S.jsxs)(`span`,{className:`pill ${e.guard_severity===`bad`?`bad`:e.guard_severity===`warn`?`warn`:`good`}`,children:[H(e.guard_status||`unknown`),e.alert_silenced?` / silenced`:e.alert_resurfaced?` / resurfaced`:``]}):(0,S.jsx)(`span`,{className:`pill info`,children:`summary`}),H(e.outcome),(0,S.jsx)(`span`,{className:`pill ${e.rebuild_status===`applied`?`good`:`warn`}`,children:H(e.rebuild_status)}),mn?e.node_transition_matched?(0,S.jsx)(`span`,{className:`pill ${e.node_transition_status===`applied_rebuild`?`good`:`warn`}`,children:H(e.node_transition_status||`matched`)}):(0,S.jsx)(`span`,{className:`pill warn`,children:`not seen`}):(0,S.jsx)(`span`,{className:`pill info`,children:`deep only`}),mn?e.node_route_generation_matched?(0,S.jsx)(`span`,{className:`pill good`,children:H(e.node_route_generation_status||`seen`)}):(0,S.jsx)(`span`,{className:`pill warn`,children:`not seen`}):(0,S.jsx)(`span`,{className:`pill info`,children:`deep only`}),mn?e.post_rebuild_selected_route_id?`${B(e.post_rebuild_selected_route_id)} packets ${e.post_rebuild_send_flow_packets||e.post_rebuild_send_packets||0} drop ${e.post_rebuild_send_flow_dropped||0}`:`нет`:`deep only`,e.policy_fingerprint?B(e.policy_fingerprint):`нет`,`${(e.old_hops||[]).map(e=>F(j,e)).join(` -> `)||`нет`} => ${(e.replacement_hops||[]).map(e=>F(j,e)).join(` -> `)||`нет`}`])}),(0,S.jsxs)(`div`,{className:`inlineActions`,children:[(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void ha(!mn,{...z,offset:0}),children:mn?`fast ledger`:`deep ledger`}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,disabled:!mn||z.offset<=0,onClick:()=>void ha(!0,{...z,offset:Math.max(0,z.offset-20)}),children:`prev`}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,disabled:!mn||zt.length<20,onClick:()=>void ha(!0,{...z,offset:z.offset+20}),children:`next`}),(0,S.jsxs)(`span`,{className:`pill`,children:[`offset `,mn?z.offset:0]}),(0,S.jsx)(`span`,{className:`muted`,children:`Deep ledger correlates heartbeat timeline and can be slower; default refresh stays fast.`})]}),zt.length===0&&(0,S.jsx)(me,{title:`Rebuild ledger пуст`,text:`Пока нет долговечной истории service-channel route rebuild решений.`})]}),(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsx)(`h3`,{children:J.servicePlacement}),(0,S.jsx)(M,{columns:[`узел`,`runtime`,`адрес`,`здоровье`,`роли`,`желаемые / reported сервисы`,`последний heartbeat`],rows:j.map(e=>{let t=wt(e,rt[e.id]||[],gt);return[e.name,(0,S.jsx)(ye,{runtime:t}),t.address,e.health_status,Ze(We[e.id]||[]),et(Qe[e.id]||[]),V((rt[e.id]||[])[0]?.observed_at||e.last_seen_at)]})})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:J.trafficFlow}),(0,S.jsx)(M,{columns:[`источник`,`цель`,`тип`,`route/path`,`статус`,`задержка`,`качество`,`наблюдение`],rows:ht(gt).filter(e=>e.source_node_id!==e.target_node_id).map(e=>{let t=j.find(t=>t.id===e.source_node_id),n=j.find(t=>t.id===e.target_node_id);return[(0,S.jsx)(be,{node:t,fallback:F(j,e.source_node_id),heartbeatsByNode:rt,meshLinks:gt}),(0,S.jsx)(be,{node:n,fallback:F(j,e.target_node_id),heartbeatsByNode:rt,meshLinks:gt}),_t(e),vt(e,j),e.link_status,e.latency_ms==null?`н/д`:`${e.latency_ms} мс`,e.quality_score==null?`н/д`:`${e.quality_score}/100`,V(e.observed_at)]})})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Политики QoS`}),(0,S.jsx)(M,{columns:[`класс`,`приоритет`,`надежность`,`политика сброса`],rows:Dn.map(e=>[e.service_class,String(e.priority),e.reliability_mode,e.drop_policy])})]})]}),w===`vpn`&&(0,S.jsxs)(`section`,{className:`grid two`,children:[(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Создать желаемое состояние VPN-подключения`}),(0,S.jsx)(`p`,{className:`muted`,children:`Только control-plane. Здесь не выполняются TUN/TAP, маршруты, DNS, firewall, QoS или packet forwarding.`}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[`ID организации`,(0,S.jsx)(`input`,{value:W.organizationId,onChange:e=>Ei({...W,organizationId:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Название`,(0,S.jsx)(`input`,{value:W.name,onChange:e=>Ei({...W,name:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Протокол`,(0,S.jsxs)(`select`,{value:W.protocolFamily,onChange:e=>Ei({...W,protocolFamily:e.target.value}),children:[(0,S.jsx)(`option`,{value:`generic`,children:`generic`}),(0,S.jsx)(`option`,{value:`wireguard`,children:`wireguard`}),(0,S.jsx)(`option`,{value:`ipsec`,children:`ipsec`}),(0,S.jsx)(`option`,{value:`openvpn`,children:`openvpn`})]})]}),(0,S.jsxs)(`label`,{children:[`Желаемое состояние`,(0,S.jsxs)(`select`,{value:W.desiredState,onChange:e=>Ei({...W,desiredState:e.target.value}),children:[(0,S.jsx)(`option`,{value:`disabled`,children:`выключено`}),(0,S.jsx)(`option`,{value:`enabled`,children:`включено`})]})]}),(0,S.jsxs)(`label`,{children:[`Ссылка на credential`,(0,S.jsx)(`input`,{value:W.credentialRef,onChange:e=>Ei({...W,credentialRef:e.target.value})})]})]}),(0,S.jsxs)(`label`,{children:[`Целевой endpoint JSON`,(0,S.jsx)(`textarea`,{value:W.targetEndpointJson,onChange:e=>Ei({...W,targetEndpointJson:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Политика разрешенных узлов JSON`,(0,S.jsx)(`textarea`,{value:W.allowedNodePolicyJson,onChange:e=>Ei({...W,allowedNodePolicyJson:e.target.value})})]}),(0,S.jsxs)(`details`,{children:[(0,S.jsx)(`summary`,{children:`Расширенные routing / QoS / placement JSON`}),(0,S.jsxs)(`label`,{children:[`Использование маршрутизации JSON`,(0,S.jsx)(`textarea`,{value:W.routingUsageJson,onChange:e=>Ei({...W,routingUsageJson:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Политика маршрута JSON`,(0,S.jsx)(`textarea`,{value:W.routePolicyJson,onChange:e=>Ei({...W,routePolicyJson:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Политика QoS JSON`,(0,S.jsx)(`textarea`,{value:W.qosPolicyJson,onChange:e=>Ei({...W,qosPolicyJson:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Политика размещения JSON`,(0,S.jsx)(`textarea`,{value:W.placementPolicyJson,onChange:e=>Ei({...W,placementPolicyJson:e.target.value})})]})]}),(0,S.jsx)(`button`,{className:`primary`,disabled:!T||!W.organizationId||!W.name,onClick:()=>void Y(()=>q.createVPNConnection(T,{organizationId:W.organizationId,name:W.name,protocolFamily:W.protocolFamily,credentialRef:W.credentialRef||null,desiredState:W.desiredState,targetEndpoint:Ye(W.targetEndpointJson,`target endpoint`),allowedNodePolicy:Ye(W.allowedNodePolicyJson,`allowed node policy`),routingUsage:Xe(W.routingUsageJson,`routing usage`),routePolicy:Ye(W.routePolicyJson,`route policy`),qosPolicy:Ye(W.qosPolicyJson,`qos policy`),placementPolicy:Ye(W.placementPolicyJson,`placement policy`)}),`Желаемое состояние VPN создано.`),children:`Создать желаемое состояние VPN`})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:`VPN-подключения`}),(0,S.jsx)(`p`,{className:`muted`,children:`Cluster-managed состояние, gateway packet stats и диагностика Android-клиента.`})]}),(0,S.jsxs)(`div`,{className:`actions compactActions`,children:[(0,S.jsx)(`button`,{onClick:()=>void Y(async()=>{Ar(`Истекшие VPN lease: ${(await q.expireStaleVPNLeases(T)).length}.`)},`Stale VPN lease проверены.`),children:`Проверить stale lease`}),(0,S.jsx)(`button`,{onClick:()=>void Da(),children:`Обновить клиент`})]})]}),(0,S.jsxs)(`div`,{className:`inlineForm`,children:[(0,S.jsxs)(`label`,{children:[`Android device id`,(0,S.jsx)(`input`,{value:qn,placeholder:`0315f630-...`,onChange:e=>Jn(e.target.value),onBlur:()=>localStorage.setItem(C.vpnDiagnosticDeviceId,qn.trim())})]}),Yn.length>0&&(0,S.jsxs)(`label`,{children:[`Найденные клиенты`,(0,S.jsx)(`select`,{value:qn,onChange:e=>{let t=e.target.value;Jn(t),localStorage.setItem(C.vpnDiagnosticDeviceId,t),Qn(Yn.find(e=>e.device_id===t)||null)},children:Yn.map(e=>{let t=N(e.payload)||{};return(0,S.jsxs)(`option`,{value:e.device_id,children:[B(e.device_id),` / `,P(t,`app_version`,`н/д`),` / `,V(e.observed_at)]},e.device_id)})})]})]}),(0,S.jsxs)(`div`,{className:`diagnosticCommandPanel`,children:[(0,S.jsxs)(`label`,{children:[`URL для теста`,(0,S.jsx)(`input`,{value:$n,onChange:e=>er(e.target.value)})]}),(0,S.jsxs)(`div`,{className:`actions compactActions`,children:[(0,S.jsx)(`button`,{onClick:()=>void Oa({type:`refresh_profile`},`Профиль`),children:`Обновить профиль`}),(0,S.jsx)(`button`,{onClick:()=>void Oa({type:`start_vpn`},`VPN`),children:`Старт VPN`}),(0,S.jsx)(`button`,{onClick:()=>void Oa({type:`stop_vpn`},`VPN`),children:`Стоп VPN`}),(0,S.jsx)(`button`,{onClick:()=>void Oa({type:`vpn_stats`},`Stats`),children:`Stats`}),(0,S.jsx)(`button`,{onClick:()=>void Oa({type:`vpn_http_get`,url:$n},`VPN HTTP`),children:`VPN HTTP`}),(0,S.jsx)(`button`,{onClick:()=>void Oa({type:`open_url`,url:$n},`Открыть URL`),children:`Открыть URL`}),(0,S.jsx)(`button`,{className:`primary`,onClick:()=>void Oa({type:`full_vpn_test`,url:$n,watch_seconds:45},`Полный VPN test`),children:`Полный тест`})]}),tr&&(0,S.jsxs)(`p`,{className:`muted`,children:[`Последняя команда: `,P(tr.payload,`type`,`н/д`),` / `,V(tr.created_at)]})]}),he(Zn),(0,S.jsxs)(`div`,{className:`stack`,children:[jn.map(e=>{let t=N(e.metadata?.client_config),n=N(t?.vpn_fabric_route),r=Rt(n?.entry_pool_node_ids||e.placement_policy?.entry_node_ids),i=Rt(n?.exit_pool_node_ids||e.placement_policy?.exit_node_ids),a=String(n?.selected_entry_node_id||r[0]||``),o=String(n?.selected_exit_node_id||Pn[e.id]?.owner_node_id||e.placement_policy?.exit_node_id||i[0]||``),s=Gn[e.id]||{};return(0,S.jsxs)(`div`,{className:`vpnCard`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`strong`,{children:e.name}),(0,S.jsxs)(`p`,{className:`muted`,children:[e.protocol_family,` / `,e.mode,` / организация `,B(e.organization_id)]}),(0,S.jsx)(k,{value:e.desired_state}),(0,S.jsx)(k,{value:e.status}),(0,S.jsx)(`span`,{className:`pill ${t?.packet_forwarding?`good`:`warn`}`,children:t?.packet_forwarding?`gateway packet relay active`:`gateway packet relay inactive`}),(0,S.jsxs)(`span`,{className:`pill`,children:[String(n?.preferred_data_plane||`backend_relay`),` / fallback `,String(n?.fallback_data_plane||`н/д`)]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Секрет`,value:e.credential_ref?`задан`:`не задан`}),(0,S.jsx)(A,{label:`Активный lease`,value:Pn[e.id]?B(Pn[e.id]?.owner_node_id):`нет`}),(0,S.jsx)(A,{label:`Fabric route`,value:`${a?F(j,a):`entry auto`} -> ${o?F(j,o):`exit auto`}`}),(0,S.jsx)(A,{label:`Entry pool`,value:r.map(e=>F(j,e)).join(`, `)||`н/д`}),(0,S.jsx)(A,{label:`Exit pool`,value:i.map(e=>F(j,e)).join(`, `)||`н/д`}),(0,S.jsx)(A,{label:`Runtime`,value:String(t?.runtime_status||`н/д`)}),(0,S.jsx)(A,{label:`Gateway`,value:String(t?.gateway_assignment_status||`н/д`)}),(0,S.jsx)(A,{label:`Client -> gateway`,value:at(s.client_to_gateway)}),(0,S.jsx)(A,{label:`Gateway -> client`,value:at(s.gateway_to_client)}),(0,S.jsx)(A,{label:`Обновлено`,value:V(e.updated_at)})]}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{disabled:e.desired_state===`enabled`,onClick:()=>void Y(()=>q.updateVPNConnectionDesiredState(T,e.id,`enabled`),`Желаемое состояние VPN включено.`),children:`Включить`}),(0,S.jsx)(`button`,{disabled:e.desired_state===`disabled`,onClick:()=>void Y(()=>q.updateVPNConnectionDesiredState(T,e.id,`disabled`),`Желаемое состояние VPN выключено.`),children:`Выключить`})]})]},e.id)}),jn.length===0&&(0,S.jsx)(me,{title:`Нет желаемого состояния VPN`,text:`Control-plane записи C18 появятся здесь.`})]})]})]}),w===`org-safe`&&(0,S.jsxs)(`section`,{className:`grid two`,children:[(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:`Организации и пользователи`}),(0,S.jsx)(`p`,{className:`muted`,children:`Операционный слой для владельца платформы: tenant scope, роли участников и безопасная сводка без раскрытия core mesh.`})]}),(0,S.jsx)(`span`,{className:`pill`,children:rr.length})]}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[`Slug`,(0,S.jsx)(`input`,{value:Di.slug,onChange:e=>Oi({...Di,slug:e.target.value}),placeholder:`home`})]}),(0,S.jsxs)(`label`,{children:[`Название`,(0,S.jsx)(`input`,{value:Di.name,onChange:e=>Oi({...Di,name:e.target.value}),placeholder:`HOME`})]})]}),(0,S.jsx)(`div`,{className:`actions`,children:(0,S.jsx)(`button`,{className:`primary`,disabled:!Di.slug.trim()||!Di.name.trim(),onClick:()=>void Y(async()=>{let e=await q.createOrganization(Di);Oi({slug:``,name:``}),Sr(e.id),ji(t=>({...t,organizationId:e.id})),Ii(t=>({...t,organizationId:e.id}))},`Организация создана.`),children:`Создать организацию`})}),(0,S.jsx)(M,{columns:[`организация`,`slug`,`статус`,`ресурсы`,`участники`,`действие`],rows:rr.map(e=>{let t=sr.filter(t=>t.organization_id===e.id),n=lr[e.id]||[];return[e.name,e.slug,(0,S.jsx)(k,{value:e.status}),String(t.length),String(n.length),(0,S.jsx)(`div`,{className:`actions`,children:(0,S.jsx)(`button`,{onClick:()=>void Y(async()=>{Sr(e.id),wr(await q.getOrganizationAdminSummary(e.id))},`Сводка организации загружена.`),children:`Открыть`})},e.id)]})})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Пользователь`}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[`Email / логин`,(0,S.jsx)(`input`,{value:G.email,onChange:e=>ki({...G,email:e.target.value}),placeholder:`user@example.com`})]}),(0,S.jsxs)(`label`,{children:[`Пароль`,(0,S.jsx)(`input`,{type:`password`,value:G.password,onChange:e=>ki({...G,password:e.target.value}),placeholder:`минимум 8 символов`})]}),(0,S.jsxs)(`label`,{children:[`Роль платформы`,(0,S.jsxs)(`select`,{value:G.platformRole,onChange:e=>ki({...G,platformRole:e.target.value}),children:[(0,S.jsx)(`option`,{value:`user`,children:`user`}),(0,S.jsx)(`option`,{value:`platform_admin`,children:`platform_admin`}),(0,S.jsx)(`option`,{value:`platform_recovery_admin`,children:`platform_recovery_admin`})]})]})]}),(0,S.jsx)(`div`,{className:`actions`,children:(0,S.jsx)(`button`,{disabled:!G.email.trim()||G.password.length<8,onClick:()=>void Y(async()=>{let e=await q.createUser(G);or(await q.listUsers()),ki({email:``,password:``,platformRole:`user`}),ji(t=>({...t,userId:e.id}))},`Пользователь создан.`),children:`Создать пользователя`})}),(0,S.jsx)(M,{columns:[`пользователь`,`роль платформы`,`id`],rows:ar.map(e=>[e.email,(0,S.jsx)(k,{value:e.platform_role||`user`}),(0,S.jsx)(`code`,{children:e.id})])})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Участник организации`}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[`Организация`,(0,S.jsxs)(`select`,{value:Ai.organizationId,onChange:e=>ji({...Ai,organizationId:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:`Выберите организацию`}),rr.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,S.jsxs)(`label`,{children:[`Пользователь`,(0,S.jsxs)(`select`,{value:Ai.userId,onChange:e=>ji({...Ai,userId:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:`Выберите пользователя`}),ar.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.email},e.id))]})]}),(0,S.jsxs)(`label`,{children:[`Роль`,(0,S.jsxs)(`select`,{value:Ai.roleId,onChange:e=>ji({...Ai,roleId:e.target.value}),children:[(0,S.jsx)(`option`,{value:`org_owner`,children:`org_owner`}),(0,S.jsx)(`option`,{value:`org_admin`,children:`org_admin`}),(0,S.jsx)(`option`,{value:`org_operator`,children:`org_operator`}),(0,S.jsx)(`option`,{value:`org_member`,children:`org_member`}),(0,S.jsx)(`option`,{value:`org_viewer`,children:`org_viewer`})]})]})]}),(0,S.jsx)(`div`,{className:`actions`,children:(0,S.jsx)(`button`,{disabled:!Ai.organizationId||!Ai.userId.trim(),onClick:()=>void Y(()=>q.addOrganizationMembership(Ai.organizationId,{userId:Ai.userId,roleId:Ai.roleId}),`Участник организации сохранен.`),children:`Сохранить участника`})})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Безопасная сводка`}),(0,S.jsxs)(`div`,{className:`inlineForm`,children:[(0,S.jsxs)(`select`,{value:xr,onChange:e=>Sr(e.target.value),children:[(0,S.jsx)(`option`,{value:``,children:`Выберите организацию`}),rr.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))]}),(0,S.jsx)(`button`,{disabled:!xr,onClick:()=>void Y(async()=>{wr(await q.getOrganizationAdminSummary(xr))},`Сводка организации загружена.`),children:`Обновить`})]}),Cr?(0,S.jsxs)(`div`,{className:`stack`,children:[(0,S.jsx)(fe,{label:`Ресурсы`,value:Cr.resource_count,tone:`steel`}),(0,S.jsx)(fe,{label:`Активные сессии`,value:Cr.active_session_count,tone:`green`}),(0,S.jsx)(A,{label:`Topology exposure`,value:Cr.topology_exposure}),(0,S.jsx)(M,{columns:[`контур`,`состояние`],rows:Object.entries(Cr.connector_status||{}).map(([e,t])=>[e,typeof t==`string`?H(t):JSON.stringify(t)])}),(0,S.jsx)(M,{columns:[`протокол`,`количество`],rows:Cr.service_endpoints.map(e=>[e.protocol,String(e.count)])})]}):(0,S.jsx)(me,{title:`Сводка не выбрана`,text:`Выберите организацию, чтобы проверить tenant-safe состояние.`})]})]}),w===`servers`&&(0,S.jsx)(`section`,{className:`grid two`,children:(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:`Справочник серверов`}),(0,S.jsx)(`p`,{className:`muted`,children:`Единый каталог целей для RDP/VPN: адрес сервера, организация, протокол и предпочтительный вход/выход маршрута.`})]}),(0,S.jsx)(`span`,{className:`pill`,children:sr.length})]}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[`Организация`,(0,S.jsxs)(`select`,{value:K.organizationId,onChange:e=>Ii({...K,organizationId:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:`Выберите организацию`}),rr.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,S.jsxs)(`label`,{children:[`Имя сервера`,(0,S.jsx)(`input`,{value:K.name,onChange:e=>Ii({...K,name:e.target.value}),placeholder:`Office RDP`})]}),(0,S.jsxs)(`label`,{children:[`Адрес`,(0,S.jsx)(`input`,{value:K.address,onChange:e=>Ii({...K,address:e.target.value}),placeholder:`192.168.1.10:3389`})]}),(0,S.jsxs)(`label`,{children:[`Протокол`,(0,S.jsxs)(`select`,{value:K.protocol,onChange:e=>Ii({...K,protocol:e.target.value}),children:[(0,S.jsx)(`option`,{value:`rdp`,children:`RDP`}),(0,S.jsx)(`option`,{value:`vpn`,children:`VPN`}),(0,S.jsx)(`option`,{value:`ssh`,children:`SSH`}),(0,S.jsx)(`option`,{value:`http`,children:`HTTP`})]})]}),(0,S.jsxs)(`label`,{children:[`Вход`,(0,S.jsxs)(`select`,{value:K.entryNode,onChange:e=>Ii({...K,entryNode:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:`Автоматически`}),j.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,S.jsxs)(`label`,{children:[`Выход`,(0,S.jsxs)(`select`,{value:K.exitNode,onChange:e=>Ii({...K,exitNode:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:`Автоматически`}),j.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,S.jsxs)(`label`,{children:[`Теги`,(0,S.jsx)(`input`,{value:K.tags,onChange:e=>Ii({...K,tags:e.target.value}),placeholder:`home, accounting`})]}),(0,S.jsxs)(`label`,{children:[`RDP пользователь`,(0,S.jsx)(`input`,{value:K.username,onChange:e=>Ii({...K,username:e.target.value}),placeholder:`user или DOMAIN\\\\user`})]}),(0,S.jsxs)(`label`,{children:[`RDP пароль`,(0,S.jsx)(`input`,{type:`password`,value:K.password,onChange:e=>Ii({...K,password:e.target.value}),placeholder:`хранится как secret`})]}),(0,S.jsxs)(`label`,{children:[`Домен`,(0,S.jsx)(`input`,{value:K.domain,onChange:e=>Ii({...K,domain:e.target.value}),placeholder:`опционально`})]})]}),(0,S.jsx)(`div`,{className:`actions`,children:(0,S.jsx)(`button`,{className:`primary`,disabled:!K.organizationId||!K.name.trim()||!K.address.trim(),onClick:()=>void Y(async()=>{let e=[`rdp`,`vnc`,`ssh`].includes(K.protocol)?`rap-secret://org/${K.organizationId}/resources/${crypto.randomUUID()}/primary`:null,t=await q.createResource({organizationId:K.organizationId,name:K.name,address:K.address,protocol:K.protocol,secretRef:e,certificateVerificationMode:K.protocol===`rdp`?`ignore`:`strict`,clipboardMode:K.protocol===`rdp`?`bidirectional`:`disabled`,fileTransferMode:K.protocol===`rdp`?`bidirectional`:`disabled`,metadata:{route_mode:K.routeMode,preferred_entry_node_id:K.entryNode||null,preferred_exit_node_id:K.exitNode||null,tags:K.tags.split(`,`).map(e=>e.trim()).filter(Boolean)}});[`rdp`,`vnc`,`ssh`].includes(K.protocol)&&(K.username.trim()||K.password)&&await q.upsertResourceSecret(t.id,{username:K.username.trim(),password:K.password,domain:K.domain.trim()}),Ii({...K,name:``,address:``,tags:``,username:``,password:``,domain:``})},`Сервер добавлен в справочник.`),children:`Добавить сервер`})}),(0,S.jsx)(M,{columns:[`сервер`,`адрес`,`протокол`,`секрет`,`организация`,`маршрут`,`создано`,`действия`],rows:sr.map(e=>{let t=e.metadata||{},n=rr.find(t=>t.id===e.organization_id);return[e.name,e.address,e.protocol,e.has_secret?`сохранен`:e.secret_ref?`нужен payload`:`нет`,n?.name||B(e.organization_id),`${B(String(t.preferred_entry_node_id||``))||`auto`} -> ${B(String(t.preferred_exit_node_id||``))||`auto`}`,V(e.created_at),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>{Ni(e),Fi({username:``,password:``,domain:``})},children:`Обновить secret`})]})}),Mi&&(0,S.jsx)(`div`,{className:`modalBackdrop`,role:`presentation`,children:(0,S.jsxs)(`div`,{className:`modalCard`,role:`dialog`,"aria-modal":`true`,"aria-labelledby":`resource-secret-title`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{id:`resource-secret-title`,children:`Учетные данные RDP`}),(0,S.jsxs)(`p`,{className:`muted`,children:[Mi.name,` · `,Mi.address]})]}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>Ni(null),children:`Закрыть`})]}),(0,S.jsxs)(Je,{children:[(0,S.jsxs)(`label`,{children:[`Пользователь`,(0,S.jsx)(`input`,{value:Pi.username,onChange:e=>Fi({...Pi,username:e.target.value}),placeholder:`user или DOMAIN\\\\user`})]}),(0,S.jsxs)(`label`,{children:[`Пароль`,(0,S.jsx)(`input`,{type:`password`,value:Pi.password,onChange:e=>Fi({...Pi,password:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Домен`,(0,S.jsx)(`input`,{value:Pi.domain,onChange:e=>Fi({...Pi,domain:e.target.value}),placeholder:`опционально`})]})]}),(0,S.jsx)(`p`,{className:`muted`,children:`Пароль сохраняется как encrypted resource secret. В metadata ресурса он не попадет.`}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{className:`primary`,disabled:!Pi.username.trim()||!Pi.password,onClick:()=>void Y(async()=>{await q.upsertResourceSecret(Mi.id,{username:Pi.username.trim(),password:Pi.password,domain:Pi.domain.trim()}),Ni(null),Fi({username:``,password:``,domain:``})},`Secret ресурса обновлен.`),children:`Сохранить secret`}),(0,S.jsx)(`button`,{onClick:()=>Ni(null),children:`Отмена`})]})]})})]})}),w===`audit`&&(0,S.jsxs)(`section`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Аудит кластера`}),(0,S.jsx)(M,{columns:[`событие`,`цель`,`actor`,`создано`],rows:dr.map(e=>[e.event_type,`${e.target_type}${e.target_id?`:${B(e.target_id)}`:``}`,e.actor_user_id?B(e.actor_user_id):`system`,V(e.created_at)])})]})]})]})}function fe({label:e,value:t,tone:n}){return(0,S.jsxs)(`article`,{className:`metric ${n}`,children:[(0,S.jsx)(`span`,{children:e}),(0,S.jsx)(`strong`,{children:t})]})}function O({label:e,value:t}){return(0,S.jsxs)(`div`,{className:`signal`,children:[(0,S.jsx)(`span`,{children:e}),(0,S.jsx)(`strong`,{children:t})]})}function k({value:e}){return(0,S.jsx)(`span`,{className:`status ${e.replace(/_/g,`-`)}`,children:H(e)})}function pe({label:e,value:t,tone:n}){return(0,S.jsxs)(`span`,{className:`functionState ${n||``}`,children:[(0,S.jsx)(`small`,{children:e}),(0,S.jsx)(`strong`,{children:t})]})}function A({label:e,value:t}){return(0,S.jsxs)(`div`,{className:`stateLine`,children:[(0,S.jsx)(`span`,{children:e}),(0,S.jsx)(`strong`,{children:t})]})}function me({title:e,text:t}){return(0,S.jsxs)(`article`,{className:`empty`,children:[(0,S.jsx)(`h3`,{children:e}),(0,S.jsx)(`p`,{children:t})]})}function he(e){if(!e)return(0,S.jsx)(`p`,{className:`muted`,children:`Диагностика Android-клиента не загружена. Укажи device id из приложения и нажми “Обновить клиент”.`});let t=N(e.payload)||{},n=N(t.runtime),r=N(t.vpn_config),i=P(t,`app_version`,`н/д`),a=P(t,`service_state`,`н/д`),o=P(t,`control_network_mode`,`н/д`),s=P(r,`packet_relay_active_base_url`)||P(r,`packet_relay_base_url`,`н/д`),c=P(r,`packet_relay_profile_base_url`,`н/д`),l=P(r,`packet_relay_candidate_urls`,`н/д`),u=Tt(n,`uplink_read_total`),d=Tt(n,`uplink_sent_total`),f=Tt(n,`downlink_received_total`),p=Tt(n,`uplink_dropped_packets`)+Tt(n,`downlink_dropped_packets`),m=Tt(n,`uplink_bypassed_control_packets`),h=Tt(n,`downlink_received_bytes`),g=Tt(n,`uplink_sent_bytes`),_=P(n,`state`,`н/д`),v=P(n,`message`,``),y=Tt(n,`uplink_sent_mbps`),b=Tt(n,`downlink_received_mbps`),x=P(t,`last_command_type`,`н/д`),ee=P(t,`last_command_result`,`н/д`);return(0,S.jsxs)(`div`,{className:`vpnCard diagnosticCard`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsxs)(`strong`,{children:[`Android client `,B(e.device_id)]}),(0,S.jsxs)(`p`,{className:`muted`,children:[i,` / `,a,` / `,V(e.observed_at)]}),(0,S.jsx)(k,{value:Date.now()-new Date(e.observed_at).getTime()<3e4?`active`:`degraded`}),(0,S.jsx)(`span`,{className:`pill`,children:o})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Relay active`,value:s}),(0,S.jsx)(A,{label:`Relay profile`,value:c}),(0,S.jsx)(A,{label:`Relay candidates`,value:l}),(0,S.jsx)(A,{label:`Packets read/sent/down`,value:`${u} / ${d} / ${f}`}),(0,S.jsx)(A,{label:`Drops / control bypass`,value:`${p} / ${m}`}),(0,S.jsx)(A,{label:`Bytes up/down`,value:`${zn(g)} / ${zn(h)}`}),(0,S.jsx)(A,{label:`Rate up/down`,value:`${y.toFixed(2)} / ${b.toFixed(2)} Mbps`}),(0,S.jsx)(A,{label:`Runtime`,value:v?`${_}: ${v}`:_}),(0,S.jsx)(A,{label:`Last command`,value:`${x}: ${ee}`})]})]})}function ge({items:e,emptyText:t}){if(e.length===0)return(0,S.jsx)(me,{title:t,text:`Тестовая телеметрия появится здесь после отчета node-agent.`});let n=[...e].reverse().slice(-24),r=e[0],i=Math.max(...n.map(e=>e.memory_used_bytes||0),1);return(0,S.jsxs)(`div`,{className:`telemetryBox`,children:[(0,S.jsxs)(`div`,{className:`signalStrip compact`,children:[(0,S.jsx)(O,{label:`Память`,value:`${zn(r.memory_used_bytes)} / ${zn(r.memory_total_bytes)}`}),(0,S.jsx)(O,{label:`Процессор`,value:r.cpu_percent==null?`н/д`:`${r.cpu_percent.toFixed(1)}%`}),(0,S.jsx)(O,{label:`Процессы`,value:r.process_count==null?`н/д`:String(r.process_count)}),(0,S.jsx)(O,{label:`Обновлено`,value:V(r.observed_at)})]}),(0,S.jsx)(`div`,{className:`sparkline`,"aria-label":`memory telemetry`,children:n.map(e=>(0,S.jsx)(`span`,{style:{height:`${Math.max(8,Math.round((e.memory_used_bytes||0)/i*100))}%`}},e.id))})]})}function _e({node:e,memberships:t,activeRoles:n,desiredWorkloads:r,observedWorkloads:i,heartbeats:a,telemetry:o,meshLinks:s,syntheticConfig:c,allNodes:l,onSetUpdatePolicy:u,updatePlan:d,updateStatuses:f,labels:p}){let m=a[0],h=o[0],g=N(m?.metadata?.mesh_listener_report),v=N(m?.metadata?.mesh_endpoint_report),y=N(m?.metadata?.mesh_outbound_session_report),b=c?.mesh_listener,x=N(m?.metadata?.mesh_peer_recovery_report),ee=N(m?.metadata?.mesh_peer_connection_intent_report),C=N(m?.metadata?.mesh_peer_connection_manager_report),te=N(m?.metadata?.mesh_rendezvous_lease_report),ne=N(m?.metadata?.mesh_route_path_decision_report),re=N(m?.metadata?.mesh_route_generation_report),ie=N(m?.metadata?.mesh_route_health_config_report),w=c?.service_channel_route_feedback,ae=w?.observations||[],oe=c?.service_channel_remediation_commands||[],T=ht(s).filter(e=>e.source_node_id!==e.target_node_id),se=T.filter(e=>e.link_status===`reachable`),ce=T.filter(e=>e.link_status!==`reachable`),le=Object.entries(m?.capabilities||{}).sort(([e],[t])=>e.localeCompare(t)),E=St(C?.probe_results),[D,ue]=(0,_.useState)(`network`),de=nt(f,`rap-node-agent`),fe=nt(f,`rap-host-agent`),pe=f[0],me=ft(f),he=t.find(t=>t.node.id===e.id)?.cluster.id||t[0]?.cluster.id||``,_e=St(v?.endpoint_candidates),ye=_e[0],be=Ct(v,[`peer_endpoint`,`advertised_endpoint`,`endpoint`])||P(ye,`address`,``)||``,xe=Ct(v,[`transport`,`advertise_transport`])||P(ye,`transport`,``)||`н/д`,Se=Ct(v,[`connectivity_mode`,`connectivity`])||P(ye,`connectivity_mode`,``)||P(g,`inbound_reachability`,``)||`н/д`,Ce=P(v,`nat_type`,P(ye,`nat_type`,`н/д`)),we=P(v,`region`,P(g,`region`,P(ye,`region`,`н/д`))),Te=P(v,`observed_at`,P(g,`observed_at`,m?.observed_at||`н/д`)),j=P(g,`status`,``)||(be?`нет listener report, есть advertised endpoint`:`report отсутствует`),Ee=P(g,`effective_listen_addr`,``)||`н/д`,De=P(g,`configured_listen_addr`,``)||`н/д`,Oe=_e.length>0?_e:be?[{endpoint_id:`${e.id}-reported`,address:be,transport:xe,reachability:Se,connectivity_mode:Se,nat_type:Ce,priority:`н/д`,last_verified_at:Te}]:[],ke=Object.entries(c?.peer_endpoints||{}),Ae=Object.entries(c?.peer_endpoint_candidates||{}).flatMap(([e,t])=>t.map(t=>({peerID:e,candidate:t}))),je=new Set(se.map(t=>t.source_node_id===e.id?t.target_node_id:t.source_node_id)),Me=Ae.filter(({peerID:e})=>!je.has(e)),Ne=[g?`listener report: есть`:`listener report: не прислан агентом`,v?`endpoint report: есть`:`endpoint report: не прислан агентом`,y?`outbound session: есть`:`outbound session: не прислан агентом`,c?`scoped config: ${c.enabled?`enabled`:`disabled`}`:`scoped config: не загружен`,w?`service-channel feedback: ${w.observation_count}`:`service-channel feedback: не загружен`,`active links: ${se.length}/${T.length}`];return(0,S.jsxs)(`div`,{className:`nodeDetails`,children:[(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Сводка runtime`}),(0,S.jsxs)(`div`,{className:`signalStrip compact nodeMetricGrid`,children:[(0,S.jsx)(O,{label:`Heartbeat`,value:m?V(m.observed_at):`н/д`}),(0,S.jsx)(O,{label:`Health`,value:H(m?.health_status||e.health_status)}),(0,S.jsx)(O,{label:`Listener`,value:jn(m)}),(0,S.jsx)(O,{label:`Mesh links`,value:`${se.length}/${T.length}`}),(0,S.jsx)(O,{label:`Web ingress`,value:Pe(m)}),(0,S.jsx)(O,{label:`Update`,value:rt(pe,d)})]}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[(0,S.jsx)(k,{value:e.registration_status}),(0,S.jsx)(k,{value:e.membership_status}),(0,S.jsx)(k,{value:e.partition_state}),(0,S.jsx)(`span`,{className:`pill`,children:e.reported_version||m?.reported_version||`версия неизвестна`}),g?.one_way_connectivity===!0&&(0,S.jsx)(`span`,{className:`pill warn`,children:`one-way`}),g?.port_conflict===!0&&(0,S.jsx)(`span`,{className:`pill bad`,children:`port conflict`})]})]}),(0,S.jsx)(`div`,{className:`nodeTabs`,role:`tablist`,"aria-label":`Node analysis sheets`,children:[[`overview`,`Обзор`],[`network`,`Сеть и адреса`],[`mesh`,`Mesh`],[`services`,`Роли и сервисы`],[`telemetry`,`Телеметрия`],[`updates`,`Обновления`],[`raw`,`Raw`]].map(([e,t])=>(0,S.jsx)(`button`,{className:D===e?`active`:``,onClick:()=>ue(e),type:`button`,children:t},e))}),D===`overview`&&(0,S.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Идентичность и размещение`}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Node ID`,value:e.id}),(0,S.jsx)(A,{label:`Node key`,value:e.node_key}),(0,S.jsx)(A,{label:`Имя`,value:e.name}),(0,S.jsx)(A,{label:`Владение`,value:H(e.ownership_type)}),(0,S.jsx)(A,{label:`Owner org`,value:B(e.owner_organization_id)}),(0,S.jsx)(A,{label:`Группа`,value:e.node_group_name||p.ungroupedNodes}),(0,S.jsx)(A,{label:`Создан`,value:V(e.created_at)}),(0,S.jsx)(A,{label:`Обновлен`,value:V(e.updated_at)})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Участие в кластерах`}),(0,S.jsx)(`div`,{className:`membershipList`,children:t.map(t=>(0,S.jsxs)(`span`,{className:t.node.id===e.id&&t.node.membership_status===`active`?`pill good`:`pill`,children:[t.cluster.name,`: `,H(t.node.membership_status)]},t.cluster.id))}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Активных ролей`,value:String(n.length)}),(0,S.jsx)(A,{label:`Desired workloads`,value:String(r.length)}),(0,S.jsx)(A,{label:`Observed workloads`,value:String(i.length)}),(0,S.jsx)(A,{label:`Последний сигнал`,value:V(e.last_seen_at||m?.observed_at)})]})]})]}),D===`network`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Локальный listener`}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Статус`,value:j}),(0,S.jsx)(A,{label:`Режим порта`,value:P(g,`listen_port_mode`,`н/д`)}),(0,S.jsx)(A,{label:`Configured addr`,value:De}),(0,S.jsx)(A,{label:`Effective addr`,value:Ee}),(0,S.jsx)(A,{label:`Inbound`,value:P(g,`inbound_reachability`,Se)}),(0,S.jsx)(A,{label:`One-way`,value:P(g,`one_way_connectivity`,`н/д`)}),(0,S.jsx)(A,{label:`Port conflict`,value:P(g,`port_conflict`,`false`)}),(0,S.jsx)(A,{label:`Failure`,value:P(g,`failure_error`,P(g,`failure_reason`,`нет`))})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Desired listener`}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Состояние`,value:b?.desired_state||`н/д`}),(0,S.jsx)(A,{label:`Режим порта`,value:b?.listen_port_mode||`н/д`}),(0,S.jsx)(A,{label:`Listen addr`,value:b?.listen_addr||`н/д`}),(0,S.jsx)(A,{label:`Auto range`,value:b?`${b.auto_port_start||`н/д`}-${b.auto_port_end||`н/д`}`:`н/д`}),(0,S.jsx)(A,{label:`Advertise endpoint`,value:b?.advertise_endpoint||`auto-discovery`}),(0,S.jsx)(A,{label:`Advertise transport`,value:b?.advertise_transport||`н/д`}),(0,S.jsx)(A,{label:`Connectivity`,value:b?.connectivity_mode||`н/д`}),(0,S.jsx)(A,{label:`NAT`,value:b?.nat_type||`н/д`}),(0,S.jsx)(A,{label:`Region/site`,value:b?.region||`н/д`}),(0,S.jsx)(A,{label:`Version`,value:b?.config_version||`н/д`})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Что узел сообщает кластеру`}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Advertised endpoint`,value:be||`не прислан`}),(0,S.jsx)(A,{label:`Transport`,value:xe}),(0,S.jsx)(A,{label:`Connectivity`,value:Se}),(0,S.jsx)(A,{label:`NAT`,value:Ce}),(0,S.jsx)(A,{label:`Region/site`,value:we}),(0,S.jsx)(A,{label:`Observed`,value:Te})]})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Исходящий control-channel`}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Status`,value:P(y,`status`,`не прислан`)}),(0,S.jsx)(A,{label:`Direction`,value:P(y,`direction`,`н/д`)}),(0,S.jsx)(A,{label:`Transport`,value:P(y,`transport`,`н/д`)}),(0,S.jsx)(A,{label:`Control Plane`,value:P(y,`control_plane_url`,`н/д`)}),(0,S.jsx)(A,{label:`Reverse usable`,value:P(y,`usable_for_inbound_control`,`н/д`)}),(0,S.jsx)(A,{label:`Inbound required`,value:P(y,`inbound_listener_required`,`н/д`)}),(0,S.jsx)(A,{label:`Relay ready`,value:P(y,`peer_connection_relay_ready`,`0`)}),(0,S.jsx)(A,{label:`Waiting rendezvous`,value:P(y,`peer_connection_waiting`,`0`)}),(0,S.jsx)(A,{label:`Rendezvous leases`,value:P(y,`rendezvous_lease_count`,`0`)}),(0,S.jsx)(A,{label:`Listener conflict`,value:P(y,`listener_port_conflict`,`false`)})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Наличие сетевых отчетов`}),(0,S.jsx)(`div`,{className:`summaryChips`,children:Ne.map(e=>(0,S.jsx)(`span`,{className:e.includes(`не прислан`)||e.includes(`не загружен`)?`pill warn`:`pill good`,children:e},e))}),!v&&!g&&(0,S.jsx)(`p`,{className:`muted`,children:`У этого узла есть heartbeat/mesh manager данные, но агент не передал адресный отчет. До обновления агента или включения endpoint/listener report панель может показать связи и config peers, но не может достоверно назвать локальный listen address.`})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Endpoint candidates узла`}),(0,S.jsx)(M,{columns:[`id`,`address`,`transport`,`reachability`,`mode`,`nat`,`priority`,`verified`],rows:Oe.map(e=>[P(e,`endpoint_id`,`н/д`),P(e,`address`,`н/д`),P(e,`transport`,`н/д`),P(e,`reachability`,`н/д`),P(e,`connectivity_mode`,`н/д`),P(e,`nat_type`,`н/д`),P(e,`priority`,`н/д`),P(e,`last_verified_at`,`н/д`)])})]}),(0,S.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Рабочие peer endpoints из config`}),(0,S.jsx)(M,{columns:[`peer`,`endpoint`],rows:ke.map(([e,t])=>[F(l,e),t])})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Резервные кандидаты peer`}),(0,S.jsx)(M,{columns:[`peer`,`address`,`transport`,`reachability`,`mode`,`priority`],rows:Me.slice(0,20).map(({peerID:e,candidate:t})=>[F(l,e),t.address,t.transport,t.reachability,t.connectivity_mode,String(t.priority)])})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Активные связи этого узла`}),(0,S.jsx)(M,{columns:[`peer`,`направление`,`тип`,`статус`,`latency`,`quality`,`путь`,`наблюдение`],rows:T.slice(0,20).map(t=>[F(l,t.source_node_id===e.id?t.target_node_id:t.source_node_id),t.source_node_id===e.id?`out`:`in`,_t(t),t.link_status,t.latency_ms==null?`н/д`:`${t.latency_ms}мс`,t.quality_score==null?`н/д`:String(t.quality_score),vt(t,l),V(t.observed_at)])}),ce.length>0&&(0,S.jsxs)(`p`,{className:`muted`,children:[`Проблемных связей: `,ce.length,`. Их статус виден в таблице выше.`]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Проверка адресов peer-to-peer`}),(0,S.jsx)(M,{columns:[`peer`,`status`,`selected endpoint`,`candidate`,`latency`,`attempts`,`failure`],rows:E.slice(0,20).map(e=>[F(l,P(e,`node_id`,``)),P(e,`link_status`,`н/д`),P(e,`selected_endpoint`,P(e,`endpoint`,`н/д`)),P(e,`selected_candidate_id`,`н/д`),P(e,`latency_ms`,`н/д`),Dt(e),P(e,`failure_reason`,`нет`)])})]})]}),D===`mesh`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Mesh control-plane`}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Recovery`,value:wn(m)}),(0,S.jsx)(A,{label:`Intents`,value:Tn(m)}),(0,S.jsx)(A,{label:`Manager`,value:An(m)}),(0,S.jsx)(A,{label:`Rendezvous`,value:En(m)}),(0,S.jsx)(A,{label:`Path decisions`,value:Dn(m)}),(0,S.jsx)(A,{label:`Route generation`,value:On(m)}),(0,S.jsx)(A,{label:`Route health`,value:kn(m)}),(0,S.jsx)(A,{label:`Service-channel feedback`,value:w?`${w.healthy_route_count} healthy / ${w.degraded_route_count} degraded / ${w.fenced_route_count} fenced`:`н/д`}),(0,S.jsx)(A,{label:`Recovery policy`,value:w?.recovery_policy?`${w.recovery_policy.source} p${w.recovery_policy.hysteresis_penalty} promote ${w.recovery_policy.promotion_min_samples}`:`н/д`}),(0,S.jsx)(A,{label:`Route policy`,value:c?.route_path_decisions?.recovery_policy?`${c.route_path_decisions.recovery_policy.source} fail/drop/slow ${c.route_path_decisions.recovery_policy.demotion_failure_threshold}/${c.route_path_decisions.recovery_policy.demotion_drop_threshold}/${c.route_path_decisions.recovery_policy.demotion_slow_threshold}`:`н/д`}),(0,S.jsx)(A,{label:`Config version`,value:c?.config_version||`н/д`})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Scoped config counts`}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Peer endpoints`,value:String(ke.length)}),(0,S.jsx)(A,{label:`Endpoint candidates`,value:String(Ae.length)}),(0,S.jsx)(A,{label:`Peer directory`,value:String(c?.peer_directory?.length||0)}),(0,S.jsx)(A,{label:`Recovery seeds`,value:String(c?.recovery_seeds?.length||0)}),(0,S.jsx)(A,{label:`Rendezvous leases`,value:String(c?.rendezvous_leases?.length||0)}),(0,S.jsx)(A,{label:`Routes`,value:String(c?.routes?.length||0)}),(0,S.jsx)(A,{label:`Fenced routes`,value:String(w?.fenced_route_count||0)}),(0,S.jsx)(A,{label:`Remediation commands`,value:String(oe.length)}),(0,S.jsx)(A,{label:`Feedback provenance`,value:w?`missing ${w.missing_provenance_count||0} / stale policy ${w.stale_policy_count||0} / stale gen ${w.stale_generation_count||0}`:`н/д`})]})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Route decisions`}),(0,S.jsx)(M,{columns:[`route`,`replacement`,`source`,`destination`,`effective hops`,`decision`,`score`,`expires`],rows:(c?.route_path_decisions?.decisions||[]).map(e=>[B(e.route_id),e.replacement_route_id?B(e.replacement_route_id):`нет`,F(l,e.source_node_id),F(l,e.destination_node_id),e.effective_hops.map(e=>Pn(F(l,e))).join(` > `),e.decision_source||(e.selected_relay_id?F(l,e.selected_relay_id):`direct`),e.path_score==null?`н/д`:String(e.path_score),V(e.expires_at)])})]}),oe.length>0&&(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Service-channel remediation commands`}),(0,S.jsx)(M,{columns:[`channel`,`action`,`primary`,`replacement`,`guard`,`execution`,`reason`,`expires`],rows:oe.slice(0,20).map(e=>[B(e?.channel_id||``),(0,S.jsx)(`span`,{className:`pill warn`,children:H(e?.action||``)}),e?.primary_route_id?B(e.primary_route_id):`н/д`,e?.replacement_route_id?B(e.replacement_route_id):`н/д`,(0,S.jsx)(`span`,{className:`pill ${e?.guard_status===`rejected`?`bad`:e?.guard_status===`allowed`?`good`:``}`,children:e?.guard_status?H(e.guard_status):`н/д`}),(0,S.jsxs)(`span`,{className:`pill ${Un(e?.execution_status)}`,children:[e?.execution_status?H(e.execution_status):`н/д`,e?.execution_reason?` / ${H(e.execution_reason)}`:``]}),e?.reason||`н/д`,e?.expires_at?V(e.expires_at):`н/д`])})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Service-channel route feedback`}),(0,S.jsx)(M,{columns:[`route`,`service`,`status`,`recovery`,`score`,`reasons`,`failures`,`duration`,`expires`],rows:ae.slice(0,40).map(e=>[B(e.route_id),e.service_class,(0,S.jsx)(`span`,{className:`pill ${st(e.feedback_status)}`,children:H(e.feedback_status)}),e.recovery_state?(0,S.jsxs)(`span`,{className:`pill ${ct(e.recovery_state)}`,children:[e.recovery_demoted?`demoted ${e.recovery_reason?H(e.recovery_reason):``}`:e.recovery_promoted?`promoted`:H(e.recovery_state),e.recovery_hysteresis_penalty?` -${e.recovery_hysteresis_penalty}`:``]}):e.stale_policy||e.stale_generation?(0,S.jsx)(`span`,{className:`pill warn`,children:H(e.stale_reason||`stale`)}):e.provenance_missing?(0,S.jsx)(`span`,{className:`pill warn`,children:`provenance missing`}):`нет`,String(e.score_adjustment),(e.reasons||[]).join(`, `)||`нет`,String(e.consecutive_failures||0),e.last_send_duration_ms==null?`н/д`:`${e.last_send_duration_ms}мс`,V(e.expires_at)])}),ae.length===0&&(0,S.jsx)(`p`,{className:`muted`,children:`Пока нет свежих наблюдений. Узел будет присылать их после реального traffic через service-channel runtime.`})]})]}),D===`services`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:p.nodeRoles}),(0,S.jsxs)(`div`,{className:`serviceTags`,children:[n.length===0&&(0,S.jsx)(`p`,{className:`muted`,children:p.noRoles}),n.map(e=>(0,S.jsxs)(`div`,{className:`serviceTag`,children:[(0,S.jsx)(`strong`,{children:$e(e.role)}),(0,S.jsx)(`span`,{children:e.organization_id?`organization: ${B(e.organization_id)}`:`cluster-wide`}),(0,S.jsx)(`small`,{children:V(e.assigned_at)}),(0,S.jsx)(`span`,{className:`pill ${xn(e.role,m)}`,children:Sn(e.role,m,p)})]},e.id))]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Capabilities`}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[le.length===0&&(0,S.jsx)(`span`,{className:`muted`,children:`Нет capability heartbeat.`}),le.slice(0,40).map(([e,t])=>(0,S.jsx)(`span`,{className:t===!0?`pill good`:`pill`,children:e},e))]})]})]}),(0,S.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:p.desiredServices}),(0,S.jsx)(M,{columns:[`service`,`desired`,`runtime`,`version`,`updated`],rows:r.map(e=>[e.service_type,H(e.desired_state),e.runtime_mode,e.version||`не закреплена`,V(e.updated_at)])})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:p.observedServices}),(0,S.jsx)(M,{columns:[`service`,`reported`,`runtime`,`version`,`observed`],rows:i.map(e=>[e.service_type,H(e.reported_state),e.runtime_mode,e.version||`н/д`,V(e.observed_at)])})]})]})]}),D===`telemetry`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:p.nodeTelemetry}),(0,S.jsx)(ge,{items:o,emptyText:p.noTelemetry}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Disk`,value:`${zn(h?.disk_used_bytes)} / ${zn(h?.disk_total_bytes)}`}),(0,S.jsx)(A,{label:`Network RX/TX`,value:`${zn(h?.network_rx_bytes)} / ${zn(h?.network_tx_bytes)}`}),(0,S.jsx)(A,{label:`Payload`,value:h?.payload?Et(h.payload):`н/д`})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:p.recentHeartbeats}),(0,S.jsx)(M,{columns:[`состояние`,`версия`,`listener`,`mesh recovery`,`mesh intents`,`rv leases`,`path decisions`,`route gen`,`route health`,`наблюдение`],rows:a.slice(0,10).map(e=>[e.health_status,e.reported_version||`неизвестно`,jn(e),wn(e),Tn(e),En(e),Dn(e),On(e),kn(e),V(e.observed_at)])})]})]}),D===`updates`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`div`,{className:`nodeDetailGrid`,children:[(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Текущая сборка`}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Node-agent version`,value:e.reported_version||m?.reported_version||`неизвестно`}),(0,S.jsx)(A,{label:`План`,value:d?`${d.action}: ${d.reason}`:`не загружен`}),(0,S.jsx)(A,{label:`Product`,value:d?.product||`rap-node-agent`}),(0,S.jsx)(A,{label:`Target`,value:d?.target_version||`н/д`}),(0,S.jsx)(A,{label:`Strategy`,value:d?.strategy||`н/д`}),(0,S.jsx)(A,{label:`Rollback`,value:d?.rollback_allowed?`разрешен`:`нет`}),(0,S.jsx)(A,{label:`Artifact`,value:d?.artifact?`${d.artifact.kind} ${d.artifact.os}/${d.artifact.arch}`:`н/д`})]}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{className:`primary`,disabled:!u,onClick:()=>u?.(e,`rap-node-agent`,null),children:`Node-agent latest`}),(0,S.jsx)(`button`,{className:`ghost`,disabled:!u||!d?.target_version,onClick:()=>u?.(e,`rap-node-agent`,d?.target_version||null),children:`Повторить target`}),(0,S.jsx)(`button`,{className:`ghost`,disabled:!u,onClick:()=>u?.(e,`rap-host-agent`,null),children:`Host-agent latest`})]}),(0,S.jsx)(`p`,{className:`muted`,children:`Latest означает policy без закрепленной версии: updater будет брать свежий active release своего канала при следующем цикле или heartbeat hint.`})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Последние отчеты updater`}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Updater health`,value:`${me.label}: ${me.detail}`}),(0,S.jsx)(A,{label:`rap-node-agent`,value:it(de)}),(0,S.jsx)(A,{label:`rap-host-agent`,value:it(fe)}),(0,S.jsx)(A,{label:`Всего отчетов`,value:String(f.length)}),(0,S.jsx)(A,{label:`Последний отчет`,value:V(pe?.observed_at)})]}),(0,S.jsxs)(`div`,{className:`summaryChips`,children:[(0,S.jsx)(`span`,{className:`pill ${me.tone}`,children:me.label}),de&&(0,S.jsxs)(`span`,{className:`pill ${ot(de)}`,children:[`node-agent: `,de.status]}),fe&&(0,S.jsxs)(`span`,{className:`pill ${ot(fe)}`,children:[`host-agent: `,fe.status]}),!de&&!fe&&(0,S.jsx)(`span`,{className:`pill warn`,children:`updater пока не отчитался`})]})]})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`История обновлений`}),(0,S.jsx)(M,{columns:[`product`,`current`,`target`,`phase`,`status`,`attempt`,`error`,`observed`],rows:f.slice(0,40).map(e=>[e.product,e.current_version||`н/д`,e.target_version||`н/д`,e.phase,(0,S.jsx)(`span`,{className:`pill ${ot(e)}`,children:e.status}),e.attempt_id?B(e.attempt_id):`н/д`,e.error_message||`нет`,V(e.observed_at)])})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Windows repair/update command`}),(0,S.jsx)(`p`,{className:`muted`,children:`Для существующего Windows-узла эта команда переустанавливает wrapper updater без нового join-token, сохраняет local state и запускает обновление до актуальной сборки.`}),(0,S.jsxs)(`div`,{className:`stateList compact`,children:[(0,S.jsx)(A,{label:`Когда выполнять`,value:`если updater stale, host-agent не отчитался или Windows-узел не доходит до target version`}),(0,S.jsx)(A,{label:`Control Plane`,value:un()}),(0,S.jsx)(A,{label:`Join-token`,value:`не нужен для repair существующего узла`})]}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{className:`primary`,onClick:()=>nn($t(e),Qt(e,he)),children:`Скачать repair .cmd`}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>void rn(Qt(e,he)),children:`Скопировать команду`})]}),(0,S.jsx)(`pre`,{className:`codePreview`,children:Qt(e,he)})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Linux repair/update command`}),(0,S.jsx)(`p`,{className:`muted`,children:`Для существующего Ubuntu/Linux-узла эта команда восстанавливает systemd updater без нового join-token, сохраняет local state и делает одноразовую проверку обновления.`}),(0,S.jsxs)(`div`,{className:`stateList compact`,children:[(0,S.jsx)(A,{label:`Когда выполнять`,value:`если host-agent не отчитался, updater stale или Linux-узел не доходит до target version`}),(0,S.jsx)(A,{label:`Control Plane`,value:un()}),(0,S.jsx)(A,{label:`Join-token`,value:`не нужен для repair существующего узла`})]}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{className:`primary`,onClick:()=>nn(tn(e),en(e,he)),children:`Скачать repair .sh`}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>void rn(en(e,he)),children:`Скопировать команду`})]}),(0,S.jsx)(`pre`,{className:`codePreview`,children:en(e,he)})]}),(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Payload последнего отчета`}),(0,S.jsxs)(`div`,{className:`rawDetailsGrid`,children:[(0,S.jsx)(ve,{title:`rap-node-agent update status`,value:de}),(0,S.jsx)(ve,{title:`rap-host-agent update status`,value:fe}),(0,S.jsx)(ve,{title:`Update plan`,value:d})]})]})]}),D===`raw`&&(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsx)(`h4`,{children:`Raw данные узла`}),(0,S.jsxs)(`div`,{className:`rawDetailsGrid`,children:[(0,S.jsx)(ve,{title:`Последний heartbeat metadata`,value:m?.metadata}),(0,S.jsx)(ve,{title:`Heartbeat capabilities`,value:m?.capabilities}),(0,S.jsx)(ve,{title:`Heartbeat service states`,value:m?.service_states}),(0,S.jsx)(ve,{title:`Synthetic mesh config`,value:c}),(0,S.jsx)(ve,{title:`Listener report`,value:g}),(0,S.jsx)(ve,{title:`Endpoint report`,value:v}),(0,S.jsx)(ve,{title:`Peer recovery report`,value:x}),(0,S.jsx)(ve,{title:`Connection intent report`,value:ee}),(0,S.jsx)(ve,{title:`Connection manager report`,value:C}),(0,S.jsx)(ve,{title:`Rendezvous lease report`,value:te}),(0,S.jsx)(ve,{title:`Route decision report`,value:ne}),(0,S.jsx)(ve,{title:`Route generation report`,value:re}),(0,S.jsx)(ve,{title:`Route health report`,value:ie})]})]})]})}function ve({title:e,value:t}){return(0,S.jsxs)(`details`,{className:`rawBlock`,children:[(0,S.jsx)(`summary`,{children:e}),(0,S.jsx)(`pre`,{children:t==null?`н/д`:JSON.stringify(t,null,2)})]})}function ye({runtime:e}){return(0,S.jsxs)(`div`,{className:`runtimeBadges`,children:[(0,S.jsx)(`span`,{className:`pill ${e.agentTone}`,children:e.agentLabel}),(0,S.jsx)(`span`,{className:`pill ${e.clientTone}`,children:e.clientLabel}),(0,S.jsx)(`span`,{className:`pill ${e.outboundTone}`,children:e.outboundLabel}),(0,S.jsx)(`span`,{className:`pill ${e.inboundTone}`,children:e.inboundLabel})]})}function be({node:e,fallback:t,heartbeatsByNode:n,meshLinks:r}){if(!e)return t;let i=wt(e,n[e.id]||[],r);return(0,S.jsxs)(`div`,{className:`nodeEndpointCell`,children:[(0,S.jsx)(`strong`,{children:e.name}),(0,S.jsx)(ye,{runtime:i}),(0,S.jsx)(`small`,{children:i.address})]})}function xe({nodes:e,links:t,heartbeatsByNode:n,rolesByNode:r,workloadsByNode:i,labels:a,emptyText:o}){let[s,c]=(0,_.useState)(null);if(e.length===0)return(0,S.jsx)(me,{title:`Нет узлов`,text:`Одобренные node-agent появятся на карте после первого heartbeat.`});let l=ht(t).filter(e=>e.source_node_id!==e.target_node_id),u=new Map(e.map(e=>[e.id,e])),d=l.filter(e=>Ae(e)&&!Re(e,u)).map(e=>({link:e,status:ke(e,l,u),kind:`direct`})).filter(e=>e.status===`reachable`||e.status===`one_way`),f=l.filter(e=>je(e)&&!Re(e,u)).map(e=>({link:e,status:ke(e,l,u),kind:`relay`})).filter(e=>e.status===`reachable`||e.status===`one_way`),p=l.filter(e=>bt(e,`observation_type`)===`synthetic_route_health`&&!Re(e,u)&&e.link_status===`reachable`).map(e=>({link:e,status:`reachable`,kind:`route`})),m=Se(d,f,p),h=l.filter(e=>Re(e,u)),g=l.filter(e=>!Re(e,u)&&e.link_status!==`reachable`),v=Me(m.map(e=>e.link)),y=new Map(e.map(e=>[e.id,Ne(n[e.id]?.[0])])),b=[...y.values()].filter(e=>e.mode===`active`).length,x=[...y.values()].filter(e=>e.mode===`passive`).length,ee=[...y.values()].filter(e=>e.mode===`mixed`).length,C=ze(e.length),te=Be(e.length),ne=He(e,C.height,te),re=new Map(e.map(e=>[e.id,Ce(e.id,m)]));return(0,S.jsxs)(`div`,{className:`topologyShell`,children:[(0,S.jsxs)(`svg`,{className:`topologySvg`,viewBox:`0 0 ${C.width} ${C.height}`,role:`img`,"aria-label":`Карта трафика узлов Fabric`,children:[(0,S.jsx)(`defs`,{children:(0,S.jsx)(`marker`,{id:`arrow`,markerHeight:`8`,markerWidth:`8`,orient:`auto`,refX:`7`,refY:`4`,children:(0,S.jsx)(`path`,{d:`M0,0 L8,4 L0,8 Z`,fill:`currentColor`})})}),m.map(({link:t,status:n,kind:r})=>{let i=ne.get(t.source_node_id),a=ne.get(t.target_node_id);if(!i||!a)return null;let o=m.some(e=>e.link.source_node_id===t.target_node_id&&e.link.target_node_id===t.source_node_id),s=Ke(t.source_node_id,t.target_node_id,r),l=qe({source:i,target:a,sourceNodeID:t.source_node_id,targetNodeID:t.target_node_id,positions:ne,nodeRadius:te,endpointOffset:te+8,laneOffset:o?9:0,laneSign:s,routeKind:r}),u=Te(De(t,e,n)),d=j(l.labelX,l.labelY,C.width,C.height);return(0,S.jsxs)(`g`,{className:`topologyLinkGroup`,onMouseEnter:()=>c({...u,...d}),onMouseLeave:()=>c(null),children:[(0,S.jsx)(`title`,{children:[u.title,...u.lines].join(` -`)}),(0,S.jsx)(`path`,{d:l.d,className:`topologyLink ${Nn(t,n)} ${r}`,markerEnd:`url(#arrow)`}),m.length<=Math.max(6,e.length)&&(0,S.jsx)(`text`,{x:l.labelX,y:l.labelY-8,className:`topologyLinkLabel`,children:Oe(t,n,r)})]},`${r}-${t.id||`${t.source_node_id}-${t.target_node_id}`}`)}),e.map(t=>{let a=ne.get(t.id),o=Ve(e.length),s=re.get(t.id)||`isolated`,l=y.get(t.id)||{mode:`unknown`,detail:`no heartbeat`},u=Fe(n[t.id]?.[0]),d=Te(Ee(t,l,s,u,n[t.id]?.[0],r[t.id]||[],i[t.id]||[])),f=j(a.x,a.y+te+12,C.width,C.height);return(0,S.jsxs)(`g`,{className:`topologyNode`,onMouseEnter:()=>c({...d,...f}),onMouseLeave:()=>c(null),children:[(0,S.jsx)(`title`,{children:[d.title,...d.lines].join(` -`)}),(0,S.jsx)(`circle`,{cx:a.x,cy:a.y,r:te,className:`topologyNodeCircle ${t.health_status} ${l.mode} web-${u}`}),(0,S.jsx)(`text`,{x:a.x,y:a.y-o.nameOffset,className:`topologyNodeName`,style:{fontSize:o.name},children:Pn(t.name,o.maxChars)}),(0,S.jsx)(`text`,{x:a.x,y:a.y+o.metaOffset,className:`topologyNodeMeta`,style:{fontSize:o.meta},children:we(l.mode,s)})]},t.id)}),m.length===0&&(0,S.jsx)(`text`,{x:C.width/2,y:C.height-34,className:`topologyEmpty`,children:o}),s&&(0,S.jsx)(`foreignObject`,{x:s.x,y:s.y,width:`360`,height:`190`,className:`topologyTooltipObject`,children:(0,S.jsxs)(`div`,{className:`topologyTooltip`,children:[(0,S.jsx)(`strong`,{children:s.title}),s.lines.slice(0,6).map(e=>(0,S.jsx)(`span`,{children:e},e))]})})]}),(0,S.jsxs)(`div`,{className:`topologyLegend`,children:[(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendLine observed`}),` direct: `,d.length]}),(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendLine relay`}),` relay: `,f.length]}),(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendLine route`}),` route-health: `,p.length]}),(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendLine observed`}),` bidirectional pairs: `,v]}),(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendLine stale`}),` stale/problem: `,h.length,`/`,g.length]}),(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendDot webReady`}),` web ready`]}),(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendDot webDegraded`}),` web degraded`]}),(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendDot webBlocked`}),` web blocked`]}),(0,S.jsxs)(`span`,{children:[`active/passive/mixed: `,b,`/`,x,`/`,ee]})]}),(0,S.jsxs)(`details`,{className:`sectionBlock fabricNodeDiagnostics`,children:[(0,S.jsxs)(`summary`,{children:[`Диагностика узлов (`,e.length,`)`]}),(0,S.jsx)(`div`,{className:`serviceTags`,children:e.map(e=>(0,S.jsxs)(`div`,{className:`serviceTag`,children:[(0,S.jsx)(`strong`,{children:e.name}),(0,S.jsxs)(`span`,{children:[H(e.health_status),` / `,H(y.get(e.id)?.mode||`unknown`),` / mesh `,H(re.get(e.id)||`isolated`)]}),(0,S.jsx)(`small`,{children:Pe(n[e.id]?.[0])}),(0,S.jsx)(`small`,{children:Ze(r[e.id]||[])}),(0,S.jsx)(`small`,{children:et(i[e.id]||[])})]},e.id))})]})]})}function Se(e,t,n){let r=[],i=new Set,a=e=>{let t=`${e.link.source_node_id}->${e.link.target_node_id}`;i.has(t)||(i.add(t),r.push(e))};return e.forEach(a),t.forEach(a),n.forEach(a),r}function Ce(e,t){let n=t.filter(t=>t.link.source_node_id!==t.link.target_node_id&&(t.link.source_node_id===e||t.link.target_node_id===e));return n.some(e=>e.status===`reachable`||e.status===`one_way`)?`connected`:n.some(e=>e.status!==`stale`)?`degraded`:`isolated`}function we(e,t){return`${e===`active`?`A`:e===`passive`?`P`:e===`mixed`?`M`:`?`} / ${t===`connected`?`mesh`:t===`degraded`?`degr`:`iso`}`}function Te(e){let[t,...n]=e.split(` -`).filter(Boolean);return{title:t||`Fabric`,lines:n}}function j(e,t,n,r){return{x:Math.min(Math.max(18,e+16),Math.max(18,n-360-18)),y:Math.min(Math.max(18,t+12),Math.max(18,r-190-18))}}function Ee(e,t,n,r,i,a,o){return[e.name,`health: ${H(e.health_status)}`,`mode: ${H(t.mode)} (${t.detail})`,`mesh: ${H(n)}`,`web ingress: ${H(r)} - ${Pe(i)}`,`roles: ${Ze(a)}`,`services: ${et(o)}`].join(` -`)}function De(e,t,n){let r=F(t,e.source_node_id),i=F(t,e.target_node_id),a=V(e.observed_at),o=bt(e,`observation_type`)||`link`,s=bt(e,`transport_mode`)||`direct`,c=vt(e,t),l=e.latency_ms==null?`н/д`:`${e.latency_ms}мс`;return[`${r} -> ${i}`,`status: ${yt(e,t,n)}`,`type: ${o}`,`mode: ${s}`,`latency: ${l}`,`path: ${c}`,`observed: ${a}`].join(` -`)}function Oe(e,t,n=`direct`){return t===`one_way`?`1w`:n===`relay`?`relay`:n===`route`?`route`:e.latency_ms!=null&&e.latency_ms>0?`${e.latency_ms}мс`:``}function ke(e,t,n){if(Re(e,n))return`stale`;if(e.link_status!==`reachable`)return e.link_status===`degraded`||e.link_status===`unreachable`?e.link_status:`unknown`;let r=t.find(t=>t.source_node_id===e.target_node_id&&t.target_node_id===e.source_node_id&&!Re(t,n));return!r||r.link_status!==`reachable`?`one_way`:`reachable`}function Ae(e){if(e.link_status!==`reachable`||bt(e,`observation_type`)!==`peer_connection_manager`)return!1;let t=bt(e,`transport_mode`);return t===`relay_control`||bt(e,`relay_node_id`)?!1:e.metadata?.direct_candidate===!0||t===`direct_quic`||t===`private_lan`}function je(e){return bt(e,`observation_type`)===`peer_connection_manager`?bt(e,`transport_mode`)===`relay_control`||!!bt(e,`relay_node_id`):!1}function Me(e){let t=new Set(e.map(e=>`${e.source_node_id}->${e.target_node_id}`)),n=new Set;for(let r of e){if(!t.has(`${r.target_node_id}->${r.source_node_id}`))continue;let e=[r.source_node_id,r.target_node_id].sort().join(`<->`);n.add(e)}return n.size}function Ne(e){if(!e)return{mode:`unknown`,detail:`no heartbeat`};let t=e.metadata||{},n=N(t.mesh_endpoint_report),r=N(t.mesh_listener_report),i=N(t.mesh_peer_connection_manager_report),a=Tt(i,`peer_connection_ready`),o=Tt(i,`peer_connection_relay_ready`),s=Tt(i,`peer_connection_waiting_rendezvous`),c=P(r,`status`,``),l=P(n,`connectivity_mode`,``),u=Ct(n,[`peer_endpoint`,`advertised_endpoint`,`endpoint`]),d=c===`listening`||c===`auto_rebound`,f=l===`outbound_only`||s>0||o>a,p=[d?`listen`:`no-listen`,a?`direct${a}`:``,o?`relay${o}`:``,u?u.replace(/^quic:\/\//,``):``].filter(Boolean);return f&&a>0?{mode:`mixed`,detail:p.join(` `)||`mixed`}:f?{mode:`passive`,detail:p.join(` `)||`outbound/relay`}:d||a>0?{mode:`active`,detail:p.join(` `)||`direct`}:{mode:`unknown`,detail:p.join(` `)||`no runtime`}}function Pe(e){let t=N(e?.metadata?.web_ingress_runtime_receiver_report);if(!t)return`web ingress: no report`;let n=t.enabled===!0||t.handler_installed===!0,r=Tt(t,`trusted_key_count`),i=Rt(t.service_classes),a=P(t,`status`,``),o=t.quic_fabric_ready===!0||a===`ready`,s=P(t,`reason`,n?`ready`:`blocked`),c=P(t,`quic_fabric_error`,``),l=i.length>0?i.join(`,`):`no classes`;return n?`web ingress: ${o?`ready`:a||s||`handler`} / keys ${r} / ${c||l}`:`web ingress: ${s}`}function Fe(e){let t=N(e?.metadata?.web_ingress_runtime_receiver_report);if(!t)return`missing`;let n=P(t,`status`,``);return n===`ready`||n===`degraded`||n===`blocked`?n:t.handler_installed===!0?`degraded`:`blocked`}function Ie(e,t){let n={ready:0,degraded:0,blocked:0,missing:0};for(let r of e){let e=Fe(t[r.id]?.[0]);e===`ready`?n.ready+=1:e===`blocked`?n.blocked+=1:e===`degraded`?n.degraded+=1:n.missing+=1}return{...n,label:`${n.ready}/${e.length} ready, ${n.degraded} degraded, ${n.blocked} blocked`}}function Le(e){let t=new Set;for(let n of Qe(e))switch(n.role){case`global-admin-runtime`:t.add(`platform_admin`);break;case`cluster-admin-runtime`:t.add(`cluster_admin`);break;case`organization-portal-runtime`:t.add(`organization_portal`);break;case`user-portal-runtime`:t.add(`user_portal`);break}return[...t]}function Re(e,t){if(e.link_status===`stale`||e.metadata?.derived_link_stale===!0)return!0;let n=new Date(e.observed_at).getTime();if(!Number.isFinite(n)||Date.now()-n>900*1e3)return!0;if(!t)return!1;let r=t.get(e.source_node_id),i=t.get(e.target_node_id);return r?.health_status!==`healthy`||i?.health_status!==`healthy`}function ze(e){let t=Ue(e),n=Math.max(Math.ceil(e/t),1);return{width:1280,height:Math.max(720,220+n*148)}}function Be(e){return e>48?22:e>24?26:e>12?32:e>6?40:46}function Ve(e){return e>48?{name:11,meta:8,nameOffset:5,metaOffset:10,memoryOffset:0,maxChars:9}:e>24?{name:13,meta:9,nameOffset:6,metaOffset:12,memoryOffset:0,maxChars:11}:e>12?{name:15,meta:10,nameOffset:7,metaOffset:14,memoryOffset:0,maxChars:13}:e>6?{name:18,meta:12,nameOffset:8,metaOffset:15,memoryOffset:0,maxChars:15}:{name:20,meta:13,nameOffset:9,metaOffset:16,memoryOffset:0,maxChars:18}}function He(e,t,n){let r=Ue(e.length),i=Math.max(1,Math.ceil(e.length/r)),a=n+98,o=1280-n-98,s=r===1?0:(o-a)/(r-1),c=n+88,l=t-n-88,u=i===1?0:(l-c)/(i-1);return new Map(e.map((e,t)=>{let n=t%r,o=Math.floor(t/r);return[e.id,{x:Math.round(r===1?560:a+s*n),y:Math.round(i===1?(c+l)/2:c+u*o)}]}))}function Ue(e){return e>48?8:e>24?6:e>12?5:e>6?4:e>3?3:Math.max(1,e)}function We(e,t,n){let r=t.x-e.x,i=t.y-e.y,a=Math.max(Math.sqrt(r*r+i*i),1),o=r/a*n,s=i/a*n;return{x1:e.x+o,y1:e.y+s,x2:t.x-o,y2:t.y-s}}function Ge(e,t,n,r,i){let a=We(e,t,n);if(r===0)return a;let o=t.x-e.x,s=t.y-e.y,c=Math.max(Math.sqrt(o*o+s*s),1),l=-s/c*r*i,u=o/c*r*i;return{x1:a.x1+l,y1:a.y1+u,x2:a.x2+l,y2:a.y2+u}}function Ke(e,t,n){let r=`${e}:${t}:${n}`,i=0;for(let e=0;e=.94)continue;let o=u.x1+i*d,s=u.y1+i*f,c=Math.sqrt((t.x-o)**2+(t.y-s)**2);c0?Math.max(72,a+34-_+g*28):0,b=(s+v+y)*c;if(Math.abs(b)<1)return{d:`M ${u.x1} ${u.y1} L ${u.x2} ${u.y2}`,labelX:(u.x1+u.x2)/2,labelY:(u.y1+u.y2)/2};let x=(u.x1+u.x2)/2,ee=(u.y1+u.y2)/2,S=x+m*b,C=ee+h*b;return{d:`M ${u.x1} ${u.y1} Q ${S} ${C} ${u.x2} ${u.y2}`,labelX:(u.x1+2*S+u.x2)/4,labelY:(u.y1+2*C+u.y2)/4}}function Je({children:e}){return(0,S.jsx)(`div`,{className:`formGrid`,children:e})}function M({columns:e,rows:t}){return t.length===0?(0,S.jsx)(me,{title:`Нет данных`,text:`В текущей области пока нечего показать.`}):(0,S.jsx)(`div`,{className:`tableWrap`,children:(0,S.jsxs)(`table`,{children:[(0,S.jsx)(`thead`,{children:(0,S.jsx)(`tr`,{children:e.map(e=>(0,S.jsx)(`th`,{children:e},e))})}),(0,S.jsx)(`tbody`,{children:t.map((e,t)=>(0,S.jsx)(`tr`,{children:e.map((e,n)=>(0,S.jsx)(`td`,{children:e},`${t}-${n}`))},t))})]})})}function Ye(e,t){let n=JSON.parse(e||`{}`);if(!n||Array.isArray(n)||typeof n!=`object`)throw Error(`${t}: требуется JSON object.`);return n}function Xe(e,t){let n=JSON.parse(e||`[]`);if(!Array.isArray(n))throw Error(`${t}: требуется JSON array.`);return n}function Ze(e){let t=Qe(e);return t.length===0?`активные роли не назначены`:t.map(e=>`${$e(e.role)}${e.organization_id?` @ ${B(e.organization_id)}`:``}`).join(`, `)}function Qe(e){return e.filter(e=>e.status===`active`)}function $e(e){let t=w[e];return t?`${t} (${e})`:e}function et(e){return e.length===0?`нет сервисов`:e.map(e=>`${e.service_type}:${e.reported_state}`).join(`, `)}function tt(e,t,n){let r=n.find(e=>e.product===`rap-node-agent`&&e.channel===`stable`&&e.status===`active`)||n.find(e=>e.product===`rap-node-agent`&&e.status===`active`),i=e.reported_version||``,a=t?.target_version||r?.version||``;return e.version_state&&e.version_state!==`unknown`?{status:e.version_state,targetLabel:a?`target ${a}`:`policy target unknown`}:i?t?.action===`update`?{status:`outdated`,targetLabel:`target ${t.target_version||a}`}:a&&i!==a?{status:`outdated`,targetLabel:`latest ${a}`}:a&&i===a?{status:`current`,targetLabel:`latest ${a}`}:{status:t?.reason===`no_update_policy`?`no_policy`:`unknown`,targetLabel:t?.reason||`release policy unknown`}:{status:`unknown`,targetLabel:a?`target ${a}`:`target unknown`}}function nt(e,t){return e.find(e=>e.product===t)}function rt(e,t){return e?`${e.product}: ${e.phase}/${e.status}`:t?`${t.action}: ${t.reason}`:`нет отчета`}function it(e){if(!e)return`нет отчета`;let t=e.target_version?` -> ${e.target_version}`:``,n=e.error_message?`, ошибка: ${e.error_message}`:``;return`${e.current_version||`н/д`}${t}, ${e.phase}/${e.status}, ${V(e.observed_at)}${n}`}function at(e){return e?`push ${e.pushed||0} / pop ${e.popped||0} / q ${e.queue_depth||0} / drop ${e.dropped||0}`:`нет данных`}function ot(e){if(!e)return`warn`;let t=`${e.phase}:${e.status}`.toLowerCase();return t.includes(`error`)||t.includes(`failed`)||t.includes(`rollback`)?`bad`:t.includes(`success`)||t.includes(`updated`)||t.includes(`noop`)||t.includes(`already_current`)?`good`:t.includes(`download`)||t.includes(`replace`)||t.includes(`plan`)||t.includes(`apply`)?`warn`:``}function st(e){let t=e.toLowerCase();return t===`healthy`?`good`:t===`fenced`?`bad`:t===`degraded`||t===`operator_retry_cooldown`?`warn`:``}function ct(e){let t=e.toLowerCase();return t===`healthy`?`good`:t===`recovered`||t===`cooldown`||t===`degraded`?`warn`:t===`fenced`||t===`demoted`?`bad`:``}function lt(e){if(e.status===`disabled`||e.lifecycle_status===`disabled`)return`disabled`;if(e.is_expired||e.lifecycle_status===`expired`)return`expired`;let t=Date.parse(e.policy_expires_at||``);return Number.isFinite(t)&&t<=Date.now()?`expired`:e.lifecycle_status||e.status||`active`}function ut(e){let t=lt(e);return t===`active`?`good`:t===`expired`?`warn`:t===`disabled`?``:`warn`}function dt(e){let t=typeof e.node_id==`string`?e.node_id:``;if(t)return B(t);let n=Array.isArray(e.node_ids)?e.node_ids.filter(e=>typeof e==`string`):[];return n.length>0?n.map(B).join(`, `):`selector`}function ft(e){let t=nt(e,`rap-node-agent`),n=nt(e,`rap-host-agent`);if(!t&&!n)return{label:`updater: нет отчета`,detail:`repair/update task не отчитался`,tone:`bad`};let r=[t,n].some(e=>e&&pt(e)),i=!n,a=n?.phase===`apply`&&n?.status===`staged`,o=[t,n].some(e=>e&&ot(e)===`bad`),s=t?`${t.current_version||`?`}->${t.target_version||`?`}`:`node ?`,c=n?`${n.current_version||`?`}->${n.target_version||`?`}`:`host ?`,l=V((n||t)?.observed_at);return o?{label:`updater: ошибка`,detail:`${s}; ${c}; ${l}`,tone:`bad`}:i?{label:`repair updater`,detail:`host-agent не отчитался; ${s}; ${l}`,tone:`warn`}:a?{label:`host-agent staged`,detail:`${c}; нужен следующий запуск updater`,tone:`warn`}:r?{label:`updater: stale`,detail:`${s}; ${c}; ${l}`,tone:`warn`}:{label:`updater: ok`,detail:`${s}; ${c}; ${l}`,tone:`good`}}function pt(e){let t=new Date(e.observed_at).getTime();return!Number.isFinite(t)||Date.now()-t>900*1e3}function mt(e){let t=typeof e.scope?.node_name==`string`?e.scope.node_name:``,n=typeof e.scope?.purpose==`string`?e.scope.purpose:``;return t||n||B(e.id)}function ht(e){let t=new Map;for(let n of e){let e=`${n.source_node_id}->${n.target_node_id}:${gt(n)}`,r=t.get(e);(!r||new Date(n.observed_at).getTime()>new Date(r.observed_at).getTime())&&t.set(e,n)}return[...t.values()]}function gt(e){let t=bt(e,`observation_type`)||`default`;return t===`synthetic_route_health`?`${t}:${bt(e,`route_id`)||e.id}`:t===`peer_connection_manager`?`${t}:${bt(e,`transport_mode`)}:${bt(e,`relay_node_id`)}`:t}function _t(e){let t=bt(e,`observation_type`);if(t===`synthetic_route_health`){let t=e.metadata?.route_path_drift_detected===!0?`drift`:`ok`;return`route-health ${e.metadata?.route_path_decision_applied===!0?`decision`:`route`} ${t}`}if(t===`peer_connection_manager`){let t=bt(e,`transport_mode`)||`manager`,n=bt(e,`connection_state`);return n?`${t} ${n}`:t}return t||`link`}function vt(e,t){let n=bt(e,`route_id`),r=bt(e,`route_path_decision_selected_relay_id`)||bt(e,`relay_node_id`),i=xt(e,`expected_effective_hops`),a=xt(e,`observed_ack_path`),o=i.length>0?i:a,s=[];return n&&s.push(B(n)),r&&s.push(`via ${B(r)}`),o.length>0&&s.push(o.map(e=>Pn(F(t,e))).join(` > `)),s.length>0?s.join(` / `):`н/д`}function yt(e,t,n=e.link_status===`reachable`?`reachable`:`unknown`){if(n===`stale`)return`stale`;if(n===`one_way`)return`one-way`;let r=bt(e,`observation_type`);if(r===`synthetic_route_health`){let n=bt(e,`route_path_decision_selected_relay_id`);return n?`relay ${Pn(F(t,n),10)}`:e.metadata?.route_path_drift_detected===!0?`drift`:`route`}if(r===`peer_connection_manager`){let n=bt(e,`transport_mode`),r=bt(e,`relay_node_id`);if(n===`relay_control`||r)return r?`relay ${Pn(F(t,r),10)}`:`relay`;if(n===`direct_quic`||n===`private_lan`||bt(e,`direct_candidate`)===`true`)return e.latency_ms==null?`direct`:`${e.latency_ms}мс`;if(n)return H(n)}return e.latency_ms==null?`связь`:`${e.latency_ms}мс`}function bt(e,t){let n=e.metadata?.[t];return typeof n==`string`?n:``}function xt(e,t){let n=e.metadata?.[t];return Array.isArray(n)?n.filter(e=>typeof e==`string`):[]}function N(e){return e&&typeof e==`object`&&!Array.isArray(e)?e:void 0}function St(e){return Array.isArray(e)?e.map(e=>N(e)).filter(e=>!!e):[]}function P(e,t,n=``){let r=e?.[t];return typeof r==`string`?r:typeof r==`number`||typeof r==`boolean`?String(r):n}function Ct(e,t){for(let n of t){let t=P(e,n,``);if(t)return t}return``}function wt(e,t,n){let r=t[0],i=r?.metadata||{},a=N(i.mesh_listener_report),o=N(i.mesh_endpoint_report),s=N(i.mesh_outbound_session_report),c=N(i.mesh_peer_connection_manager_report),l=N(i.mesh_peer_recovery_report),u=St(o?.endpoint_candidates)[0],d=Ct(o,[`peer_endpoint`,`advertised_endpoint`,`endpoint`])||P(u,`address`,``)||P(a,`effective_listen_addr`,``)||`адрес не прислан`;if(!r&&!e.last_seen_at)return{agentLabel:`agent: no heartbeat`,agentTone:`bad`,clientLabel:`client: unknown`,clientTone:`warn`,outboundLabel:`outbound: no heartbeat`,outboundTone:`bad`,inboundLabel:`inbound: unknown`,inboundTone:`warn`,address:d,detail:`Узел создан/одобрен, но node-agent еще ни разу не прислал heartbeat.`};let f=Tt(c,`peer_connection_ready`)||Tt(l,`peer_connection_ready`)||ht(n).filter(t=>(t.source_node_id===e.id||t.target_node_id===e.id)&&t.link_status===`reachable`).length,p=Tt(c,`peer_connection_total`)||Tt(l,`peer_connection_total`)||ht(n).filter(t=>t.source_node_id===e.id||t.target_node_id===e.id).length,m=Tt(c,`failed`),h=P(a,`status`,``),g=a?.port_conflict===!0,_=a?.one_way_connectivity===!0||P(o,`connectivity_mode`,``)===`outbound_only`||Tt(c,`peer_connection_relay_ready`)>0,v=`inbound: no report`,y=`warn`;h===`listening`||h===`auto_rebound`?(v=h===`auto_rebound`?`inbound: auto port`:`inbound: listening`,y=`good`):h===`listen_failed`?(v=g?`inbound: port busy`:`inbound: failed`,y=`bad`):h===`disabled`?(v=`inbound: disabled`,y=_?`warn`:`bad`):o&&(v=`inbound: advertised`,y=`good`);let b=`client: no peers`,x=`warn`;f>0?(b=`client: ready ${f}/${Math.max(p,f)}`,x=`good`):(m>0||p>0)&&(b=`client: backoff ${f}/${Math.max(p,m)}`,x=`bad`);let ee=P(s,`status`,``),S=s?.usable_for_inbound_control===!0,C=Tt(s,`peer_connection_relay_ready`),te=Tt(s,`rendezvous_lease_count`),ne=`outbound: no report`,re=`warn`;ee===`ready`?(ne=S?`outbound: ready reverse`:`outbound: ready`,re=`good`):ee===`backoff`||ee===`failed`?(ne=`outbound: ${ee}`,re=`bad`):(_||C>0||te>0)&&(ne=`outbound: inferred`,re=`warn`);let ie=e.health_status===`healthy`?`good`:e.health_status===`unknown`?`warn`:`bad`;return{agentLabel:r?`agent: heartbeat`:`agent: stale`,agentTone:ie,clientLabel:_&&f>0?`${b} one-way`:b,clientTone:x,outboundLabel:ne,outboundTone:re,inboundLabel:v,inboundTone:y,address:d,detail:P(a,`failure_error`,P(a,`failure_reason`,``))}}function Tt(e,t,n=0){let r=e?.[t];return typeof r==`number`&&Number.isFinite(r)?r:n}function Et(e){if(e==null)return`н/д`;let t=JSON.stringify(e);return t.length>140?`${t.slice(0,137)}...`:t}function Dt(e){let t=St(e.candidate_results);return t.length===0?`н/д`:t.slice(0,4).map(e=>{let t=P(e,`candidate_id`,`candidate`),n=P(e,`link_status`,`unknown`),r=P(e,`latency_ms`,``);return r&&r!==`0`?`${t}:${n}:${r}мс`:`${t}:${n}`}).join(`, `)}function Ot(e){return Object.values(e.peer_endpoint_candidates||{}).reduce((e,t)=>e+t.length,0)}function kt(e){let t=e?.rendezvous_relay_policy;if(!t)return`none`;let n=[`stale${t.stale_relay_count}`,`wd${t.withdrawn_lease_count}`,`repl${t.replacement_lease_count}`];t.scoring_mode.includes(`synthetic_route_health_feedback`)&&n.push(`rh feedback`);let r=t.decisions?.find(e=>e.selected_relay_id);return r?.selected_relay_id&&n.push(`via ${B(r.selected_relay_id)}`),n.join(` `)}function At(e){let t=e?.route_path_decisions;if(!t)return`none`;let n=[`path${t.decision_count}`,`repl${t.replacement_decision_count}`];(t.degraded_decision_count||0)>0&&n.push(`degr${t.degraded_decision_count}`);let r=t.decisions?.find(e=>e.selected_relay_id||e.next_hop_id);return r?.selected_relay_id?n.push(`via ${B(r.selected_relay_id)}`):r?.next_hop_id&&n.push(`next ${B(r.next_hop_id)}`),n.join(` `)}function F(e,t){return e.find(e=>e.id===t)?.name||B(t)}function jt(e,t){let n=new Map(t.map(e=>[e.id,e])),r=[e.name],i=e.parent_group_id,a=new Set([e.id]);for(;i&&!a.has(i);){a.add(i);let e=n.get(i);if(!e)break;r.unshift(e.name),i=e.parent_group_id}return r.join(` / `)}function Mt(e,t){let n=t.find(t=>t.id===e);return n?jt(n,t):e}function Nt(e,t){let n=[],r=new Map;for(let e of t){let t=e.parent_group_id||``;r.set(t,[...r.get(t)||[],e])}let i=e=>{for(let t of r.get(e)||[])n.push(t.id),i(t.id)};return i(e),n}function Pt(e,t,n,r,i){let a=[],o=new Map,s=[],c=[];for(let t of e){let e=t.memberships.find(e=>e.cluster.id===n);if(!e){c.push(t);continue}let r=e.node.node_group_id;if(!r){s.push(t);continue}o.set(r,[...o.get(r)||[],t])}let l=new Map;for(let e of t){let t=e.parent_group_id||``;l.set(t,[...l.get(t)||[],e])}for(let e of l.values())e.sort((e,t)=>e.sort_order-t.sort_order||e.name.localeCompare(t.name));let u=new Map,d=e=>{let t=u.get(e.id);if(t!=null)return t;let n=o.get(e.id)?.length||0;for(let t of l.get(e.id)||[])n+=d(t);return u.set(e.id,n),n},f=(e,t)=>{let n=[...o.get(e.id)||[]].sort((e,t)=>e.node.name.localeCompare(t.node.name)),r=l.get(e.id)||[],s=`group-${e.id}`,c=d(e);if(a.push({kind:`group`,key:s,label:e.name,depth:t,count:c,groupId:e.id}),!i.has(s)){for(let r of n)a.push({kind:`node`,key:`node-${e.id}-${r.node.id}`,entry:r,depth:t+1});for(let e of r)f(e,t+1)}return c};for(let e of l.get(``)||[])f(e,0);if(s.length>0){let e=`group-ungrouped`;if(a.push({kind:`group`,key:e,label:r.ungroupedNodes,depth:0,count:s.length}),!i.has(e))for(let e of s.sort((e,t)=>e.node.name.localeCompare(t.node.name)))a.push({kind:`node`,key:`node-ungrouped-${e.node.id}`,entry:e,depth:1})}if(c.length>0){let e=`group-outside-active-cluster`;if(a.push({kind:`group`,key:e,label:r.notMemberOfActiveCluster,depth:0,count:c.length}),!i.has(e))for(let e of c.sort((e,t)=>e.node.name.localeCompare(t.node.name)))a.push({kind:`node`,key:`node-outside-${e.node.id}`,entry:e,depth:1})}return a}function Ft(e,t){return Object.entries(t).filter(([,t])=>t.some(t=>t.role===e&&t.status===`active`)).map(([e])=>e)}function It(e){let t={roles:e.roles,node_name:e.nodeName.trim()||null,node_group_id:e.nodeGroupId||null,ownership_type:e.ownershipType,purpose:e.purpose.trim()||null,approval:{mode:`manual`,auto_approve:!1,role_assignment:`manual_after_approval`},source:`platform_owner_console`};if(e.installMode===`docker`){let n=(e.controlPlaneEndpoint||Bt()).trim().replace(/\/$/,``);t.install_profile=`docker`,t.backend_url=n,t.control_plane_endpoints=[n],t.image=e.dockerImage||`rap-node-agent:latest`,e.dockerContainerName.trim()&&(t.container_name=e.dockerContainerName.trim()),t.artifact_endpoints=Ht(e.artifactEndpoints||Vt()),e.dockerImageArtifactSHA256.trim()&&(t.docker_image_artifact_sha256=e.dockerImageArtifactSHA256.trim()),t.network=e.dockerNetwork||`host`,t.restart_policy=`unless-stopped`,t.pull_image=!!e.pullImage,t.replace=e.replace!==!1,t.mesh_synthetic_runtime_enabled=e.syntheticRuntime!==!1,t.mesh_production_forwarding_enabled=!1,t.mesh_listen_addr=e.meshListenAddr||`:19131`,t.mesh_listen_port_mode=e.meshListenPortMode||`auto`,t.mesh_listen_auto_port_start=e.meshListenAutoPortStart||19131,t.mesh_listen_auto_port_end=e.meshListenAutoPortEnd||19231,e.meshAdvertiseEndpoint?.trim()&&(t.mesh_advertise_endpoint=e.meshAdvertiseEndpoint.trim().replace(/\/$/,``)),t.mesh_advertise_transport=e.meshAdvertiseTransport||`direct_http`,t.mesh_connectivity_mode=e.meshConnectivityMode||`private_lan`,t.mesh_nat_type=e.meshNATType||`unknown`,t.mesh_region=e.meshRegion||null}if(e.installMode===`windows_service`){let n=(e.controlPlaneEndpoint||Bt()).trim().replace(/\/$/,``);t.install_profile=`windows_service`,t.backend_url=n,t.control_plane_endpoints=[n],t.artifact_endpoints=Ht(e.artifactEndpoints||Vt()),t.startup_mode=e.windowsStartupMode||`auto`,e.windowsInstallDir.trim()&&(t.install_dir=e.windowsInstallDir.trim()),e.windowsNodeAgentSHA256.trim()&&(t.node_agent_artifact_sha256=e.windowsNodeAgentSHA256.trim()),t.mesh_synthetic_runtime_enabled=e.syntheticRuntime!==!1,t.mesh_production_forwarding_enabled=!1,t.mesh_listen_addr=e.meshListenAddr||`:19131`,t.mesh_listen_port_mode=e.meshListenPortMode||`auto`,t.mesh_listen_auto_port_start=e.meshListenAutoPortStart||19131,t.mesh_listen_auto_port_end=e.meshListenAutoPortEnd||19231,e.meshAdvertiseEndpoint?.trim()&&(t.mesh_advertise_endpoint=e.meshAdvertiseEndpoint.trim().replace(/\/$/,``)),t.mesh_advertise_transport=e.meshAdvertiseTransport||`direct_http`,t.mesh_connectivity_mode=e.meshConnectivityMode||`outbound_only`,t.mesh_nat_type=e.meshNATType||`unknown`,t.mesh_region=e.meshRegion||`windows`}if(e.installMode===`linux_binary`){let n=(e.controlPlaneEndpoint||Bt()).trim().replace(/\/$/,``);t.install_profile=`linux_binary`,t.backend_url=n,t.control_plane_endpoints=[n],t.artifact_endpoints=Ht(e.artifactEndpoints||Vt()),t.startup_mode=`systemd`,e.linuxInstallDir.trim()&&(t.install_dir=e.linuxInstallDir.trim()),e.linuxNodeAgentSHA256.trim()&&(t.node_agent_artifact_sha256=e.linuxNodeAgentSHA256.trim()),t.replace=e.replace!==!1,t.mesh_synthetic_runtime_enabled=e.syntheticRuntime!==!1,t.mesh_production_forwarding_enabled=!1,t.mesh_listen_addr=e.meshListenAddr||`:19131`,t.mesh_listen_port_mode=e.meshListenPortMode||`auto`,t.mesh_listen_auto_port_start=e.meshListenAutoPortStart||19131,t.mesh_listen_auto_port_end=e.meshListenAutoPortEnd||19231,e.meshAdvertiseEndpoint?.trim()&&(t.mesh_advertise_endpoint=e.meshAdvertiseEndpoint.trim().replace(/\/$/,``)),t.mesh_advertise_transport=e.meshAdvertiseTransport||`direct_http`,t.mesh_connectivity_mode=e.meshConnectivityMode||`outbound_only`,t.mesh_nat_type=e.meshNATType||`unknown`,t.mesh_region=e.meshRegion||`linux`}return t}function Lt(e,t){let n=Rt(e.roles),r=Rt(e.artifact_endpoints).join(`, `);return{...t,roles:n.length>0?n:t.roles,nodeName:P(e,`node_name`,``)||t.nodeName,nodeGroupId:P(e,`node_group_id`,``)||t.nodeGroupId,ownershipType:P(e,`ownership_type`,t.ownershipType),purpose:P(e,`purpose`,``)||t.purpose,installMode:P(e,`install_profile`,t.installMode),dockerImage:P(e,`image`,t.dockerImage),dockerContainerName:P(e,`container_name`,``)||t.dockerContainerName,dockerNetwork:P(e,`network`,t.dockerNetwork),windowsStartupMode:P(e,`startup_mode`,t.windowsStartupMode),windowsInstallDir:P(e,`install_dir`,``)||t.windowsInstallDir,windowsNodeAgentSHA256:P(e,`node_agent_artifact_sha256`,``)||t.windowsNodeAgentSHA256,linuxInstallDir:P(e,`install_dir`,``)||t.linuxInstallDir,linuxNodeAgentSHA256:P(e,`node_agent_artifact_sha256`,``)||t.linuxNodeAgentSHA256,meshListenAddr:P(e,`mesh_listen_addr`,t.meshListenAddr),meshListenPortMode:P(e,`mesh_listen_port_mode`,t.meshListenPortMode),meshListenAutoPortStart:Tt(e,`mesh_listen_auto_port_start`,t.meshListenAutoPortStart),meshListenAutoPortEnd:Tt(e,`mesh_listen_auto_port_end`,t.meshListenAutoPortEnd),meshAdvertiseEndpoint:P(e,`mesh_advertise_endpoint`,``)||t.meshAdvertiseEndpoint,meshAdvertiseTransport:P(e,`mesh_advertise_transport`,t.meshAdvertiseTransport),meshConnectivityMode:P(e,`mesh_connectivity_mode`,t.meshConnectivityMode),meshNATType:P(e,`mesh_nat_type`,t.meshNATType),meshRegion:P(e,`mesh_region`,``)||t.meshRegion,controlPlaneEndpoint:Rt(e.control_plane_endpoints)[0]||P(e,`backend_url`,``)||t.controlPlaneEndpoint,artifactEndpoints:r||t.artifactEndpoints,dockerImageArtifactSHA256:P(e,`docker_image_artifact_sha256`,``)||t.dockerImageArtifactSHA256,pullImage:zt(e,`pull_image`,t.pullImage),replace:zt(e,`replace`,t.replace),syntheticRuntime:zt(e,`mesh_synthetic_runtime_enabled`,t.syntheticRuntime)}}function Rt(e){return Array.isArray(e)?e.filter(e=>typeof e==`string`).map(e=>e.trim()).filter(Boolean):[]}function zt(e,t,n){let r=e[t];return typeof r==`boolean`?r:n}function Bt(){return typeof window>`u`||!window.location?.origin?`http://:18080/api/v1`:`${window.location.origin.replace(/\/$/,``)}/api/v1`}function Vt(){return typeof window>`u`||!window.location?.origin?`http://:18080/downloads`:`${window.location.origin.replace(/\/$/,``)}/downloads`}function Ht(e){return e.split(`,`).map(e=>e.trim().replace(/\/$/,``)).filter(Boolean)}function I(e){return Ht(e.artifactEndpoints||Vt()).map(e=>`${e}/rap-node-agent-dev-enrollment-bootstrap-smoke.tar`)}function Ut(e){return e.meshConnectivityMode===`outbound_only`?`outbound_only`:e.meshConnectivityMode===`private_lan`?`private_lan`:e.meshNATType!==`none`&&e.meshAdvertiseEndpoint.trim()?`nat_forward`:`direct`}function Wt(e,t){let n={...e};return t===`private_lan`?(n.meshConnectivityMode=`private_lan`,n.meshNATType=`none`):t===`direct`?(n.meshConnectivityMode=`direct`,n.meshNATType=`none`):t===`nat_forward`?(n.meshConnectivityMode=`direct`,n.meshNATType=`port_restricted`):(n.meshConnectivityMode=`outbound_only`,n.meshNATType=`symmetric`,n.meshAdvertiseEndpoint=``),n}function Gt(e,t){return e.nodeName.trim()?e.nodeName.trim():`${pn(t?.slug||t?.name||`rap-node`)}-node-1`}function Kt(e,t){return e.dockerContainerName.trim()?e.dockerContainerName.trim():`rap-node-agent-${pn(Gt(e,t))}`}function qt(e,t,n=le){let r=t?.id||e.cluster_id,i=Gt(n,t),a=Kt(n,t),o=pn(i),s=[`rap-host-agent install`,`--backend-url ${R(ln(n))}`,`--cluster-id ${R(r)}`,`--join-token ${R(e.token)}`,`--node-name ${R(i)}`,`--image ${R(n.dockerImage||`rap-node-agent:latest`)}`,`--container-name ${R(a)}`,`--state-dir ${R(`/var/lib/rap/nodes/${o}`)}`,`--network host`,`--replace`];for(let e of I(n))s.push(`--image-artifact-url ${R(e)}`);return n.dockerImageArtifactSHA256.trim()&&s.push(`--image-artifact-sha256 ${R(n.dockerImageArtifactSHA256.trim())}`),s.join(` \\ - `)}function Jt(e,t,n=le){let r=t?.id||e.cluster_id,i=Gt(n,t),a=[`sudo "$rap_host_agent" install`,`--profile-url ${R(ln(n))}`,`--cluster-id ${R(r)}`,`--install-token ${R(e.token)}`,`--node-name ${R(i)}`].join(` \\ - `);return[`rap_host_agent="$(mktemp /tmp/rap-host-agent.XXXXXX)"`,`curl -fL --retry 3 --retry-delay 1 ${R(L(n))} -o "$rap_host_agent"`,`chmod +x "$rap_host_agent"`,a].join(` && \\ - `)}function Yt(e,t,n=le){let r=t?.id||e.cluster_id,i=Gt(n,t),a=[`sudo "$rap_host_agent" install-linux`,`--profile-url ${R(ln(n))}`,`--cluster-id ${R(r)}`,`--install-token ${R(e.token)}`,`--node-name ${R(i)}`].join(` \\ - `);return[`rap_host_agent="$(mktemp /tmp/rap-host-agent.XXXXXX)"`,`curl -fL --retry 3 --retry-delay 1 ${R(L(n))} -o "$rap_host_agent"`,`chmod +x "$rap_host_agent"`,a].join(` && \\ - `)}function Xt(e,t,n=le){let r=t?.id||e.cluster_id,i=Gt(n,t),a=ln(n);return[`$rapHostAgent = Join-Path $env:TEMP "rap-host-agent.exe"`,`Invoke-WebRequest -UseBasicParsing ${mn(fn(n))} -OutFile $rapHostAgent`,`& $rapHostAgent install-windows --profile-url ${mn(a)} --cluster-id ${mn(r)} --install-token ${mn(e.token)} --node-name ${mn(i)} --startup-mode ${mn(n.windowsStartupMode||`auto`)}`].join(`\r -`)}function Zt(e,t,n=le){let r=t?.id||e.cluster_id,i=Gt(n,t),a=ln(n),o=fn(n),s=n.windowsStartupMode||`auto`;return[`powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -UseBasicParsing '${o}' -OutFile $env:TEMP\\rap-host-agent.exe"`,`%TEMP%\\rap-host-agent.exe install-windows --profile-url "${a}" --cluster-id "${r}" --install-token "${e.token}" --node-name "${i}" --startup-mode "${s}"`].join(`\r -`)}function Qt(e,t){let n=un(),r=n.replace(/\/api\/v1$/i,``).replace(/\/api$/i,``).replace(/\/$/,``),i=e.name||e.node_key||e.id,a=on(i),o=`%ProgramFiles%\\RAP\\${a}`,s=`%ProgramData%\\RAP\\nodes\\${a}`,c=`RAP Node Agent ${a}`,l=`RAP Host Agent Updater ${a}`,u=`${o}\\rap-host-agent.exe`,d=`${u}.next`;return[`@echo off`,`echo === RAP Windows updater repair: ${hn(i)} ===`,`echo Node ID: ${e.id}`,`echo Control Plane: ${n}`,`echo.`,`echo === Before repair: scheduled tasks ===`,`schtasks /Query /TN "${c}" /V /FO LIST`,`schtasks /Query /TN "${l}" /V /FO LIST`,`echo.`,`echo === Before repair: binaries ===`,`dir "${o}\\rap-*.exe*"`,`echo.`,`powershell -NoProfile -ExecutionPolicy Bypass -Command "Invoke-WebRequest -UseBasicParsing '${r}/downloads/rap-host-agent-windows-amd64.exe' -OutFile $env:TEMP\\rap-host-agent.exe"`,`%TEMP%\\rap-host-agent.exe install-windows --backend-url "${n}" --cluster-id "${t||``}" --node-id "${e.id}" --node-name "${hn(i)}" --replace --startup-mode "auto" --auto-update-current-version "0.0.0" --auto-update-initial-delay-seconds 1`,`"${u}" update-loop --backend-url "${n}" --cluster-id "${t||``}" --node-id "${e.id}" --state-dir "${s}" --current-version "0.0.0" --os windows --arch amd64 --install-type windows_service --binary-path "${o}\\rap-node-agent.exe" --windows-task-name "${c}" --health-timeout-seconds 30 --interval-seconds 0 --initial-delay-seconds 0 --max-runs 1 --host-agent-update-status-enabled --host-agent-current-version "0.0.0" --host-agent-binary-path "${u}"`,`echo.`,`echo === Applying staged host-agent if present ===`,`if exist "${d}" copy /Y "${d}" "${u}"`,`if exist "${d}" del /F /Q "${d}"`,`schtasks /End /TN "${l}"`,`schtasks /Run /TN "${l}"`,`echo.`,`echo === After repair: binaries ===`,`dir "${o}\\rap-*.exe*"`,`echo.`,`echo === After repair: updater task ===`,`schtasks /Query /TN "${l}" /V /FO LIST`,`echo.`,`echo Repair command finished. Check the admin panel for rap-node-agent and rap-host-agent plan/noop reports.`].join(`\r -`)}function $t(e){return`rap-repair-updater-${an(e.name||e.node_key||e.id||`node`)}.cmd`}function en(e,t){let n=un(),r=n.replace(/\/api\/v1$/i,``).replace(/\/api$/i,``).replace(/\/$/,``),i=e.name||e.node_key||e.id,a=sn(i),o=`/opt/rap/${a}`,s=`/var/lib/rap/nodes/${a}`,c=`rap-node-agent-${a}.service`,l=`rap-host-agent-updater-${a}.service`,u=`${o}/rap-host-agent`;return[`#!/usr/bin/env bash`,`set -euo pipefail`,`echo "=== RAP Linux updater repair: ${cn(i)} ==="`,`echo "Node ID: ${e.id}"`,`echo "Control Plane: ${n}"`,`echo`,`echo "=== Before repair: systemd units ==="`,`systemctl status ${R(c)} --no-pager || true`,`systemctl status ${R(l)} --no-pager || true`,`echo`,`echo "=== Before repair: binaries ==="`,`ls -la ${R(o)} || true`,`echo`,`rap_host_agent="$(mktemp /tmp/rap-host-agent.XXXXXX)"`,`curl -fL --retry 3 --retry-delay 1 ${R(`${r}/downloads/rap-host-agent-linux-amd64`)} -o "$rap_host_agent"`,`chmod +x "$rap_host_agent"`,`sudo "$rap_host_agent" install-linux --backend-url ${R(n)} --cluster-id ${R(t||``)} --node-id ${R(e.id)} --node-name ${R(i)} --replace --startup-mode systemd --auto-update-current-version 0.0.0 --auto-update-initial-delay-seconds 1`,`sudo ${R(u)} update-loop --backend-url ${R(n)} --cluster-id ${R(t||``)} --node-id ${R(e.id)} --state-dir ${R(s)} --current-version 0.0.0 --os linux --arch amd64 --install-type linux_binary --binary-path ${R(`${o}/rap-node-agent`)} --systemd-unit ${R(c)} --health-timeout-seconds 30 --interval-seconds 0 --initial-delay-seconds 0 --max-runs 1 --host-agent-update-status-enabled --host-agent-current-version 0.0.0 --host-agent-binary-path ${R(u)}`,`sudo systemctl daemon-reload`,`sudo systemctl restart ${R(l)}`,`echo`,`echo "=== After repair: systemd updater ==="`,`systemctl status ${R(l)} --no-pager || true`,`echo "Repair command finished. Check the admin panel for rap-node-agent and rap-host-agent plan/noop reports."`].join(` -`)}function tn(e){return`rap-repair-updater-${an(e.name||e.node_key||e.id||`node`)}.sh`}function nn(e,t){if(typeof document>`u`)return;let n=new Blob([t.endsWith(`\r -`)?t:`${t}\r\n`],{type:`text/plain;charset=utf-8`}),r=URL.createObjectURL(n),i=document.createElement(`a`);i.href=r,i.download=e,document.body.appendChild(i),i.click(),i.remove(),URL.revokeObjectURL(r)}async function rn(e){await navigator.clipboard.writeText(e)}function an(e){return e.trim().replace(/[\\/:*?"<>|]+/g,`-`).replace(/\s+/g,`-`).replace(/-+/g,`-`).replace(/^-|-$/g,``).slice(0,80)||`node`}function on(e){return e.trim().replace(/[\\/:*?"<>|]+/g,`-`).replace(/\s+/g,`-`).replace(/-+/g,`-`).replace(/^-|-$/g,``)||`node`}function sn(e){return on(e).slice(0,48)||`node`}function cn(e){return e.replace(/\\/g,`\\\\`).replace(/"/g,`\\"`).replace(/\$/g,`\\$`).replace(/`/g,"\\`")}function ln(e=le){return(e.controlPlaneEndpoint||Bt()).trim().replace(/\/$/,``)}function un(){let e=typeof window>`u`?``:window.location?.origin||``;return/^(http:\/\/)?(192\.168\.200\.61|docker-test\.cin\.su)(:18080)?$/i.test(e.replace(/\/$/,``))?`https://vpn.cin.su/api/v1`:`${e.replace(/\/$/,``)}/api/v1`}function dn(e=le){let t=Ht(e.artifactEndpoints)[0];return t?t.replace(/\/downloads$/i,``).replace(/\/$/,``):ln(e).replace(/\/api\/v1$/i,``).replace(/\/api$/i,``).replace(/\/$/,``)}function L(e=le){return`${typeof window>`u`&&!e.controlPlaneEndpoint?`http://:18080`:dn(e)}/downloads/rap-host-agent-linux-amd64`}function fn(e=le){return`${typeof window>`u`&&!e.controlPlaneEndpoint?`http://:18080`:dn(e)}/downloads/rap-host-agent-windows-amd64.exe`}function pn(e){return e.trim().toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,42)||`rap-node`}function R(e){return`'${e.replace(/'/g,`'\\''`)}'`}function mn(e){return`'${e.replace(/'/g,`''`)}'`}function hn(e){return e.replace(/"/g,`""`)}function gn(e,t){return e.includes(t)?e.filter(e=>e!==t):[...e,t]}function _n(e,t,n,r,i){let a=n.trim().toLowerCase(),o=new Map;for(let n of e){if(a&&!vn(n,a))continue;let e=z(n,t,r,i);o.set(e,[...o.get(e)||[],n])}return Array.from(o.entries()).map(([e,t])=>({label:e,items:t.sort((e,t)=>e.node.name.localeCompare(t.node.name))})).sort((e,t)=>e.label.localeCompare(t.label))}function vn(e,t){return[e.node.name,e.node.node_key,e.node.health_status,e.node.ownership_type,e.node.reported_version||``,...e.memberships.flatMap(e=>[e.cluster.name,e.cluster.slug,e.node.membership_status])].some(e=>e.toLowerCase().includes(t))}function z(e,t,n,r){if(n===`health`)return H(e.node.health_status);if(n===`ownership`)return H(e.node.ownership_type);if(n===`cluster_count`)return Mn(e.memberships.length,r);let i=e.memberships.find(e=>e.cluster.id===t);return i?i.node.membership_status===`active`?r===`en`?`In active cluster`:`В активном кластере`:`${r===`en`?`Membership`:`Участие`}: ${H(i.node.membership_status)}`:r===`en`?`Not in active cluster`:`Не в активном кластере`}function yn(e,t){let n=ae[e]||[];if(n.length===0||!t)return`unknown`;if(bn(t))return`stale`;let r=t.capabilities||{};return n.some(e=>!!r[e])?`confirmed`:`missing`}function bn(e){if(!e?.observed_at)return!0;let t=new Date(e.observed_at).getTime();return!Number.isFinite(t)||Date.now()-t>60*1e3}function xn(e,t){let n=yn(e,t);return n===`confirmed`?`good`:n===`missing`?`bad`:n===`stale`?`warn`:``}function Sn(e,t,n){let r=yn(e,t);return r===`confirmed`?n.capabilityConfirmed:r===`missing`?n.capabilityMissing:r===`stale`?`heartbeat устарел`:n.capabilityUnknown}function Cn(e,t,n){let r=yn(e,t);return n===`en`?r===`confirmed`?`capable`:r===`missing`?`not reported`:r===`stale`?`stale heartbeat`:`unknown`:r===`confirmed`?`подходит`:r===`missing`?`не заявлено`:r===`stale`?`heartbeat устарел`:`неизвестно`}function wn(e){let t=e?.metadata?.mesh_peer_recovery_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t,r=typeof n.mode==`string`?n.mode:`unknown`,i=typeof n.ready_peer_count==`number`?n.ready_peer_count:null,a=typeof n.target_ready_peers==`number`?n.target_ready_peers:null,o=typeof n.deficit==`number`?n.deficit:0,s=i==null||a==null?r:`${r} ${i}/${a}`;return o>0?`${s} deficit ${o}`:s}function Tn(e){let t=e?.metadata?.mesh_peer_connection_intent_report;if(!t||typeof t!=`object`||Array.isArray(t))return An(e);let n=t,r=typeof n.intent_count==`number`?n.intent_count:0,i=typeof n.maintain_count==`number`?n.maintain_count:0,a=typeof n.recover_count==`number`?n.recover_count:0,o=typeof n.rendezvous_required_count==`number`?n.rendezvous_required_count:0,s=typeof n.rendezvous_resolved_count==`number`?n.rendezvous_resolved_count:0,c=typeof n.relay_control_count==`number`?n.relay_control_count:0,l=[`rv${o}`];s>0&&l.push(`ok${s}`),c>0&&l.push(`relay${c}`);let u=o>0||s>0||c>0?`${r} intents m${i}/r${a} ${l.join(`/`)}`:`${r} intents m${i}/r${a}`,d=An(e);return d===`н/д`?u:`${u}; ${d}`}function En(e){let t=e?.metadata?.mesh_rendezvous_lease_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t,r=typeof n.lease_count==`number`?n.lease_count:0,i=typeof n.active_count==`number`?n.active_count:0,a=typeof n.admitted_as_relay_count==`number`?n.admitted_as_relay_count:0,o=typeof n.admitted_as_peer_count==`number`?n.admitted_as_peer_count:0,s=typeof n.renewal_needed_count==`number`?n.renewal_needed_count:0,c=typeof n.relay_control_ready_count==`number`?n.relay_control_ready_count:0,l=typeof n.stale_relay_count==`number`?n.stale_relay_count:0,u=typeof n.refresh_attempt_count==`number`?n.refresh_attempt_count:0,d=typeof n.refresh_success_count==`number`?n.refresh_success_count:0,f=[`lease ${i}/${r}`];return a>0&&f.push(`relay${a}`),o>0&&f.push(`peer${o}`),s>0&&f.push(`renew${s}`),l>0&&f.push(`stale${l}`),c>0&&f.push(`ready${c}`),u>0&&f.push(`ref${d}/${u}`),f.join(` `)}function Dn(e){let t=e?.metadata?.mesh_route_path_decision_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t,r=typeof n.decision_count==`number`?n.decision_count:0,i=typeof n.replacement_decision_count==`number`?n.replacement_decision_count:0,a=typeof n.degraded_decision_count==`number`?n.degraded_decision_count:0,o=typeof n.recovery_hysteresis_count==`number`?n.recovery_hysteresis_count:0,s=typeof n.recovery_promoted_count==`number`?n.recovery_promoted_count:0,c=typeof n.recovery_demoted_count==`number`?n.recovery_demoted_count:0,l=typeof n.local_effective_path_count==`number`?n.local_effective_path_count:0,u=typeof n.next_hop_available_count==`number`?n.next_hop_available_count:0,d=typeof n.withdrawn_local_relay_count==`number`?n.withdrawn_local_relay_count:0,f=[`path ${l}/${r}`];return i>0&&f.push(`repl${i}`),a>0&&f.push(`degr${a}`),o>0&&f.push(`rec${o}`),s>0&&f.push(`prom${s}`),c>0&&f.push(`dem${c}`),u>0&&f.push(`next${u}`),d>0&&f.push(`wd${d}`),f.join(` `)}function On(e){let t=e?.metadata?.mesh_route_generation_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t,r=typeof n.active_decision_count==`number`?n.active_decision_count:0,i=typeof n.applied_decision_count==`number`?n.applied_decision_count:0,a=typeof n.withdrawn_decision_count==`number`?n.withdrawn_decision_count:0,o=n.generation_changed===!0,s=[`gen ${r}`];return i>0&&s.push(`ap${i}`),a>0&&s.push(`wd${a}`),o&&s.push(`chg`),s.join(` `)}function kn(e){let t=e?.metadata?.mesh_route_health_config_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t,r=e?.metadata?.mesh_route_health_feedback_refresh_report,i=r&&typeof r==`object`&&!Array.isArray(r)?r:{},a=typeof n.route_health_route_count==`number`?n.route_health_route_count:0,o=typeof n.route_path_decision_applied_count==`number`?n.route_path_decision_applied_count:0,s=typeof n.replacement_route_health_route_count==`number`?n.replacement_route_health_route_count:0,c=typeof n.route_health_decision_drift_candidate_count==`number`?n.route_health_decision_drift_candidate_count:0,l=typeof i.feedback_refresh_attempt_count==`number`?i.feedback_refresh_attempt_count:typeof n.feedback_refresh_attempt_count==`number`?n.feedback_refresh_attempt_count:0,u=typeof i.feedback_refresh_success_count==`number`?i.feedback_refresh_success_count:typeof n.feedback_refresh_success_count==`number`?n.feedback_refresh_success_count:0,d=typeof i.feedback_refresh_suppressed_count==`number`?i.feedback_refresh_suppressed_count:typeof n.feedback_refresh_suppressed_count==`number`?n.feedback_refresh_suppressed_count:0,f=[`rh ${o}/${a}`];return s>0&&f.push(`repl${s}`),c>0&&f.push(`drift${c}`),(l>0||d>0)&&f.push(`fb${u}/${l}`),d>0&&f.push(`sup${d}`),f.join(` `)}function An(e){let t=e?.metadata?.mesh_peer_connection_manager_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t;if(n.enabled===!1)return`manager off`;let r=typeof n.attempted==`number`?n.attempted:0,i=typeof n.succeeded==`number`?n.succeeded:0,a=typeof n.deferred==`number`?n.deferred:0,o=typeof n.relay_control_count==`number`?n.relay_control_count:0,s=o>0?`mgr ${i}/${r} relay${o}`:`mgr ${i}/${r}`;return a>0?`${s} def${a}`:s}function jn(e){let t=e?.metadata?.mesh_listener_report;if(!t||typeof t!=`object`||Array.isArray(t))return`н/д`;let n=t,r=typeof n.status==`string`?n.status:`unknown`,i=typeof n.listen_port_mode==`string`?n.listen_port_mode:`manual`,a=typeof n.effective_listen_addr==`string`?n.effective_listen_addr:``,o=typeof n.failure_reason==`string`?n.failure_reason:``;return r===`listening`?a?`listen ${a}`:`listen`:r===`auto_rebound`?a?`auto ${a}`:`auto rebound`:r===`listen_failed`?o?`${i} failed: ${o}`:`${i} failed`:r===`disabled`?i===`disabled`?`inbound off`:`inbound unavailable`:r}function Mn(e,t){if(t===`en`)return e===1?`1 cluster`:`${e} clusters`;let n=e%10,r=e%100;return n===1&&r!==11?`${e} кластер`:n>=2&&n<=4&&(r<12||r>14)?`${e} кластера`:`${e} кластеров`}function Nn(e,t=e.link_status===`reachable`?`reachable`:`unknown`){return t===`stale`?`stale`:t===`one_way`?`oneWay`:t!==`reachable`||e.link_status!==`reachable`?`bad`:e.quality_score!=null&&e.quality_score<70||e.latency_ms!=null&&e.latency_ms>80?`weak`:`good`}function Pn(e,t=16){return e.length>t?`${e.slice(0,Math.max(1,t-2))}…`:e}function Fn(e){return window.confirm(`${e}?\n\nЭто высокорисковая операция владельца платформы. Действие будет записано в аудит.`)}function In(e){let t=(e||``).replace(/\/$/,``);return!t||t===`/api/v1`?window.location.origin:t.endsWith(`/api/v1`)?t.slice(0,-7):t}function B(e){return e?e.length>12?`${e.slice(0,8)}...${e.slice(-4)}`:e:`нет`}function V(e){return e?new Intl.DateTimeFormat(void 0,{dateStyle:`medium`,timeStyle:`short`}).format(new Date(e)):`никогда`}function Ln(e){return e==null||Number.isNaN(e)?`age n/a`:e<60?`${Math.max(0,Math.round(e))}s ago`:e<3600?`${Math.round(e/60)}m ago`:e<86400?`${Math.round(e/3600)}h ago`:`${Math.round(e/86400)}d ago`}function Rn(e){return e?new Intl.DateTimeFormat(void 0,{hour:`2-digit`,minute:`2-digit`,second:`2-digit`}).format(new Date(e)):`н/д`}function zn(e){if(e==null||Number.isNaN(e))return`н/д`;let t=[`B`,`KB`,`MB`,`GB`,`TB`],n=e,r=0;for(;n>=1024&&r(e[t]||0)>0).map(t=>`${t[0]}:${e[t]}`).join(` `)||`qos none`}function Vn(e){return!e||Object.keys(e).length===0?`n/a`:[`control`,`interactive`,`reliable`,`bulk`,`droppable`].filter(t=>(e[t]||0)>0).map(t=>`${t[0]}:${e[t]}`).join(` `)||`n/a`}function Hn(e,t){return(t||0)>0||e===`critical`?`bad`:e===`degraded`?`warn`:e===`watch`?`info`:`good`}function Un(e){switch(e){case`applied`:case`rebuild_request_applied`:return`good`;case`waiting_node_apply`:case`pending_rebuild_request`:case`pending_degraded_fallback`:case`rebuild_request_recorded`:case`rebuild_request_recorded_node_pending`:case`rebuild_request_no_alternate`:case`rebuild_request_deferred_by_policy`:case`route_rebuild_no_safe_recovery`:return`warn`;case`expired`:case`rejected_by_policy_guard`:case`rebuild_request_rejected`:case`rebuild_request_expired`:return`bad`;default:return e?`bad`:``}}function Wn(e,t,n){return e===`service_channel_feedback_no_alternate`||t===`pending_degraded_fallback`||(n||[]).includes(`no_unfenced_alternate_route`)?`warn`:t===`applied`||(n||[]).includes(`service_channel_rebuild_applied`)?`good`:e?.includes(`replacement`)||e||t?`info`:``}function H(e){return{active:`активно`,approved:`одобрено`,authoritative:`authoritative`,connecting:`подключается`,connected:`связан`,critical:`критично`,current:`актуальна`,degraded:`degraded`,disabled:`выключено`,enabled:`включено`,failed:`ошибка`,healthy:`здоров`,watch:`наблюдение`,flow_health_ready:`flow ready`,flow_drops_reported:`flow drops`,route_quality_window_drops_reported:`route drops`,backend_fallback_observed:`backend fallback`,route_quality_window_failures_reported:`route failures`,route_quality_window_slow_samples_reported:`slow samples`,route_send_latency_high:`high latency`,flow_queue_pressure_high:`queue pressure high`,bulk_pressure_with_interactive_qos_observed:`bulk+interactive`,bulk_pressure_observed:`bulk pressure`,flow_queue_pressure_observed:`queue pressure`,flow_health_degraded:`flow degraded`,bulk_window_reduced_to_protect_interactive:`bulk reduced`,rebuild_request_applied:`planner applied`,rebuild_request_recorded:`rebuild recorded`,rebuild_request_recorded_node_pending:`node pending`,rebuild_request_no_alternate:`no alternate`,rebuild_request_deferred_by_policy:`deferred by policy`,rebuild_request_rejected:`rebuild rejected`,rebuild_request_expired:`rebuild expired`,route_rebuild_no_safe_recovery:`no safe recovery`,access_decision:`access decision`,access_no_safe_recovery:`access no-safe`,access_recovery_selected:`access recovery`,access_rebuild_applied:`access applied`,access_replacement_selected:`access replacement`,inspect_access_no_safe_recovery_route_pool_and_signed_policy:`inspect no-safe route pool`,watch_recovery_route_quality_and_confirm_post_recovery_traffic:`watch recovery traffic`,confirm_applied_rebuild_runtime_traffic_stays_on_replacement:`confirm applied traffic`,watch_replacement_route_quality_until_applied_or_recovered:`watch replacement`,pending_degraded_fallback:`pending fallback`,service_channel_feedback_no_alternate:`no safe route`,service_channel_feedback_replacement:`replacement`,service_channel_feedback_exit_pool_replacement:`exit replacement`,service_channel_feedback_entry_pool_replacement:`entry replacement`,service_channel_feedback_entry_exit_pool_replacement:`pool replacement`,service_channel_remediation_command:`remediation`,service_channel_feedback_rebuild_requested:`rebuild requested`,remediation_rebuild_applied_to_alternate:`planner selected alternate`,no_unfenced_alternate_route:`no safe alternate`,active_lease_not_found_for_rebuild_resolution:`lease missing`,remediation_command_ttl_expired:`command expired`,durable_rebuild_route_request_recorded:`rebuild recorded`,durable_rebuild_route_request_rejected:`request rejected`,durable_rebuild_route_request_applied:`request applied`,durable_rebuild_route_no_alternate:`no alternate`,durable_rebuild_route_deferred_by_policy:`deferred by policy`,durable_rebuild_route_expired:`request expired`,isolated:`изолирован`,offline:`нет связи`,one_way:`односторонняя`,outdated:`обновить`,pending:`ожидает`,platform_managed:`платформенный`,promoted:`promoted`,rejected:`отклонено`,ready:`готово`,revoked:`отозвано`,running:`работает`,customer_managed:`клиентский`,no_policy:`нет политики`,not_configured:`не задано`,missing:`нет отчета`,service_channel_recovery_demoted:`demoted`,service_channel_recovery_demoted_degraded:`degraded`,service_channel_recovery_demoted_degraded_fallback:`fallback`,service_channel_recovery_demoted_failure:`failure`,service_channel_recovery_demoted_fenced:`fenced`,service_channel_recovery_demoted_rebuild:`rebuild`,service_channel_recovery_demoted_slow:`slow`,service_channel_feedback_provenance_missing:`provenance missing`,service_channel_feedback_stale:`stale feedback`,service_channel_feedback_stale_generation:`stale generation`,service_channel_feedback_stale_policy:`stale policy`,service_channel_feedback_stale_policy_and_generation:`stale policy+generation`,schema_ready:`schema ready`,schema_migration_required:`schema migration required`,snapshots_warmed:`snapshots warmed`,missing_snapshots_warmed_stale_deferred:`missing warmed, stale deferred`,snapshot_warmup_partial:`warmup partial`,stopped:`остановлено`,stale:`устарело`,unknown:`неизвестно`}[e]||e}(0,v.createRoot)(document.getElementById(`root`)).render((0,S.jsx)(_.StrictMode,{children:(0,S.jsx)(de,{})})); \ No newline at end of file diff --git a/web-admin/deploy/html/index.html b/web-admin/deploy/html/index.html index 409e7a7..4f0eb28 100644 --- a/web-admin/deploy/html/index.html +++ b/web-admin/deploy/html/index.html @@ -4,7 +4,7 @@ Панель Secure Access Fabric - + diff --git a/web-admin/src/App.tsx b/web-admin/src/App.tsx index 0ab7c78..366df41 100644 --- a/web-admin/src/App.tsx +++ b/web-admin/src/App.tsx @@ -30,6 +30,7 @@ import type { MeshLink, MeshRouteIntent, NodeHeartbeat, + NodeBridgeReplayPlan, NodeJoinToken, NodeSyntheticMeshConfig, NodeTelemetryObservation, @@ -43,6 +44,7 @@ import type { ReleaseVersion, Resource, RoleAssignment, + StaleNodeRiskReport, UserAccount, VPNClientDiagnosticCommand, VPNClientDiagnosticStatus, @@ -266,6 +268,7 @@ type JoinTokenFormState = { meshListenAutoPortStart: number; meshListenAutoPortEnd: number; meshAdvertiseEndpoint: string; + meshAdvertiseEndpoints: string; meshAdvertiseTransport: string; meshConnectivityMode: string; meshNATType: string; @@ -295,12 +298,13 @@ const defaultJoinTokenForm: JoinTokenFormState = { windowsNodeAgentSHA256: "", linuxInstallDir: "", linuxNodeAgentSHA256: "", - meshListenAddr: ":19131", + meshListenAddr: "", meshListenPortMode: "auto", meshListenAutoPortStart: 19131, meshListenAutoPortEnd: 19231, meshAdvertiseEndpoint: "", - meshAdvertiseTransport: "direct_http", + meshAdvertiseEndpoints: "", + meshAdvertiseTransport: "direct_quic", meshConnectivityMode: "private_lan", meshNATType: "none", meshRegion: "docker-test", @@ -309,7 +313,7 @@ const defaultJoinTokenForm: JoinTokenFormState = { dockerImageArtifactSHA256: "", pullImage: false, replace: true, - syntheticRuntime: true, + syntheticRuntime: false, }; const copy = { @@ -349,7 +353,7 @@ const copy = { slugHelp: "Slug — постоянный короткий технический идентификатор кластера для URL, скриптов, логов и интеграций. Его лучше не менять после создания.", clusterCatalog: "Каталог кластеров", - clusterCatalogText: "Список реальных кластеров из Control Plane. Выберите активный кластер или раскройте карточку для подробностей.", + clusterCatalogText: "Список реальных кластеров из Control/API слоя. Выберите активный кластер или раскройте карточку для подробностей.", makeActive: "Сделать активным", openSettings: "Открыть настройки", selected: "Выбран", @@ -357,7 +361,7 @@ const copy = { clusterDetails: "Подробнее", consoleTitle: "Панель владельца платформы", boundary: - "WEB является только представлением. Решения кластера проходят через Control Plane API, PostgreSQL как source of truth и аудит.", + "WEB является только представлением. HTTP Control API управляет политикой, релизами и аудитом; межузловой transport Fabric остается QUIC/UDP.", noLoginError: "Войдите как владелец продукта или администратор платформы, чтобы загрузить панель.", accessDenied: "Доступ к этой панели запрещен.", sessionMode: "Режим сессии", @@ -368,7 +372,7 @@ const copy = { emptyLiveText: "Это реальные данные, не заглушка: в выбранном кластере ещё нет одобренных node-agent узлов. Создайте join token, запустите rap-node-agent и подтвердите join request.", realDataNote: - "Показываются только данные из PostgreSQL/Control Plane. Если значения нулевые, значит соответствующих узлов, ролей или сервисов пока нет.", + "Показываются только данные из PostgreSQL и Control/API слоя. Если значения нулевые, значит соответствующих узлов, ролей или сервисов пока нет.", signedInAs: "Вход выполнен", actorUser: "Actor user", testMode: "Тестирование", @@ -479,7 +483,7 @@ const copy = { fabricMap: "Карта трафика Fabric", fabricNodeLayer: "Узлы кластера", observedPeerLinks: "Наблюдаемые связи", - placementIntent: "control-plane назначение", + placementIntent: "управляющее назначение", endpointName: "Название", publicEndpoint: "Публичный адрес", endpointType: "Тип входа", @@ -504,7 +508,7 @@ const copy = { }, en: { productOwner: "Product Owner", - controlPlane: "Control Plane", + controlPlane: "Control API", sideText: "Full platform-owner panel for clusters, nodes, trust, placement, and safe service desired state.", signInTitle: "Sign in", signInText: "Enter your credentials.", @@ -538,14 +542,14 @@ const copy = { slugHelp: "Slug is a stable short technical identifier for URLs, scripts, logs, and integrations. It should generally not change after creation.", clusterCatalog: "Cluster catalog", - clusterCatalogText: "Real clusters from the Control Plane. Select the active cluster or expand a card for details.", + clusterCatalogText: "Real clusters from the Control/API layer. Select the active cluster or expand a card for details.", makeActive: "Make active", openSettings: "Open settings", selected: "Selected", createCluster: "Create cluster", clusterDetails: "Details", consoleTitle: "Platform Owner Console", - boundary: "WEB is presentation only. Cluster decisions go through Control Plane APIs, PostgreSQL source of truth, and audit.", + boundary: "WEB is presentation only. The HTTP Control API handles policy, releases, and audit; inter-node Fabric transport remains QUIC/UDP.", noLoginError: "Sign in as a product owner or platform administrator to load the panel.", accessDenied: "Access to this panel is denied.", sessionMode: "Session mode", @@ -555,7 +559,7 @@ const copy = { emptyLiveTitle: "Cluster has no live nodes yet", emptyLiveText: "These are real values, not placeholders: the selected cluster has no approved node-agent nodes yet. Create a join token, run rap-node-agent, and approve the join request.", - realDataNote: "Only PostgreSQL/Control Plane data is shown. Zero values mean the corresponding nodes, roles, or services do not exist yet.", + realDataNote: "Only PostgreSQL and Control/API data is shown. Zero values mean the corresponding nodes, roles, or services do not exist yet.", signedInAs: "Signed in", actorUser: "Actor user", testMode: "Testing", @@ -666,7 +670,7 @@ const copy = { fabricMap: "Fabric traffic map", fabricNodeLayer: "Cluster nodes", observedPeerLinks: "Observed links", - placementIntent: "control-plane placement", + placementIntent: "control/API placement", endpointName: "Name", publicEndpoint: "Public endpoint", endpointType: "Entry type", @@ -759,6 +763,8 @@ export function App() { const [joinRequests, setJoinRequests] = useState([]); const [joinTokens, setJoinTokens] = useState([]); const [releaseVersions, setReleaseVersions] = useState([]); + const [staleNodeRiskReport, setStaleNodeRiskReport] = useState(null); + const [bridgeReplayPlansByNode, setBridgeReplayPlansByNode] = useState>({}); const [nodeUpdatePlansByNode, setNodeUpdatePlansByNode] = useState>({}); const [nodeUpdateStatusesByNode, setNodeUpdateStatusesByNode] = useState>({}); const [rolesByNode, setRolesByNode] = useState>({}); @@ -799,6 +805,8 @@ export function App() { const [resources, setResources] = useState([]); const [membershipsByOrg, setMembershipsByOrg] = useState>({}); const [audit, setAudit] = useState([]); + const [auditFocusNodeId, setAuditFocusNodeId] = useState(""); + const [auditFocusNodeLabel, setAuditFocusNodeLabel] = useState(""); const [fabricDrilldownAudit, setFabricDrilldownAudit] = useState([]); const [fabricDrilldownAuditSummary, setFabricDrilldownAuditSummary] = useState(null); const [lastDataRefreshAt, setLastDataRefreshAt] = useState(""); @@ -842,7 +850,20 @@ export function App() { const [nodeRoleDrafts, setNodeRoleDrafts] = useState>({}); const [nodeWorkloadDrafts, setNodeWorkloadDrafts] = useState>({}); const [meshListenerDrafts, setMeshListenerDrafts] = useState< - Record + Record< + string, + { + listenAddr: string; + mode: string; + autoRange: string; + advertiseEndpoint: string; + endpointCandidates: string; + advertiseTransport: string; + connectivity: string; + nat: string; + region: string; + } + > >({}); const [nodeTestingDrafts, setNodeTestingDrafts] = useState>({}); const [testingOrgId, setTestingOrgId] = useState(""); @@ -874,6 +895,8 @@ export function App() { const [membershipForm, setMembershipForm] = useState({ organizationId: "", userId: "", roleId: "org_member" }); const [resourceSecretDialog, setResourceSecretDialog] = useState(null); const [resourceSecretForm, setResourceSecretForm] = useState({ username: "", password: "", domain: "" }); + const [legacyGuardSmokeResult, setLegacyGuardSmokeResult] = useState(""); + const [legacyGuardSmokeCheckedAt, setLegacyGuardSmokeCheckedAt] = useState(""); const [resourceForm, setResourceForm] = useState({ organizationId: "", name: "", @@ -966,6 +989,10 @@ export function App() { () => groupNodeInventory(allNodeInventory, selectedClusterId, allNodeSearch, allNodeGroupBy, language), [allNodeInventory, allNodeGroupBy, allNodeSearch, language, selectedClusterId], ); + const nodeInventoryByNodeId = useMemo( + () => Object.fromEntries(allNodeInventory.map((entry) => [entry.node.id, entry])), + [allNodeInventory], + ); const visibleNodeInventory = useMemo(() => { const normalizedSearch = allNodeSearch.trim().toLowerCase(); const allowedGroupIds = nodeGroupFilterId ? new Set([nodeGroupFilterId, ...descendantGroupIds(nodeGroupFilterId, nodeGroups)]) : null; @@ -1031,6 +1058,44 @@ export function App() { () => buildNodeInventoryTreeRows(visibleNodeInventory, nodeGroups, selectedClusterId, t, new Set(collapsedNodeGroupKeys)), [collapsedNodeGroupKeys, nodeGroups, selectedClusterId, t, visibleNodeInventory], ); + const legacyRemovalBlockedAuditEvents = useMemo( + () => + audit + .filter((event) => event.event_type === "legacy_compatibility_removal.blocked") + .slice(0, 4), + [audit], + ); + const visibleAuditEvents = useMemo( + () => + auditFocusNodeId + ? audit.filter((event) => auditEventMatchesNode(event, auditFocusNodeId)) + : audit, + [audit, auditFocusNodeId], + ); + const focusBlockerNode = useCallback( + (nodeId: string, mode: "details" | "manage") => { + const entry = nodeInventoryByNodeId[nodeId]; + if (!entry) { + return; + } + setActiveView("nodes"); + setNodeViewScope("all"); + setNodeGroupFilterId(""); + setAllNodeSearch(entry.node.name || entry.node.node_key); + setNodeInfoDialog(entry); + setNodeInfoMode(mode); + }, + [nodeInventoryByNodeId], + ); + const focusNodeAuditTrail = useCallback( + (nodeId: string) => { + const entry = nodeInventoryByNodeId[nodeId]; + setAuditFocusNodeId(nodeId); + setAuditFocusNodeLabel(entry?.node.name || entry?.node.node_key || nodeId); + setActiveView("audit"); + }, + [nodeInventoryByNodeId], + ); const fabricDrilldownAuditEvents = useMemo( () => fabricDrilldownAudit.slice(0, 8), [fabricDrilldownAudit], @@ -1324,6 +1389,7 @@ export function App() { loadedJoinRequests, loadedJoinTokens, loadedReleaseVersions, + loadedStaleNodeRiskReport, loadedAuthority, loadedAudit, loadedFabricDrilldownAuditResult, @@ -1351,6 +1417,7 @@ export function App() { client.listJoinRequests(clusterId), client.listJoinTokens(clusterId), client.listReleaseVersions(clusterId, "rap-node-agent", "dev"), + client.getStaleNodeRiskReport(clusterId), client.getClusterAuthority(clusterId), client.listAudit(clusterId), client.getFabricServiceChannelRebuildInvestigationBreadcrumbs(clusterId, { limit: 20 }), @@ -1380,6 +1447,7 @@ export function App() { setJoinRequests(loadedJoinRequests); setJoinTokens(loadedJoinTokens); setReleaseVersions(loadedReleaseVersions); + setStaleNodeRiskReport(loadedStaleNodeRiskReport); setAuthority(loadedAuthority); if (!options.preserveEditableForms) { setAuthorityForm({ @@ -1466,6 +1534,21 @@ export function App() { } setHeartbeatsByNode(Object.fromEntries(heartbeatEntries)); + const replayReadyNodeIds = loadedStaleNodeRiskReport.nodes + .filter((node) => node.recovery_bridge_replay_ready) + .map((node) => node.node_id); + if (replayReadyNodeIds.length > 0) { + const bridgeReplayEntries = await Promise.all( + replayReadyNodeIds.map(async (nodeId) => [nodeId, await client.getNodeBridgeReplayPlan(clusterId, nodeId)] as const), + ); + if (requestSeq !== clusterScopeRequestSeq.current) { + return; + } + setBridgeReplayPlansByNode(Object.fromEntries(bridgeReplayEntries)); + } else { + setBridgeReplayPlansByNode({}); + } + const updatePlanEntries = await Promise.all( loadedNodes.map(async (node) => [node.id, await client.getNodeUpdatePlan(clusterId, node.id, { currentVersion: node.reported_version })] as const), ); @@ -1764,6 +1847,8 @@ export function App() { setJoinRequests([]); setJoinTokens([]); setReleaseVersions([]); + setStaleNodeRiskReport(null); + setBridgeReplayPlansByNode({}); setNodeUpdatePlansByNode({}); setAuthority(null); setRolesByNode({}); @@ -1816,6 +1901,49 @@ export function App() { } } + async function runLegacyRemovalGuardSmoke() { + if (!selectedClusterId || !staleNodeRiskReport || staleNodeRiskReport.summary.blocked_nodes < 1) { + setLegacyGuardSmokeResult("Guard smoke сейчас недоступен: в отчете нет blocker-узлов для controlled blocked-check."); + setLegacyGuardSmokeCheckedAt(new Date().toISOString()); + return; + } + setLoading(true); + setError(""); + setNotice(""); + setLegacyGuardSmokeResult(""); + try { + const smokeVersion = `0.2.402-guard-smoke-${Date.now()}`; + await client.createReleaseVersion(selectedClusterId, { + product: "rap-node-agent", + version: smokeVersion, + channel: "stable", + status: "active", + compatibility: { legacy_removal: true }, + changelog: "UI smoke check for legacy removal guard", + artifacts: [ + { + os: "linux", + arch: "amd64", + installType: "docker", + kind: "image", + url: "https://example.test/rap-node-agent.tar", + sha256: "sha256-guard-smoke", + sizeBytes: 123, + metadata: {}, + }, + ], + }); + setLegacyGuardSmokeResult( + "Smoke unexpectedly succeeded. Guard should have blocked breaking release creation while stale recovery-risk nodes remain.", + ); + } catch (err) { + setLegacyGuardSmokeResult(err instanceof Error ? err.message : "Guard smoke failed with a non-Error response."); + } finally { + setLegacyGuardSmokeCheckedAt(new Date().toISOString()); + setLoading(false); + } + } + async function refreshVPNClientDiagnostic() { if (!selectedClusterId) { setVPNClientDiagnostic(null); @@ -2721,22 +2849,291 @@ export function App() { + +

- Version Storage будет хранить stable/current/candidate и signed artifacts. Сейчас это не production updater runtime. + Version Storage и 409/HTTP ответы относятся к Control API. Межузловой runtime transport Fabric остается QUIC/UDP-only.

+ {staleNodeRiskReport && ( +
+
+ + {staleNodeRiskReport.legacy_removal_allowed ? "legacy cleanup allowed" : "legacy cleanup blocked"} + + nodes {staleNodeRiskReport.summary.total_nodes} + 0 ? "warn" : "good"}`}> + stale {staleNodeRiskReport.summary.stale_nodes} + + 0 ? "bad" : "good"}`}> + blockers {staleNodeRiskReport.summary.blocked_nodes} + + {staleNodeRiskReport.bridge_hold_required && bridge hold active} + {(staleNodeRiskReport.blocked_operations || []).map((operation) => ( + + {operation} + + ))} + {(staleNodeRiskReport.summary.artifact_gap_nodes || 0) > 0 && ( + artifact gap {staleNodeRiskReport.summary.artifact_gap_nodes} + )} + {(staleNodeRiskReport.summary.unknown_profile_nodes || 0) > 0 && ( + profile unknown {staleNodeRiskReport.summary.unknown_profile_nodes} + )} + {(staleNodeRiskReport.summary.waiting_update_status_nodes || 0) > 0 && ( + waiting status {staleNodeRiskReport.summary.waiting_update_status_nodes} + )} + {(staleNodeRiskReport.summary.unknown_version_nodes || 0) > 0 && ( + version unknown {staleNodeRiskReport.summary.unknown_version_nodes} + )} + {(staleNodeRiskReport.summary.direct_peer_alert_nodes || 0) > 0 && ( + direct peer alert {staleNodeRiskReport.summary.direct_peer_alert_nodes} + )} + {(staleNodeRiskReport.summary.legacy_recovery_contract_nodes || 0) > 0 && ( + legacy recovery contract {staleNodeRiskReport.summary.legacy_recovery_contract_nodes} + )} + {(staleNodeRiskReport.summary.recovery_bridge_required_nodes || 0) > 0 && ( + recovery bridge {staleNodeRiskReport.summary.recovery_bridge_required_nodes} + )} + {(staleNodeRiskReport.summary.recovery_bridge_replay_ready_nodes || 0) > 0 && ( + bridge replay ready {staleNodeRiskReport.summary.recovery_bridge_replay_ready_nodes} + )} + {(staleNodeRiskReport.summary.waiting_recovery_heartbeat_nodes || 0) > 0 && ( + waiting heartbeat {staleNodeRiskReport.summary.waiting_recovery_heartbeat_nodes} + )} +
+ {staleNodeRiskReport.summary.blocked_nodes > 0 ? ( +
+ {staleNodeRiskReport.bridge_hold_required && ( +

+ {(() => { + const bridgeHoldNodeCount = + (staleNodeRiskReport.bridge_hold_node_ids || []).length || + staleNodeRiskReport.summary.recovery_bridge_required_nodes || + 0; + const bridgeHoldReasons = staleNodeRiskReport.bridge_hold_reasons || []; + return ( + <> + Recovery bridge hold active: compatibility overlap must remain enabled for{" "} + {bridgeHoldNodeCount} node(s) + {bridgeHoldReasons.length > 0 ? ` until ${bridgeHoldReasons.join(", ")} is cleared.` : "."} + + ); + })()} +

+ )} + {staleNodeRiskReport.nodes + .filter((node) => node.blocked) + .slice(0, 4) + .map((node) => { + const bridgeReplayPlan = bridgeReplayPlansByNode[node.node_id]; + const productSummary = node.products + .map((product) => { + const artifact = product.compatible_artifact_found + ? product.matching_release_version || "ok" + : "missing"; + return `${product.product}: ${artifact}`; + }) + .join(" | "); + return ( +
+ + + + + + + + + 0 ? (node.recovery_bridge_actions || []).join(", ") : "none"} + /> + {bridgeReplayPlan && bridgeReplayPlan.products && bridgeReplayPlan.products.length > 0 && ( + <> + + {bridgeReplayPlan.products.map((productPlan) => ( + ${productPlan.update_plan.target_version || "none"} / ${productPlan.recovery_bridge_mode || "default"} / ${productPlan.last_status_reason || "no reason"}`} + /> + ))} +
+ + + +
+ + + )} + + + +
+ + + +
+
+ ); + })} +
+ ) : ( +

Сейчас старых узлов-блокеров нет: compatibility overlap можно снимать только после отдельного решения оператора.

+ )} + {legacyRemovalBlockedAuditEvents.length > 0 && ( +
+

Последние заблокированные попытки rollout

+ {legacyRemovalBlockedAuditEvents.map((event) => { + const payload = objectField(event.payload) || {}; + const blockedOperation = stringField(payload, "blocked_operation", ""); + const staleNodes = numberField(payload, "stale_nodes"); + const blockedNodes = numberField(payload, "blocked_nodes"); + const target = `${statusLabel(event.target_type)}${event.target_id ? `:${shortId(event.target_id)}` : ""}`; + const counts = [ + Number.isFinite(staleNodes) ? `stale ${staleNodes}` : "", + Number.isFinite(blockedNodes) ? `blockers ${blockedNodes}` : "", + ] + .filter(Boolean) + .join(" / "); + return ( +
+ + + + +
+ ); + })} +
+ )} +
+

+ Operator smoke: этот action намеренно пытается создать breaking release через HTTP Control API и должен получить blocked `409`, + пока stale recovery-risk узлы ещё есть. Это проверка guard и frontend error formatting, а не transport Fabric. +

+
+ smoke + {legacyGuardSmokeResult ? ( + legacyGuardSmokeResult.includes("unexpectedly succeeded") ? ( + unexpected success + ) : ( + + expected blocked 409{legacyGuardSmokeCheckedAt ? ` · ${formatDateAgo(legacyGuardSmokeCheckedAt)}` : ""} + + ) + ) : ( + not checked + )} +
+
+ +
+ {legacyGuardSmokeResult && ( +
+ Guard smoke +
{legacyGuardSmokeResult}
+ {legacyGuardSmokeCheckedAt &&
Проверено: {formatDate(legacyGuardSmokeCheckedAt)}
} +
+ )} +
+
+ )}
-

Admin endpoints

+

Control/API access

- Панель кластера не переезжает автоматически на storage-узел. Cluster Admin Endpoint должен быть назначен отдельной explicit ролью на - ingress/admin-capable узле. + Это слой входа в Control/API, а не transport fabric. Панель кластера не переезжает автоматически на storage-узел: admin/runtime access + назначается отдельной ролью на ingress/admin-capable узле.

@@ -3176,11 +3573,12 @@ export function App() { const listenerConfig = (listenerDesired?.config || {}) as Record; const draft = meshListenerDrafts[node.id] || { - listenAddr: String(listenerConfig.listen_addr || ":19131"), + listenAddr: String(listenerConfig.listen_addr || ""), mode: String(listenerConfig.listen_port_mode || "auto"), autoRange: `${Number(listenerConfig.auto_port_start || 19131)}-${Number(listenerConfig.auto_port_end || 19231)}`, advertiseEndpoint: String(listenerConfig.advertise_endpoint || ""), - advertiseTransport: String(listenerConfig.advertise_transport || "direct_http"), + endpointCandidates: meshListenerEndpointCandidatesToText(listenerConfig), + advertiseTransport: String(listenerConfig.advertise_transport || "direct_quic"), connectivity: String(listenerConfig.connectivity_mode || "private_lan"), nat: String(listenerConfig.nat_type || "none"), region: String(listenerConfig.region || ""), @@ -3211,15 +3609,30 @@ export function App() { setDraft({ advertiseEndpoint: event.target.value })} - placeholder="http://external-or-lan-ip:19131" + placeholder="quic://192.168.200.85:18080" /> +