diff --git a/_tmp_release_host_agent_0.2.283-fabricendpointguard/rap-host-agent-0.2.283-fabricendpointguard-linux-amd64 b/_tmp_release_host_agent_0.2.283-fabricendpointguard/rap-host-agent-0.2.283-fabricendpointguard-linux-amd64 new file mode 100644 index 0000000..ca41649 Binary files /dev/null and b/_tmp_release_host_agent_0.2.283-fabricendpointguard/rap-host-agent-0.2.283-fabricendpointguard-linux-amd64 differ diff --git a/_tmp_release_host_agent_0.2.284-quorumauthority/rap-host-agent-0.2.284-quorumauthority-linux-amd64 b/_tmp_release_host_agent_0.2.284-quorumauthority/rap-host-agent-0.2.284-quorumauthority-linux-amd64 new file mode 100644 index 0000000..012a106 Binary files /dev/null and b/_tmp_release_host_agent_0.2.284-quorumauthority/rap-host-agent-0.2.284-quorumauthority-linux-amd64 differ diff --git a/_tmp_release_host_agent_0.2.285-quorumbootstrap/rap-host-agent-0.2.285-quorumbootstrap-linux-amd64 b/_tmp_release_host_agent_0.2.285-quorumbootstrap/rap-host-agent-0.2.285-quorumbootstrap-linux-amd64 new file mode 100644 index 0000000..ae90513 Binary files /dev/null and b/_tmp_release_host_agent_0.2.285-quorumbootstrap/rap-host-agent-0.2.285-quorumbootstrap-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.280-fabricsession/fabric-loadtest-linux-amd64 b/_tmp_release_node_agent_0.2.280-fabricsession/fabric-loadtest-linux-amd64 new file mode 100644 index 0000000..77d8765 Binary files /dev/null and b/_tmp_release_node_agent_0.2.280-fabricsession/fabric-loadtest-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.280-fabricsession/rap-node-agent-0.2.280-fabricsession-docker-amd64.tar b/_tmp_release_node_agent_0.2.280-fabricsession/rap-node-agent-0.2.280-fabricsession-docker-amd64.tar new file mode 100644 index 0000000..ac660a3 Binary files /dev/null and b/_tmp_release_node_agent_0.2.280-fabricsession/rap-node-agent-0.2.280-fabricsession-docker-amd64.tar differ diff --git a/_tmp_release_node_agent_0.2.280-fabricsession/rap-node-agent-0.2.280-fabricsession-linux-amd64 b/_tmp_release_node_agent_0.2.280-fabricsession/rap-node-agent-0.2.280-fabricsession-linux-amd64 new file mode 100644 index 0000000..83dd2f3 Binary files /dev/null and b/_tmp_release_node_agent_0.2.280-fabricsession/rap-node-agent-0.2.280-fabricsession-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.281-fabricwinshim/rap-node-agent-0.2.281-fabricwinshim-linux-amd64 b/_tmp_release_node_agent_0.2.281-fabricwinshim/rap-node-agent-0.2.281-fabricwinshim-linux-amd64 new file mode 100644 index 0000000..0dea1bc Binary files /dev/null and b/_tmp_release_node_agent_0.2.281-fabricwinshim/rap-node-agent-0.2.281-fabricwinshim-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.282-fabricpeerquicshim/rap-node-agent-0.2.282-fabricpeerquicshim-linux-amd64 b/_tmp_release_node_agent_0.2.282-fabricpeerquicshim/rap-node-agent-0.2.282-fabricpeerquicshim-linux-amd64 new file mode 100644 index 0000000..9be9082 Binary files /dev/null and b/_tmp_release_node_agent_0.2.282-fabricpeerquicshim/rap-node-agent-0.2.282-fabricpeerquicshim-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.283-fabricendpointguard/rap-node-agent-0.2.283-fabricendpointguard-linux-amd64 b/_tmp_release_node_agent_0.2.283-fabricendpointguard/rap-node-agent-0.2.283-fabricendpointguard-linux-amd64 new file mode 100644 index 0000000..518d7dc Binary files /dev/null and b/_tmp_release_node_agent_0.2.283-fabricendpointguard/rap-node-agent-0.2.283-fabricendpointguard-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.284-quorumauthority/rap-node-agent-0.2.284-quorumauthority-linux-amd64 b/_tmp_release_node_agent_0.2.284-quorumauthority/rap-node-agent-0.2.284-quorumauthority-linux-amd64 new file mode 100644 index 0000000..a9b08be Binary files /dev/null and b/_tmp_release_node_agent_0.2.284-quorumauthority/rap-node-agent-0.2.284-quorumauthority-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.285-quorumbootstrap/rap-node-agent-0.2.285-quorumbootstrap-docker-amd64.tar b/_tmp_release_node_agent_0.2.285-quorumbootstrap/rap-node-agent-0.2.285-quorumbootstrap-docker-amd64.tar new file mode 100644 index 0000000..fb83a89 Binary files /dev/null and b/_tmp_release_node_agent_0.2.285-quorumbootstrap/rap-node-agent-0.2.285-quorumbootstrap-docker-amd64.tar differ diff --git a/_tmp_release_node_agent_0.2.285-quorumbootstrap/rap-node-agent-0.2.285-quorumbootstrap-linux-amd64 b/_tmp_release_node_agent_0.2.285-quorumbootstrap/rap-node-agent-0.2.285-quorumbootstrap-linux-amd64 new file mode 100644 index 0000000..92a57a3 Binary files /dev/null and b/_tmp_release_node_agent_0.2.285-quorumbootstrap/rap-node-agent-0.2.285-quorumbootstrap-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.288-quiccertpins/rap-node-agent-0.2.288-quiccertpins-linux-amd64 b/_tmp_release_node_agent_0.2.288-quiccertpins/rap-node-agent-0.2.288-quiccertpins-linux-amd64 new file mode 100644 index 0000000..05603b7 Binary files /dev/null and b/_tmp_release_node_agent_0.2.288-quiccertpins/rap-node-agent-0.2.288-quiccertpins-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.289-quicdesiredlistener/rap-node-agent-0.2.289-quicdesiredlistener-linux-amd64 b/_tmp_release_node_agent_0.2.289-quicdesiredlistener/rap-node-agent-0.2.289-quicdesiredlistener-linux-amd64 new file mode 100644 index 0000000..9a6e98d Binary files /dev/null and b/_tmp_release_node_agent_0.2.289-quicdesiredlistener/rap-node-agent-0.2.289-quicdesiredlistener-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.290-quicsynthetictransport/rap-node-agent-0.2.290-quicsynthetictransport-linux-amd64 b/_tmp_release_node_agent_0.2.290-quicsynthetictransport/rap-node-agent-0.2.290-quicsynthetictransport-linux-amd64 new file mode 100644 index 0000000..1c466da Binary files /dev/null and b/_tmp_release_node_agent_0.2.290-quicsynthetictransport/rap-node-agent-0.2.290-quicsynthetictransport-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.291-quichttpdataplaneguard/rap-node-agent-0.2.291-quichttpdataplaneguard-linux-amd64 b/_tmp_release_node_agent_0.2.291-quichttpdataplaneguard/rap-node-agent-0.2.291-quichttpdataplaneguard-linux-amd64 new file mode 100644 index 0000000..91dfb3b Binary files /dev/null and b/_tmp_release_node_agent_0.2.291-quichttpdataplaneguard/rap-node-agent-0.2.291-quichttpdataplaneguard-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.292-quicdataplanereport/rap-node-agent-0.2.292-quicdataplanereport-linux-amd64 b/_tmp_release_node_agent_0.2.292-quicdataplanereport/rap-node-agent-0.2.292-quicdataplanereport-linux-amd64 new file mode 100644 index 0000000..ae750f6 Binary files /dev/null and b/_tmp_release_node_agent_0.2.292-quicdataplanereport/rap-node-agent-0.2.292-quicdataplanereport-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.293-privateintentguard/rap-node-agent-0.2.293-privateintentguard-linux-amd64 b/_tmp_release_node_agent_0.2.293-privateintentguard/rap-node-agent-0.2.293-privateintentguard-linux-amd64 new file mode 100644 index 0000000..1fb8802 Binary files /dev/null and b/_tmp_release_node_agent_0.2.293-privateintentguard/rap-node-agent-0.2.293-privateintentguard-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.294-relaycertpin/rap-node-agent-0.2.294-relaycertpin-linux-amd64 b/_tmp_release_node_agent_0.2.294-relaycertpin/rap-node-agent-0.2.294-relaycertpin-linux-amd64 new file mode 100644 index 0000000..bb01169 Binary files /dev/null and b/_tmp_release_node_agent_0.2.294-relaycertpin/rap-node-agent-0.2.294-relaycertpin-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.295-quiclivehandler/rap-node-agent-0.2.295-quiclivehandler-docker-amd64.tar b/_tmp_release_node_agent_0.2.295-quiclivehandler/rap-node-agent-0.2.295-quiclivehandler-docker-amd64.tar new file mode 100644 index 0000000..79040c1 Binary files /dev/null and b/_tmp_release_node_agent_0.2.295-quiclivehandler/rap-node-agent-0.2.295-quiclivehandler-docker-amd64.tar differ diff --git a/_tmp_release_node_agent_0.2.295-quiclivehandler/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.295-quiclivehandler/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..145d0b9 Binary files /dev/null and b/_tmp_release_node_agent_0.2.295-quiclivehandler/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.296-prodroutefix/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.296-prodroutefix/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..0f4a82d Binary files /dev/null and b/_tmp_release_node_agent_0.2.296-prodroutefix/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.297-relaytarget/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.297-relaytarget/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..78b0a8c Binary files /dev/null and b/_tmp_release_node_agent_0.2.297-relaytarget/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.298-quicmodes/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.298-quicmodes/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..1b09f09 Binary files /dev/null and b/_tmp_release_node_agent_0.2.298-quicmodes/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.299-relaymetadata/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.299-relaymetadata/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..dc8ca23 Binary files /dev/null and b/_tmp_release_node_agent_0.2.299-relaymetadata/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.300-reversestream/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.300-reversestream/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..9da403f Binary files /dev/null and b/_tmp_release_node_agent_0.2.300-reversestream/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.301-reversehello/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.301-reversehello/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..696a19c Binary files /dev/null and b/_tmp_release_node_agent_0.2.301-reversehello/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.302-reversehellostats/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.302-reversehellostats/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..8bed5d4 Binary files /dev/null and b/_tmp_release_node_agent_0.2.302-reversehellostats/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.303-reversetransportptr/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.303-reversetransportptr/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..8cd006c Binary files /dev/null and b/_tmp_release_node_agent_0.2.303-reversetransportptr/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.304-reverseids/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.304-reverseids/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..b164108 Binary files /dev/null and b/_tmp_release_node_agent_0.2.304-reverseids/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.305-localrelayreverse/rap-node-agent-0.2.305-localrelayreverse-docker-amd64.tar b/_tmp_release_node_agent_0.2.305-localrelayreverse/rap-node-agent-0.2.305-localrelayreverse-docker-amd64.tar new file mode 100644 index 0000000..53034e6 Binary files /dev/null and b/_tmp_release_node_agent_0.2.305-localrelayreverse/rap-node-agent-0.2.305-localrelayreverse-docker-amd64.tar differ diff --git a/_tmp_release_node_agent_0.2.305-localrelayreverse/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.305-localrelayreverse/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..1b2ceb4 Binary files /dev/null and b/_tmp_release_node_agent_0.2.305-localrelayreverse/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.306-localrelayreverse/rap-node-agent-0.2.306-localrelayreverse-linux-amd64 b/_tmp_release_node_agent_0.2.306-localrelayreverse/rap-node-agent-0.2.306-localrelayreverse-linux-amd64 new file mode 100644 index 0000000..a5e1fd0 Binary files /dev/null and b/_tmp_release_node_agent_0.2.306-localrelayreverse/rap-node-agent-0.2.306-localrelayreverse-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.306-localrelayreverse/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.306-localrelayreverse/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..a5e1fd0 Binary files /dev/null and b/_tmp_release_node_agent_0.2.306-localrelayreverse/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.307-localrelayreverse/rap-node-agent-0.2.307-localrelayreverse-linux-amd64 b/_tmp_release_node_agent_0.2.307-localrelayreverse/rap-node-agent-0.2.307-localrelayreverse-linux-amd64 new file mode 100644 index 0000000..8def83a Binary files /dev/null and b/_tmp_release_node_agent_0.2.307-localrelayreverse/rap-node-agent-0.2.307-localrelayreverse-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.307-localrelayreverse/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.307-localrelayreverse/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..8def83a Binary files /dev/null and b/_tmp_release_node_agent_0.2.307-localrelayreverse/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.308-localrelayreverse/rap-node-agent-0.2.308-localrelayreverse-linux-amd64 b/_tmp_release_node_agent_0.2.308-localrelayreverse/rap-node-agent-0.2.308-localrelayreverse-linux-amd64 new file mode 100644 index 0000000..15d40ef Binary files /dev/null and b/_tmp_release_node_agent_0.2.308-localrelayreverse/rap-node-agent-0.2.308-localrelayreverse-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.308-localrelayreverse/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.308-localrelayreverse/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..15d40ef Binary files /dev/null and b/_tmp_release_node_agent_0.2.308-localrelayreverse/rap-node-agent-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.309-latencyaware/rap-node-agent-0.2.309-latencyaware-docker-amd64.tar b/_tmp_release_node_agent_0.2.309-latencyaware/rap-node-agent-0.2.309-latencyaware-docker-amd64.tar new file mode 100644 index 0000000..dae5908 Binary files /dev/null and b/_tmp_release_node_agent_0.2.309-latencyaware/rap-node-agent-0.2.309-latencyaware-docker-amd64.tar differ diff --git a/_tmp_release_node_agent_0.2.309-latencyaware/rap-node-agent-0.2.309-latencyaware-linux-amd64 b/_tmp_release_node_agent_0.2.309-latencyaware/rap-node-agent-0.2.309-latencyaware-linux-amd64 new file mode 100644 index 0000000..a0660b7 Binary files /dev/null and b/_tmp_release_node_agent_0.2.309-latencyaware/rap-node-agent-0.2.309-latencyaware-linux-amd64 differ diff --git a/_tmp_release_node_agent_0.2.309-latencyaware/rap-node-agent-linux-amd64 b/_tmp_release_node_agent_0.2.309-latencyaware/rap-node-agent-linux-amd64 new file mode 100644 index 0000000..a0660b7 Binary files /dev/null and b/_tmp_release_node_agent_0.2.309-latencyaware/rap-node-agent-linux-amd64 differ diff --git a/agents/rap-node-agent/Dockerfile.fabric-loadtest b/agents/rap-node-agent/Dockerfile.fabric-loadtest new file mode 100644 index 0000000..25d3eec --- /dev/null +++ b/agents/rap-node-agent/Dockerfile.fabric-loadtest @@ -0,0 +1,17 @@ +FROM golang:1.25-bookworm AS build + +WORKDIR /src +COPY agents/rap-node-agent/go.mod ./ +COPY agents/rap-node-agent/go.sum ./ +RUN go mod download +COPY agents/rap-node-agent/ ./ +RUN CGO_ENABLED=0 GOOS=linux GOARCH=amd64 go build -o /out/fabric-loadtest ./cmd/fabric-loadtest + +FROM debian:bookworm-slim + +RUN apt-get update \ + && apt-get install -y --no-install-recommends ca-certificates iproute2 iptables iputils-ping procps \ + && rm -rf /var/lib/apt/lists/* +COPY --from=build /out/fabric-loadtest /usr/local/bin/fabric-loadtest +ENTRYPOINT ["/usr/local/bin/fabric-loadtest"] + diff --git a/agents/rap-node-agent/cmd/fabric-loadtest/main.go b/agents/rap-node-agent/cmd/fabric-loadtest/main.go new file mode 100644 index 0000000..29951c9 --- /dev/null +++ b/agents/rap-node-agent/cmd/fabric-loadtest/main.go @@ -0,0 +1,2009 @@ +package main + +import ( + "bytes" + "context" + "crypto/rand" + "crypto/rsa" + "crypto/sha256" + "crypto/tls" + "crypto/x509" + "crypto/x509/pkix" + "encoding/hex" + "encoding/json" + "flag" + "fmt" + "log" + "math/big" + "os" + "runtime" + "sort" + "strings" + "sync" + "sync/atomic" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh" + "github.com/quic-go/quic-go" +) + +const ( + maxStreamResultSamples = 25 + maxMetricSamples = 10000 +) + +type loadtestConfig struct { + Mode string `json:"mode"` + ListenAddr string `json:"listen_addr"` + Targets []string `json:"targets"` + TopologyProfile string `json:"topology_profile,omitempty"` + Soak bool `json:"soak"` + Nodes int `json:"nodes"` + Streams int `json:"streams"` + Concurrency int `json:"concurrency"` + BytesPerStream int64 `json:"bytes_per_stream"` + ControlEvery int `json:"control_every"` + ControlBytes int64 `json:"control_bytes_per_stream"` + PayloadSize int `json:"payload_size"` + ShortSessions bool `json:"short_sessions"` + PoolFailover bool `json:"pool_failover"` + FailTarget int `json:"fail_target"` + FailAfter time.Duration `json:"fail_after"` + ImpairTarget int `json:"impair_target"` + ProbeTargets bool `json:"probe_targets"` + MaxTargetRTTMs int64 `json:"max_target_rtt_ms"` + MigrateSlow bool `json:"migrate_slow_streams"` + MaxAckMs int64 `json:"max_ack_ms"` + MaxAckP95Ms int64 `json:"max_ack_p95_ms"` + MaxAckP99Ms int64 `json:"max_ack_p99_ms"` + MaxTargetAckMs int64 `json:"max_target_ack_ms"` + MaxControlP95 int64 `json:"max_control_ack_p95_ms"` + MaxSetupP95Ms int64 `json:"max_setup_p95_ms"` + MaxSetupP99Ms int64 `json:"max_setup_p99_ms"` + MaxRerouteP95Ms int64 `json:"max_reroute_p95_ms"` + MaxRerouteP99Ms int64 `json:"max_reroute_p99_ms"` + MaxGoroutineDelta int `json:"max_goroutine_delta"` + MaxHeapDeltaMB int64 `json:"max_heap_delta_mb"` + MaxOpenFDDelta int `json:"max_open_fd_delta"` + MaxOpenFDs int `json:"max_open_fds"` + MinThroughputMbps int64 `json:"min_throughput_mbps"` + MinChannelChurn int64 `json:"min_channel_churn_per_sec"` + StreamTimeout time.Duration `json:"stream_timeout"` + AckTimeout time.Duration `json:"ack_timeout"` + TargetQuarantine time.Duration `json:"target_quarantine_ttl"` + FailureQuarantine time.Duration `json:"failure_quarantine_ttl"` + Duration time.Duration `json:"duration"` + Timeout time.Duration `json:"timeout"` + ResourceSample time.Duration `json:"resource_sample_interval"` + ReportPath string `json:"report_path,omitempty"` +} + +type streamResult struct { + StreamIndex int `json:"stream_index"` + InitialTarget string `json:"initial_target"` + Target string `json:"target"` + RouteID string `json:"route_id,omitempty"` + RouteMode string `json:"route_mode,omitempty"` + TargetAttempts []string `json:"target_attempts,omitempty"` + RouteAttempts []string `json:"route_attempts,omitempty"` + FailoverCount int `json:"failover_count"` + MigrationCount int `json:"migration_count"` + BytesSent int64 `json:"bytes_sent"` + FramesSent int64 `json:"frames_sent"` + AcksReceived int64 `json:"acks_received"` + AbandonedFrames int64 `json:"abandoned_frames,omitempty"` + AckIntegrityErrors int `json:"ack_integrity_errors,omitempty"` + MaxAckMs int64 `json:"max_ack_ms"` + SetupMs int64 `json:"setup_ms"` + RerouteLatencyMs int64 `json:"reroute_latency_ms,omitempty"` + DurationMs int64 `json:"duration_ms"` + Error string `json:"error,omitempty"` + Degraded bool `json:"degraded,omitempty"` + ShortSession bool `json:"short_session"` + TrafficClass string `json:"traffic_class"` + LogicalStream uint64 `json:"logical_stream"` +} + +type loadtestReport struct { + SchemaVersion string `json:"schema_version"` + StartedAt time.Time `json:"started_at"` + FinishedAt time.Time `json:"finished_at"` + Config loadtestConfig `json:"config"` + TotalStreams int `json:"total_streams"` + SuccessfulStreams int `json:"successful_streams"` + FailedStreams int `json:"failed_streams"` + BytesSent int64 `json:"bytes_sent"` + FramesSent int64 `json:"frames_sent"` + AcksReceived int64 `json:"acks_received"` + AbandonedFrames int64 `json:"abandoned_frames,omitempty"` + AckMismatchedStreams int `json:"ack_mismatched_streams,omitempty"` + AckIntegrityErrors int `json:"ack_integrity_errors,omitempty"` + FailoverEvents int `json:"failover_events"` + MigrationEvents int `json:"migration_events"` + ChannelOpens uint64 `json:"channel_opens"` + ChannelCloses uint64 `json:"channel_closes"` + ChannelLeaks int `json:"channel_leaks"` + ChannelChurnPerSec int64 `json:"channel_churn_per_sec"` + ThroughputBps int64 `json:"throughput_bps"` + SetupLatencyP50Ms int64 `json:"setup_latency_p50_ms"` + SetupLatencyP95Ms int64 `json:"setup_latency_p95_ms"` + SetupLatencyP99Ms int64 `json:"setup_latency_p99_ms"` + ChannelOpenP50Ms int64 `json:"channel_open_p50_ms"` + ChannelOpenP95Ms int64 `json:"channel_open_p95_ms"` + ChannelOpenP99Ms int64 `json:"channel_open_p99_ms"` + RerouteLatencyP95Ms int64 `json:"reroute_latency_p95_ms"` + RerouteLatencyP99Ms int64 `json:"reroute_latency_p99_ms"` + StreamDurationP95Ms int64 `json:"stream_duration_p95_ms"` + AckP95Ms int64 `json:"ack_p95_ms,omitempty"` + AckP99Ms int64 `json:"ack_p99_ms,omitempty"` + ControlStreams int `json:"control_streams"` + BulkStreams int `json:"bulk_streams"` + ControlAckP95Ms int64 `json:"control_ack_p95_ms,omitempty"` + BulkAckP95Ms int64 `json:"bulk_ack_p95_ms,omitempty"` + RouteAttemptsTotal int64 `json:"route_attempts_total"` + RerouteCauses map[string]int `json:"reroute_causes,omitempty"` + Errors map[string]int `json:"errors,omitempty"` + TargetProbes []targetProbeResult `json:"target_probes,omitempty"` + ExcludedTargets []string `json:"excluded_targets,omitempty"` + TargetBytes map[string]int64 `json:"target_bytes,omitempty"` + TargetStreams map[string]int `json:"target_streams,omitempty"` + TargetStats map[string]targetStats `json:"target_stats,omitempty"` + DegradedTargets map[string]string `json:"degraded_targets,omitempty"` + RoutePressure mesh.FabricRoutePressureSnapshot `json:"route_pressure,omitempty"` + TransportSnapshot mesh.QUICFabricTransportSnapshot `json:"transport_snapshot,omitempty"` + ResourceSamples []resourceSample `json:"resource_samples,omitempty"` + ResourceSummary resourceSummary `json:"resource_summary,omitempty"` + StreamSamples []streamResult `json:"stream_samples,omitempty"` + ErrorSamples []streamResult `json:"error_samples,omitempty"` + Verdict string `json:"verdict"` + VerdictReasons []string `json:"verdict_reasons,omitempty"` +} + +type resourceSample struct { + ObservedAt time.Time `json:"observed_at"` + ElapsedMs int64 `json:"elapsed_ms"` + Goroutines int `json:"goroutines"` + HeapAllocBytes uint64 `json:"heap_alloc_bytes"` + HeapInuseBytes uint64 `json:"heap_inuse_bytes"` + HeapObjects uint64 `json:"heap_objects"` + OpenFDs int `json:"open_fds,omitempty"` + NumGC uint32 `json:"num_gc"` + ActiveStreams int `json:"active_streams"` + ActiveRoutes int `json:"active_routes"` + ActiveRouteLoad int `json:"active_route_load"` +} + +type resourceSummary struct { + SampleCount int `json:"sample_count"` + GoroutinesStart int `json:"goroutines_start"` + GoroutinesEnd int `json:"goroutines_end"` + GoroutinesMax int `json:"goroutines_max"` + GoroutinesDelta int `json:"goroutines_delta"` + HeapAllocStartBytes uint64 `json:"heap_alloc_start_bytes"` + HeapAllocEndBytes uint64 `json:"heap_alloc_end_bytes"` + HeapAllocMaxBytes uint64 `json:"heap_alloc_max_bytes"` + HeapAllocDeltaBytes int64 `json:"heap_alloc_delta_bytes"` + HeapObjectsStart uint64 `json:"heap_objects_start"` + HeapObjectsEnd uint64 `json:"heap_objects_end"` + HeapObjectsMax uint64 `json:"heap_objects_max"` + HeapObjectsDelta int64 `json:"heap_objects_delta"` + OpenFDsStart int `json:"open_fds_start,omitempty"` + OpenFDsEnd int `json:"open_fds_end,omitempty"` + OpenFDsMax int `json:"open_fds_max,omitempty"` + OpenFDsDelta int `json:"open_fds_delta,omitempty"` + GCCountDelta uint32 `json:"gc_count_delta"` + ActiveStreamsMax int `json:"active_streams_max"` + ActiveRouteLoadMax int `json:"active_route_load_max"` +} + +type targetProbeResult struct { + Target string `json:"target"` + RTTMs int64 `json:"rtt_ms"` + Error string `json:"error,omitempty"` + Usable bool `json:"usable"` +} + +type targetStats struct { + Streams int `json:"streams"` + BytesSent int64 `json:"bytes_sent"` + FramesSent int64 `json:"frames_sent"` + AcksReceived int64 `json:"acks_received"` + MaxAckMs int64 `json:"max_ack_ms"` + SetupLatencyP50Ms int64 `json:"setup_latency_p50_ms"` + SetupLatencyP95Ms int64 `json:"setup_latency_p95_ms"` + DurationP50Ms int64 `json:"duration_p50_ms"` + DurationP95Ms int64 `json:"duration_p95_ms"` + FailoverEntrypoint int `json:"failover_entrypoint,omitempty"` + DegradedEvents int `json:"degraded_events,omitempty"` + MaxActiveChannels int `json:"max_active_channels,omitempty"` + RouteModes map[string]int `json:"route_modes,omitempty"` +} + +type streamResultCollector struct { + mu sync.Mutex + + total int + successful int + failed int + + errors map[string]int + rerouteCauses map[string]int + targetBytes map[string]int64 + targetStreams map[string]int + + ackMismatchedStreams int + ackIntegrityErrors int + abandonedFrames int64 + targetFrames map[string]int64 + targetAcks map[string]int64 + targetMaxAck map[string]int64 + targetFailoverEntrypoint map[string]int + targetDegradedEvents map[string]int + targetRouteModes map[string]map[string]int + + setup []int64 + setupCount int + rerouteLatency []int64 + rerouteLatencyCount int + durations []int64 + durationCount int + controlAck []int64 + controlAckCount int + bulkAck []int64 + bulkAckCount int + allAck []int64 + allAckCount int + targetSetup map[string][]int64 + targetSetupCount map[string]int + targetDurations map[string][]int64 + targetDurationCount map[string]int + + controlStreams int + bulkStreams int + routeAttempts int64 + streamSamples []streamResult + errorSamples []streamResult +} + +type targetHealthTracker struct { + mu sync.Mutex + degraded map[string]string + degradedUntil map[string]time.Time + observed map[string]string + rttMs map[string]int64 +} + +func main() { + cfg := parseFlags() + ctx, cancel := context.WithTimeout(context.Background(), cfg.Timeout) + defer cancel() + + switch cfg.Mode { + case "server": + if err := runServer(ctx, cfg); err != nil { + log.Fatal(err) + } + case "client": + report, err := runClient(ctx, cfg) + if err != nil { + log.Fatal(err) + } + writeReport(report) + case "all": + report, err := runAll(ctx, cfg) + if err != nil { + log.Fatal(err) + } + writeReport(report) + default: + log.Fatalf("unsupported mode %q", cfg.Mode) + } +} + +func parseFlags() loadtestConfig { + var targetList string + cfg := loadtestConfig{} + flag.StringVar(&cfg.Mode, "mode", "all", "server, client, or all") + flag.StringVar(&cfg.ListenAddr, "listen", "127.0.0.1:0", "QUIC fabric listen address for server/all mode") + flag.StringVar(&targetList, "targets", "", "comma-separated quic://host:port targets for client mode") + flag.StringVar(&cfg.TopologyProfile, "topology-profile", "", "optional scenario label: public, nat-lan-relay, mixed-public-nat-lan-relay, or custom") + flag.BoolVar(&cfg.Soak, "soak", false, "keep generating logical streams until duration expires") + flag.IntVar(&cfg.Nodes, "nodes", 1, "number of in-process target nodes for all mode") + flag.IntVar(&cfg.Streams, "streams", 1000, "logical streams to create") + flag.IntVar(&cfg.Concurrency, "concurrency", 64, "maximum concurrently active QUIC fabric streams") + flag.Int64Var(&cfg.BytesPerStream, "bytes-per-stream", 4*1024*1024, "bytes to send on each stream") + flag.IntVar(&cfg.ControlEvery, "control-every", 0, "mark every Nth stream as control traffic, 0 disables mixed control traffic") + flag.Int64Var(&cfg.ControlBytes, "control-bytes-per-stream", 4096, "bytes to send on each control stream") + flag.IntVar(&cfg.PayloadSize, "payload-size", 64*1024, "payload bytes per fabric frame") + flag.BoolVar(&cfg.ShortSessions, "short-sessions", true, "close each QUIC fabric stream after its logical channel completes") + flag.BoolVar(&cfg.PoolFailover, "pool-failover", true, "retry a failed stream on the next target in the pool") + flag.IntVar(&cfg.FailTarget, "fail-target", -1, "target index to close during all-mode load, -1 disables failure injection") + flag.DurationVar(&cfg.FailAfter, "fail-after", 0, "delay before closing fail-target in all mode") + flag.IntVar(&cfg.ImpairTarget, "impair-target", -1, "target index expected to be degraded by an external impairment, -1 disables") + flag.BoolVar(&cfg.ProbeTargets, "probe-targets", false, "probe target RTT before stream placement") + flag.Int64Var(&cfg.MaxTargetRTTMs, "max-target-rtt-ms", 0, "exclude targets whose probe RTT exceeds this threshold") + flag.BoolVar(&cfg.MigrateSlow, "migrate-slow-streams", false, "continue a logical stream on the next target when ACK latency exceeds max-ack-ms") + flag.Int64Var(&cfg.MaxAckMs, "max-ack-ms", 0, "ACK latency threshold for slow stream migration") + flag.Int64Var(&cfg.MaxAckP95Ms, "max-ack-p95-ms", 0, "fail verdict when overall ACK p95 exceeds this threshold, 0 disables") + flag.Int64Var(&cfg.MaxAckP99Ms, "max-ack-p99-ms", 0, "fail verdict when overall ACK p99 exceeds this threshold, 0 disables") + flag.Int64Var(&cfg.MaxTargetAckMs, "max-target-ack-ms", 0, "fail verdict when any healthy target max ACK exceeds this threshold, 0 disables") + flag.Int64Var(&cfg.MaxControlP95, "max-control-ack-p95-ms", 100, "fail verdict when control ACK p95 exceeds this threshold, 0 disables") + flag.Int64Var(&cfg.MaxSetupP95Ms, "max-setup-p95-ms", 200, "fail verdict when channel setup p95 exceeds this threshold, 0 disables") + flag.Int64Var(&cfg.MaxSetupP99Ms, "max-setup-p99-ms", 0, "fail verdict when channel setup p99 exceeds this threshold, 0 disables") + flag.Int64Var(&cfg.MaxRerouteP95Ms, "max-reroute-p95-ms", 0, "fail verdict when reroute setup p95 exceeds this threshold, 0 disables") + flag.Int64Var(&cfg.MaxRerouteP99Ms, "max-reroute-p99-ms", 0, "fail verdict when reroute setup p99 exceeds this threshold, 0 disables") + flag.IntVar(&cfg.MaxGoroutineDelta, "max-goroutine-delta", 0, "fail verdict when resource summary goroutine delta exceeds this value, 0 disables") + flag.Int64Var(&cfg.MaxHeapDeltaMB, "max-heap-delta-mb", 0, "fail verdict when heap alloc delta exceeds this MiB value, 0 disables") + flag.IntVar(&cfg.MaxOpenFDDelta, "max-open-fd-delta", 0, "fail verdict when open file descriptor delta exceeds this value, 0 disables") + flag.IntVar(&cfg.MaxOpenFDs, "max-open-fds", 0, "fail verdict when max open file descriptors exceeds this value, 0 disables") + flag.Int64Var(&cfg.MinThroughputMbps, "min-throughput-mbps", 0, "fail verdict when throughput falls below this Mbps value, 0 disables") + flag.Int64Var(&cfg.MinChannelChurn, "min-channel-churn-per-sec", 0, "fail verdict when logical channel open rate falls below this value, 0 disables") + flag.DurationVar(&cfg.StreamTimeout, "stream-timeout", 30*time.Second, "timeout for a single logical channel attempt, 0 disables") + flag.DurationVar(&cfg.AckTimeout, "ack-timeout", 2*time.Second, "timeout for one data-frame ACK before rerouting/failing, 0 disables") + flag.DurationVar(&cfg.TargetQuarantine, "target-quarantine-ttl", 30*time.Second, "how long a failed or slow target stays out of placement before it can be retried") + flag.DurationVar(&cfg.FailureQuarantine, "failure-quarantine-ttl", 5*time.Minute, "how long a known hard-failed target stays out of placement before it can be retried") + flag.DurationVar(&cfg.Duration, "duration", 0, "optional max client send duration") + flag.DurationVar(&cfg.Timeout, "timeout", 10*time.Minute, "overall timeout") + flag.DurationVar(&cfg.ResourceSample, "resource-sample-interval", time.Second, "resource sampling interval for soak and stress reports") + flag.StringVar(&cfg.ReportPath, "report-path", "", "optional path to write the full JSON report") + flag.Parse() + cfg.Targets = splitCSV(targetList) + if cfg.Streams <= 0 { + cfg.Streams = 1 + } + if cfg.Nodes <= 0 { + cfg.Nodes = 1 + } + if cfg.Concurrency <= 0 || cfg.Concurrency > cfg.Streams { + cfg.Concurrency = cfg.Streams + } + if cfg.BytesPerStream <= 0 { + cfg.BytesPerStream = 1 + } + if cfg.ControlBytes <= 0 { + cfg.ControlBytes = 1 + } + if cfg.PayloadSize <= 0 { + cfg.PayloadSize = 64 * 1024 + } + if cfg.PayloadSize > fabricproto.DefaultMaxPayload { + cfg.PayloadSize = fabricproto.DefaultMaxPayload + } + if cfg.Soak && cfg.Duration <= 0 { + cfg.Duration = time.Minute + } + if cfg.ResourceSample < 0 { + cfg.ResourceSample = 0 + } + if cfg.TargetQuarantine < 0 { + cfg.TargetQuarantine = 0 + } + if cfg.FailureQuarantine < 0 { + cfg.FailureQuarantine = cfg.TargetQuarantine + } + return cfg +} + +func runServer(ctx context.Context, cfg loadtestConfig) error { + tlsConfig, fingerprint, err := selfSignedTLSConfig() + if err != nil { + return err + } + server, err := mesh.StartQUICFabricServer(ctx, mesh.QUICFabricServerConfig{ + ListenAddr: cfg.ListenAddr, + TLSConfig: tlsConfig, + QUICConfig: loadtestQUICConfig(cfg), + }) + if err != nil { + return err + } + defer server.Close() + log.Printf("fabric_loadtest_server addr=quic://%s tls_cert_sha256=%s", server.Addr().String(), fingerprint) + <-ctx.Done() + return ctx.Err() +} + +func runAll(ctx context.Context, cfg loadtestConfig) (loadtestReport, error) { + tlsConfig, _, err := selfSignedTLSConfig() + if err != nil { + return loadtestReport{}, err + } + servers := make([]*mesh.QUICFabricServer, 0, cfg.Nodes) + for i := 0; i < cfg.Nodes; i++ { + server, err := mesh.StartQUICFabricServer(ctx, mesh.QUICFabricServerConfig{ + ListenAddr: cfg.ListenAddr, + TLSConfig: tlsConfig, + QUICConfig: loadtestQUICConfig(cfg), + }) + if err != nil { + for _, server := range servers { + _ = server.Close() + } + return loadtestReport{}, err + } + servers = append(servers, server) + cfg.Targets = append(cfg.Targets, "quic://"+server.Addr().String()) + } + defer func() { + for _, server := range servers { + _ = server.Close() + } + }() + if cfg.FailTarget >= 0 && cfg.FailTarget < len(servers) { + go func() { + if cfg.FailAfter > 0 { + select { + case <-time.After(cfg.FailAfter): + case <-ctx.Done(): + return + } + } + _ = servers[cfg.FailTarget].Close() + }() + } + return runClient(ctx, cfg) +} + +func runClient(ctx context.Context, cfg loadtestConfig) (loadtestReport, error) { + if len(cfg.Targets) == 0 { + return loadtestReport{}, fmt.Errorf("at least one target is required") + } + if reasons := targetEndpointPolicyVerdictReasons(loadtestReport{Config: cfg}); len(reasons) > 0 { + return loadtestReport{}, fmt.Errorf("invalid fabric targets: %s", strings.Join(reasons, "; ")) + } + started := time.Now().UTC() + transport := mesh.NewQUICFabricTransport(loadtestQUICConfig(cfg)) + transport.MaxStreamsPerConn = cfg.Concurrency + defer transport.Close() + health := newTargetHealthTracker() + var probes []targetProbeResult + var excluded []string + if cfg.ProbeTargets || cfg.MaxTargetRTTMs > 0 { + probes, excluded, cfg.Targets = probeAndFilterTargets(ctx, transport, cfg) + if len(cfg.Targets) == 0 { + return loadtestReport{}, fmt.Errorf("all fabric targets failed probe") + } + health.RecordProbes(probes) + } + + payload := make([]byte, cfg.PayloadSize) + for i := range payload { + payload[i] = byte(i % 251) + } + runCtx := ctx + var cancel context.CancelFunc + if cfg.Duration > 0 && !cfg.Soak { + runCtx, cancel = context.WithTimeout(ctx, cfg.Duration) + defer cancel() + } + + var totalBytes atomic.Int64 + var totalFrames atomic.Int64 + var totalAcks atomic.Int64 + var totalFailovers atomic.Int64 + var totalMigrations atomic.Int64 + pressure := mesh.NewFabricRoutePressureTracker() + sampler := startResourceSampler(runCtx, started, transport, pressure, cfg.ResourceSample) + results := runClientStreams(runCtx, transport, pressure, health, cfg, payload, &totalBytes, &totalFrames, &totalAcks, &totalFailovers, &totalMigrations) + resourceSamples := sampler.Stop() + + finished := time.Now().UTC() + report := summarizeResults(cfg, started, finished, results) + report.TargetProbes = probes + report.ExcludedTargets = excluded + report.BytesSent = totalBytes.Load() + report.FramesSent = totalFrames.Load() + report.AcksReceived = totalAcks.Load() + report.FailoverEvents = int(totalFailovers.Load()) + report.MigrationEvents = int(totalMigrations.Load()) + report.TransportSnapshot = transport.Snapshot() + report.ChannelOpens = report.TransportSnapshot.Stats.StreamOpens + report.ChannelCloses = report.TransportSnapshot.Stats.StreamCloses + report.ChannelLeaks = report.TransportSnapshot.ActiveStreams + if elapsed := finished.Sub(started).Seconds(); elapsed > 0 { + report.ThroughputBps = int64(float64(report.BytesSent*8) / elapsed) + report.ChannelChurnPerSec = int64(float64(report.ChannelOpens) / elapsed) + } + report.RoutePressure = pressure.SnapshotPressure() + report.DegradedTargets = health.Snapshot() + report.ResourceSamples = resourceSamples + report.ResourceSummary = summarizeResourceSamples(resourceSamples) + applyRoutePressureToTargetStats(&report) + report.Verdict, report.VerdictReasons = verdict(report) + return report, nil +} + +func runClientStreams(ctx context.Context, transport *mesh.QUICFabricTransport, pressure *mesh.FabricRoutePressureTracker, health *targetHealthTracker, cfg loadtestConfig, payload []byte, totalBytes *atomic.Int64, totalFrames *atomic.Int64, totalAcks *atomic.Int64, totalFailovers *atomic.Int64, totalMigrations *atomic.Int64) *streamResultCollector { + if cfg.Soak { + return runClientSoakStreams(ctx, transport, pressure, health, cfg, payload, totalBytes, totalFrames, totalAcks, totalFailovers, totalMigrations) + } + results := newStreamResultCollector() + var wg sync.WaitGroup + sem := make(chan struct{}, cfg.Concurrency) + for i := 0; i < cfg.Streams; i++ { + i := i + wg.Add(1) + go func() { + defer wg.Done() + sem <- struct{}{} + defer func() { <-sem }() + result := runStream(ctx, transport, pressure, health, cfg, i, payload) + results.Add(result) + addStreamTotals(result, totalBytes, totalFrames, totalAcks, totalFailovers, totalMigrations) + }() + } + wg.Wait() + return results +} + +type resourceSampler struct { + done chan struct{} + stopped chan []resourceSample +} + +func startResourceSampler(_ context.Context, started time.Time, transport *mesh.QUICFabricTransport, pressure *mesh.FabricRoutePressureTracker, interval time.Duration) *resourceSampler { + sampler := &resourceSampler{done: make(chan struct{}), stopped: make(chan []resourceSample, 1)} + go func() { + samples := []resourceSample{captureResourceSample(started, transport, pressure)} + if interval <= 0 { + <-sampler.done + samples = append(samples, captureResourceSample(started, transport, pressure)) + sampler.stopped <- samples + return + } + ticker := time.NewTicker(interval) + defer ticker.Stop() + for { + select { + case <-sampler.done: + samples = append(samples, captureResourceSample(started, transport, pressure)) + sampler.stopped <- samples + return + case <-ticker.C: + samples = append(samples, captureResourceSample(started, transport, pressure)) + } + } + }() + return sampler +} + +func (s *resourceSampler) Stop() []resourceSample { + if s == nil { + return nil + } + close(s.done) + return <-s.stopped +} + +func captureResourceSample(started time.Time, transport *mesh.QUICFabricTransport, pressure *mesh.FabricRoutePressureTracker) resourceSample { + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + now := time.Now().UTC() + sample := resourceSample{ + ObservedAt: now, + ElapsedMs: now.Sub(started).Milliseconds(), + Goroutines: runtime.NumGoroutine(), + HeapAllocBytes: mem.HeapAlloc, + HeapInuseBytes: mem.HeapInuse, + HeapObjects: mem.HeapObjects, + OpenFDs: countOpenFDs(), + NumGC: mem.NumGC, + } + if transport != nil { + snapshot := transport.Snapshot() + sample.ActiveStreams = snapshot.ActiveStreams + } + if pressure != nil { + snapshot := pressure.SnapshotPressure() + sample.ActiveRoutes = len(snapshot.Active) + sample.ActiveRouteLoad = snapshot.ActiveTotal + } + return sample +} + +func countOpenFDs() int { + entries, err := os.ReadDir("/proc/self/fd") + if err != nil { + return -1 + } + return len(entries) +} + +func summarizeResourceSamples(samples []resourceSample) resourceSummary { + if len(samples) == 0 { + return resourceSummary{} + } + first := samples[0] + last := samples[len(samples)-1] + summary := resourceSummary{ + SampleCount: len(samples), + GoroutinesStart: first.Goroutines, + GoroutinesEnd: last.Goroutines, + GoroutinesMax: first.Goroutines, + GoroutinesDelta: last.Goroutines - first.Goroutines, + HeapAllocStartBytes: first.HeapAllocBytes, + HeapAllocEndBytes: last.HeapAllocBytes, + HeapAllocMaxBytes: first.HeapAllocBytes, + HeapAllocDeltaBytes: int64(last.HeapAllocBytes) - int64(first.HeapAllocBytes), + HeapObjectsStart: first.HeapObjects, + HeapObjectsEnd: last.HeapObjects, + HeapObjectsMax: first.HeapObjects, + HeapObjectsDelta: int64(last.HeapObjects) - int64(first.HeapObjects), + OpenFDsStart: first.OpenFDs, + OpenFDsEnd: last.OpenFDs, + OpenFDsMax: first.OpenFDs, + OpenFDsDelta: last.OpenFDs - first.OpenFDs, + GCCountDelta: last.NumGC - first.NumGC, + ActiveStreamsMax: first.ActiveStreams, + ActiveRouteLoadMax: first.ActiveRouteLoad, + } + for _, sample := range samples[1:] { + if sample.Goroutines > summary.GoroutinesMax { + summary.GoroutinesMax = sample.Goroutines + } + if sample.HeapAllocBytes > summary.HeapAllocMaxBytes { + summary.HeapAllocMaxBytes = sample.HeapAllocBytes + } + if sample.HeapObjects > summary.HeapObjectsMax { + summary.HeapObjectsMax = sample.HeapObjects + } + if sample.OpenFDs > summary.OpenFDsMax { + summary.OpenFDsMax = sample.OpenFDs + } + if sample.ActiveStreams > summary.ActiveStreamsMax { + summary.ActiveStreamsMax = sample.ActiveStreams + } + if sample.ActiveRouteLoad > summary.ActiveRouteLoadMax { + summary.ActiveRouteLoadMax = sample.ActiveRouteLoad + } + } + return summary +} + +func runClientSoakStreams(ctx context.Context, transport *mesh.QUICFabricTransport, pressure *mesh.FabricRoutePressureTracker, health *targetHealthTracker, cfg loadtestConfig, payload []byte, totalBytes *atomic.Int64, totalFrames *atomic.Int64, totalAcks *atomic.Int64, totalFailovers *atomic.Int64, totalMigrations *atomic.Int64) *streamResultCollector { + var wg sync.WaitGroup + var nextIndex atomic.Int64 + results := newStreamResultCollector() + var stopAt time.Time + if cfg.Duration > 0 { + stopAt = time.Now().Add(cfg.Duration) + } + for worker := 0; worker < cfg.Concurrency; worker++ { + wg.Add(1) + go func() { + defer wg.Done() + for { + select { + case <-ctx.Done(): + return + default: + } + if !stopAt.IsZero() && !time.Now().Before(stopAt) { + return + } + index := int(nextIndex.Add(1) - 1) + result := runStream(ctx, transport, pressure, health, cfg, index, payload) + if ctx.Err() != nil && result.Error != "" && strings.Contains(result.Error, "context deadline exceeded") && result.BytesSent == 0 { + return + } + addStreamTotals(result, totalBytes, totalFrames, totalAcks, totalFailovers, totalMigrations) + results.Add(result) + } + }() + } + wg.Wait() + return results +} + +func addStreamTotals(result streamResult, totalBytes *atomic.Int64, totalFrames *atomic.Int64, totalAcks *atomic.Int64, totalFailovers *atomic.Int64, totalMigrations *atomic.Int64) { + totalBytes.Add(result.BytesSent) + totalFrames.Add(result.FramesSent) + totalAcks.Add(result.AcksReceived) + totalFailovers.Add(int64(result.FailoverCount)) + totalMigrations.Add(int64(result.MigrationCount)) +} + +func runStream(ctx context.Context, transport *mesh.QUICFabricTransport, pressure *mesh.FabricRoutePressureTracker, health *targetHealthTracker, cfg loadtestConfig, index int, payload []byte) streamResult { + if cfg.StreamTimeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, cfg.StreamTimeout) + defer cancel() + } + initialTargetIndex, spreadOffset := loadtestSpreadStart(index, len(cfg.Targets)) + initialTargetIndex = loadtestPreferredTargetIndex(cfg.Targets, initialTargetIndex, spreadOffset, health, -1) + target := cfg.Targets[initialTargetIndex] + logicalStreamID := loadtestLogicalStreamID(index) + trafficClass := loadtestTrafficClass(cfg, index) + bytesPerStream := cfg.BytesPerStream + if trafficClass == fabricproto.TrafficClassControl { + bytesPerStream = cfg.ControlBytes + } + result := streamResult{ + StreamIndex: index, + InitialTarget: target, + Target: target, + ShortSession: cfg.ShortSessions, + TrafficClass: loadtestTrafficClassName(trafficClass), + LogicalStream: logicalStreamID, + } + targetCount := 1 + if cfg.PoolFailover { + targetCount = len(cfg.Targets) + } + var lastErr string + remaining := bytesPerStream + lastTargetIndex := -1 + for attempt := 0; attempt < targetCount; attempt++ { + targetIndex := initialTargetIndex + if attempt > 0 { + targetIndex = loadtestSpreadUsableTargetIndex(cfg.Targets, spreadOffset+attempt-1, health, lastTargetIndex) + } + target = cfg.Targets[targetIndex] + lastTargetIndex = targetIndex + routeID := loadtestRouteID(targetIndex, target) + routeMode := loadtestRouteMode(cfg, targetIndex) + attemptResult := runStreamAttempt(ctx, transport, pressure, cfg, index, targetIndex, target, routeID, logicalStreamID, trafficClass, payload, remaining) + attemptResult.RouteMode = routeMode + result.TargetAttempts = append(result.TargetAttempts, target) + result.RouteAttempts = append(result.RouteAttempts, routeID) + result.BytesSent += attemptResult.BytesSent + result.FramesSent += attemptResult.FramesSent + result.AcksReceived += attemptResult.AcksReceived + result.AbandonedFrames += attemptResult.AbandonedFrames + result.AckIntegrityErrors += attemptResult.AckIntegrityErrors + result.SetupMs += attemptResult.SetupMs + result.DurationMs += attemptResult.DurationMs + if attempt > 0 && attemptResult.SetupMs > result.RerouteLatencyMs { + result.RerouteLatencyMs = attemptResult.SetupMs + } + if attemptResult.MaxAckMs > result.MaxAckMs { + result.MaxAckMs = attemptResult.MaxAckMs + } + if attemptResult.Degraded { + result.Degraded = true + if health != nil { + health.MarkDegraded(target, "slow_ack", cfg.TargetQuarantine) + } + } + if attemptResult.Error != "" && health != nil && shouldQuarantineTarget(attemptResult.Error) { + ttl := cfg.TargetQuarantine + if targetIndex == cfg.FailTarget && cfg.FailureQuarantine > ttl { + ttl = cfg.FailureQuarantine + } + health.MarkDegraded(target, attemptResult.Error, ttl) + } + remaining -= attemptResult.BytesSent + if attemptResult.Error == "slow_ack_migration" && cfg.MigrateSlow && remaining > 0 && attempt+1 < targetCount { + result.MigrationCount++ + result.Error = "" + continue + } + if attemptResult.Degraded && cfg.MigrateSlow && attempt+1 < targetCount && trafficClass == fabricproto.TrafficClassControl { + remaining = bytesPerStream + result.BytesSent -= attemptResult.BytesSent + result.FramesSent -= attemptResult.FramesSent + result.AcksReceived -= attemptResult.AcksReceived + result.AbandonedFrames -= attemptResult.AbandonedFrames + result.AckIntegrityErrors -= attemptResult.AckIntegrityErrors + result.MaxAckMs = 0 + result.MigrationCount++ + result.Error = "" + continue + } + if attemptResult.Error == "" { + result.Target = target + result.RouteID = routeID + result.RouteMode = routeMode + result.FailoverCount = attempt + result.Error = "" + return result + } + lastErr = attemptResult.Error + result.Error = lastErr + } + result.FailoverCount = len(result.TargetAttempts) - 1 + return result +} + +func loadtestSpreadStart(streamIndex int, targetCount int) (int, int) { + if targetCount <= 0 { + return 0, 0 + } + if streamIndex < 0 { + streamIndex = 0 + } + return streamIndex % targetCount, streamIndex / targetCount +} + +func loadtestLogicalStreamID(streamIndex int) uint64 { + if streamIndex < 0 { + streamIndex = 0 + } + return uint64(streamIndex) + 10_000 +} + +func loadtestPreferredTargetIndex(targets []string, preferred int, spread int, health *targetHealthTracker, exclude int) int { + if len(targets) == 0 { + return 0 + } + placementOrdinal := spread*len(targets) + preferred + if index, ok := loadtestLatencyAwareCandidateIndex(targets, placementOrdinal, health, exclude); ok { + return index + } + if preferred >= 0 && preferred < len(targets) && preferred != exclude && (health == nil || !health.IsDegraded(targets[preferred])) { + return preferred + } + return loadtestSpreadUsableTargetIndex(targets, spread, health, exclude) +} + +func loadtestSpreadUsableTargetIndex(targets []string, spread int, health *targetHealthTracker, exclude int) int { + if len(targets) == 0 { + return 0 + } + if index, ok := loadtestLatencyAwareCandidateIndex(targets, spread, health, exclude); ok { + return index + } + usable := loadtestUsableTargetIndexes(targets, health, exclude) + if len(usable) == 0 { + return nextUsableTargetIndex(targets, spread, health, exclude) + } + if spread < 0 { + spread = -spread + } + return usable[spread%len(usable)] +} + +func loadtestLatencyAwareCandidateIndex(targets []string, spread int, health *targetHealthTracker, exclude int) (int, bool) { + usable := loadtestUsableTargetIndexes(targets, health, exclude) + if len(usable) == 0 { + return 0, false + } + if index, ok := health.latencyAwareTargetIndex(targets, usable, spread); ok { + return index, true + } + return 0, false +} + +func loadtestUsableTargetIndexes(targets []string, health *targetHealthTracker, exclude int) []int { + var usable []int + for index, target := range targets { + if index == exclude { + continue + } + if health != nil && health.IsDegraded(target) { + continue + } + usable = append(usable, index) + } + return usable +} + +func runStreamAttempt(ctx context.Context, transport *mesh.QUICFabricTransport, pressure *mesh.FabricRoutePressureTracker, cfg loadtestConfig, streamIndex int, targetIndex int, target string, routeID string, logicalStreamID uint64, trafficClass fabricproto.TrafficClass, payload []byte, bytesToSend int64) streamResult { + result := streamResult{ + StreamIndex: streamIndex, + InitialTarget: target, + Target: target, + RouteID: routeID, + RouteMode: loadtestRouteMode(cfg, targetIndex), + ShortSession: cfg.ShortSessions, + TrafficClass: loadtestTrafficClassName(trafficClass), + LogicalStream: logicalStreamID, + } + started := time.Now() + releaseRoute := pressure.Acquire(routeID) + defer releaseRoute() + session, err := transport.Connect(ctx, mesh.FabricTransportTarget{ + PeerID: fmt.Sprintf("target-%d", targetIndex), + Endpoint: target, + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + NextProtos: []string{"rap-fabric-data-session-v1"}, + }, + Timeout: 15 * time.Second, + InboundBuffer: 128, + ErrorBuffer: 8, + }) + if err != nil { + result.Error = err.Error() + return result + } + defer session.Close() + result.SetupMs = time.Since(started).Milliseconds() + if err := session.Send(ctx, fabricproto.Frame{Type: fabricproto.FrameOpenStream, TrafficClass: trafficClass, StreamID: logicalStreamID}); err != nil { + result.Error = err.Error() + return result + } + defer func() { + _ = session.Send(context.Background(), fabricproto.Frame{Type: fabricproto.FrameCloseStream, TrafficClass: trafficClass, StreamID: logicalStreamID}) + }() + var sent int64 + var seq uint64 + framePayload := make([]byte, len(payload)) + for sent < bytesToSend { + select { + case <-ctx.Done(): + result.Error = ctx.Err().Error() + result.DurationMs = time.Since(started).Milliseconds() + return result + default: + } + chunkSize := len(framePayload) + if remaining := bytesToSend - sent; remaining < int64(chunkSize) { + chunkSize = int(remaining) + } + seq++ + chunk := framePayload[:chunkSize] + fillLoadtestPayload(chunk, streamIndex, logicalStreamID, seq, sent) + expectedAckPayload := fabricproto.DataAckPayload(chunk) + if err := session.Send(ctx, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: trafficClass, + StreamID: logicalStreamID, + Sequence: seq, + Payload: chunk, + }); err != nil { + result.Error = err.Error() + break + } + result.FramesSent++ + if ok, ackMs, integrityOK := waitForAck(ctx, session, cfg.AckTimeout, logicalStreamID, seq, expectedAckPayload); ok { + result.AcksReceived++ + sent += int64(len(chunk)) + result.BytesSent = sent + if !integrityOK { + result.AckIntegrityErrors++ + result.Error = "ack payload checksum mismatch" + break + } + if ackMs > result.MaxAckMs { + result.MaxAckMs = ackMs + } + if cfg.MigrateSlow && cfg.MaxAckMs > 0 && ackMs > cfg.MaxAckMs { + result.Degraded = true + if sent < bytesToSend { + result.Error = "slow_ack_migration" + break + } + } + } else { + if result.FramesSent > result.AcksReceived { + result.AbandonedFrames += result.FramesSent - result.AcksReceived + } + result.Error = "ack timeout or session closed" + break + } + } + result.DurationMs = time.Since(started).Milliseconds() + return result +} + +func loadtestTrafficClass(cfg loadtestConfig, streamIndex int) fabricproto.TrafficClass { + if cfg.ControlEvery > 0 && streamIndex%cfg.ControlEvery == 0 { + return fabricproto.TrafficClassControl + } + return fabricproto.TrafficClassBulk +} + +func loadtestTrafficClassName(trafficClass fabricproto.TrafficClass) string { + switch trafficClass { + case fabricproto.TrafficClassControl: + return "control" + case fabricproto.TrafficClassInteractive: + return "interactive" + case fabricproto.TrafficClassReliable: + return "reliable" + case fabricproto.TrafficClassBulk: + return "bulk" + default: + return fmt.Sprintf("traffic_class_%d", trafficClass) + } +} + +func newTargetHealthTracker() *targetHealthTracker { + return &targetHealthTracker{ + degraded: map[string]string{}, + degradedUntil: map[string]time.Time{}, + observed: map[string]string{}, + rttMs: map[string]int64{}, + } +} + +func (t *targetHealthTracker) RecordProbes(probes []targetProbeResult) { + if t == nil || len(probes) == 0 { + return + } + t.mu.Lock() + defer t.mu.Unlock() + if t.rttMs == nil { + t.rttMs = map[string]int64{} + } + for _, probe := range probes { + target := strings.TrimSpace(probe.Target) + if target == "" || !probe.Usable || probe.RTTMs <= 0 { + continue + } + t.rttMs[target] = probe.RTTMs + } +} + +func (t *targetHealthTracker) MarkDegraded(target string, reason string, ttl time.Duration) { + target = strings.TrimSpace(target) + if t == nil || target == "" { + return + } + t.mu.Lock() + if t.degraded == nil { + t.degraded = map[string]string{} + } + if t.degradedUntil == nil { + t.degradedUntil = map[string]time.Time{} + } + if t.observed == nil { + t.observed = map[string]string{} + } + t.degraded[target] = strings.TrimSpace(reason) + t.observed[target] = strings.TrimSpace(reason) + if ttl > 0 { + t.degradedUntil[target] = time.Now().Add(ttl) + } else { + delete(t.degradedUntil, target) + } + t.mu.Unlock() +} + +func (t *targetHealthTracker) IsDegraded(target string) bool { + if t == nil { + return false + } + t.mu.Lock() + defer t.mu.Unlock() + target = strings.TrimSpace(target) + if until, ok := t.degradedUntil[target]; ok && !until.IsZero() && time.Now().After(until) { + delete(t.degraded, target) + delete(t.degradedUntil, target) + return false + } + _, ok := t.degraded[target] + return ok +} + +func (t *targetHealthTracker) Snapshot() map[string]string { + if t == nil { + return nil + } + t.mu.Lock() + defer t.mu.Unlock() + source := t.observed + if len(source) == 0 { + source = t.degraded + } + out := make(map[string]string, len(source)) + for target, reason := range source { + out[target] = reason + } + return out +} + +func (t *targetHealthTracker) latencyAwareTargetIndex(targets []string, candidates []int, spread int) (int, bool) { + if t == nil || len(candidates) == 0 { + return 0, false + } + t.mu.Lock() + defer t.mu.Unlock() + if len(t.rttMs) == 0 { + return 0, false + } + minRTT := int64(0) + maxRTT := int64(0) + rtts := make(map[int]int64, len(candidates)) + for _, index := range candidates { + rtt := t.rttMs[strings.TrimSpace(targets[index])] + if rtt <= 0 { + continue + } + rtts[index] = rtt + if minRTT == 0 || rtt < minRTT { + minRTT = rtt + } + if rtt > maxRTT { + maxRTT = rtt + } + } + if minRTT <= 0 || maxRTT < minRTT*4 { + return 0, false + } + totalWeight := 0 + weights := make(map[int]int, len(candidates)) + for _, index := range candidates { + rtt := rtts[index] + weight := 1 + if rtt > 0 { + weight = int((maxRTT + rtt - 1) / rtt) + if weight < 1 { + weight = 1 + } + if weight > 32 { + weight = 32 + } + } + weights[index] = weight + totalWeight += weight + } + if totalWeight <= 0 { + return 0, false + } + if spread < 0 { + spread = -spread + } + slot := spread % totalWeight + for _, index := range candidates { + weight := weights[index] + if slot < weight { + return index, true + } + slot -= weight + } + return candidates[len(candidates)-1], true +} + +func shouldQuarantineTarget(reason string) bool { + reason = strings.ToLower(strings.TrimSpace(reason)) + if reason == "" { + return false + } + if reason == context.DeadlineExceeded.Error() { + return false + } + return strings.Contains(reason, "timeout") || + strings.Contains(reason, "deadline") || + strings.Contains(reason, "connection refused") || + strings.Contains(reason, "connection reset") || + strings.Contains(reason, "no route") || + strings.Contains(reason, "session closed") || + strings.Contains(reason, "application error") +} + +func nextUsableTargetIndex(targets []string, start int, health *targetHealthTracker, exclude int) int { + if len(targets) == 0 { + return 0 + } + for offset := 0; offset < len(targets); offset++ { + index := (start + offset) % len(targets) + if index < 0 { + index += len(targets) + } + if index == exclude { + continue + } + if health == nil || !health.IsDegraded(targets[index]) { + return index + } + } + index := start % len(targets) + if index < 0 { + index += len(targets) + } + return index +} + +func loadtestRouteID(targetIndex int, target string) string { + target = strings.TrimSpace(target) + if target == "" { + return fmt.Sprintf("target-%d", targetIndex) + } + sum := sha256.Sum256([]byte(target)) + return fmt.Sprintf("target-%d-%s", targetIndex, hex.EncodeToString(sum[:4])) +} + +func loadtestRouteMode(cfg loadtestConfig, targetIndex int) string { + switch strings.ToLower(strings.TrimSpace(cfg.TopologyProfile)) { + case "nat-lan-relay", "mixed-public-nat-lan-relay": + switch targetIndex % 4 { + case 0: + return string(mesh.FabricRouteLAN) + case 1: + return string(mesh.FabricRouteICE) + case 2: + return string(mesh.FabricRouteReverse) + default: + return string(mesh.FabricRouteRelay) + } + case "public": + return string(mesh.FabricRouteDirect) + default: + return string(mesh.FabricRouteDirect) + } +} + +func waitForAck(ctx context.Context, session mesh.FabricTransportSession, timeout time.Duration, streamID uint64, sequence uint64, expectedPayload []byte) (bool, int64, bool) { + started := time.Now() + var timeoutC <-chan time.Time + var timer *time.Timer + if timeout > 0 { + timer = time.NewTimer(timeout) + defer timer.Stop() + timeoutC = timer.C + } + for { + select { + case frame, ok := <-session.Frames(): + if !ok { + return false, 0, false + } + if frame.Type == fabricproto.FrameAck && frame.StreamID == streamID && frame.Sequence == sequence { + return true, time.Since(started).Milliseconds(), bytes.Equal(frame.Payload, expectedPayload) + } + case <-session.Errors(): + return false, 0, false + case <-timeoutC: + return false, time.Since(started).Milliseconds(), false + case <-ctx.Done(): + return false, 0, false + } + } +} + +func fillLoadtestPayload(dst []byte, streamIndex int, logicalStreamID uint64, sequence uint64, offset int64) { + seed := uint64(streamIndex+1)*0x9e3779b185ebca87 ^ logicalStreamID*0xc2b2ae3d27d4eb4f ^ sequence*0x165667b19e3779f9 ^ uint64(offset) + for i := range dst { + x := seed + uint64(i)*0x27d4eb2f165667c5 + x ^= x >> 33 + x *= 0xff51afd7ed558ccd + x ^= x >> 33 + x *= 0xc4ceb9fe1a85ec53 + x ^= x >> 33 + dst[i] = byte(x) + } +} + +func probeAndFilterTargets(ctx context.Context, transport *mesh.QUICFabricTransport, cfg loadtestConfig) ([]targetProbeResult, []string, []string) { + probes := make([]targetProbeResult, 0, len(cfg.Targets)) + filtered := make([]string, 0, len(cfg.Targets)) + excluded := []string{} + for index, target := range cfg.Targets { + probe := probeTarget(ctx, transport, index, target) + if probe.Error == "" && (cfg.MaxTargetRTTMs <= 0 || probe.RTTMs <= cfg.MaxTargetRTTMs) { + probe.Usable = true + filtered = append(filtered, target) + } else { + excluded = append(excluded, target) + } + probes = append(probes, probe) + } + return probes, excluded, filtered +} + +func probeTarget(ctx context.Context, transport *mesh.QUICFabricTransport, index int, target string) targetProbeResult { + probeCtx, cancel := context.WithTimeout(ctx, 5*time.Second) + defer cancel() + started := time.Now() + session, err := transport.Connect(probeCtx, mesh.FabricTransportTarget{ + PeerID: fmt.Sprintf("target-%d", index), + Endpoint: target, + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + NextProtos: []string{"rap-fabric-data-session-v1"}, + }, + Timeout: 5 * time.Second, + InboundBuffer: 4, + ErrorBuffer: 4, + }) + if err != nil { + return targetProbeResult{Target: target, Error: err.Error()} + } + defer session.Close() + if err := session.Send(probeCtx, fabricproto.Frame{Type: fabricproto.FramePing, Sequence: 1, Payload: []byte("probe")}); err != nil { + return targetProbeResult{Target: target, Error: err.Error()} + } + for { + select { + case frame, ok := <-session.Frames(): + if !ok { + return targetProbeResult{Target: target, Error: "probe session closed"} + } + if frame.Type == fabricproto.FramePong && frame.Sequence == 1 { + return targetProbeResult{Target: target, RTTMs: time.Since(started).Milliseconds()} + } + case err := <-session.Errors(): + if err == nil { + return targetProbeResult{Target: target, Error: "probe session error"} + } + return targetProbeResult{Target: target, Error: err.Error()} + case <-probeCtx.Done(): + return targetProbeResult{Target: target, Error: probeCtx.Err().Error()} + } + } +} + +func newStreamResultCollector() *streamResultCollector { + return &streamResultCollector{ + errors: map[string]int{}, + rerouteCauses: map[string]int{}, + targetBytes: map[string]int64{}, + targetStreams: map[string]int{}, + targetFrames: map[string]int64{}, + targetAcks: map[string]int64{}, + targetMaxAck: map[string]int64{}, + targetFailoverEntrypoint: map[string]int{}, + targetDegradedEvents: map[string]int{}, + targetRouteModes: map[string]map[string]int{}, + targetSetup: map[string][]int64{}, + targetSetupCount: map[string]int{}, + targetDurations: map[string][]int64{}, + targetDurationCount: map[string]int{}, + } +} + +func (c *streamResultCollector) Add(result streamResult) { + if c == nil { + return + } + c.mu.Lock() + defer c.mu.Unlock() + c.total++ + if len(c.streamSamples) < maxStreamResultSamples { + c.streamSamples = append(c.streamSamples, result) + } + if result.Error != "" && len(c.errorSamples) < maxStreamResultSamples { + c.errorSamples = append(c.errorSamples, result) + } + if result.Error == "" { + c.successful++ + c.setup = recordMetric(c.setup, result.SetupMs, c.setupCount) + c.setupCount++ + if result.FailoverCount > 0 || result.MigrationCount > 0 { + c.rerouteLatency = recordMetric(c.rerouteLatency, result.RerouteLatencyMs, c.rerouteLatencyCount) + c.rerouteLatencyCount++ + } + c.durations = recordMetric(c.durations, result.DurationMs, c.durationCount) + c.durationCount++ + c.targetSetup[result.Target] = recordMetric(c.targetSetup[result.Target], result.SetupMs, c.targetSetupCount[result.Target]) + c.targetSetupCount[result.Target]++ + c.targetDurations[result.Target] = recordMetric(c.targetDurations[result.Target], result.DurationMs, c.targetDurationCount[result.Target]) + c.targetDurationCount[result.Target]++ + switch result.TrafficClass { + case "control": + c.controlStreams++ + c.controlAck = recordMetric(c.controlAck, result.MaxAckMs, c.controlAckCount) + c.controlAckCount++ + case "bulk": + c.bulkStreams++ + c.bulkAck = recordMetric(c.bulkAck, result.MaxAckMs, c.bulkAckCount) + c.bulkAckCount++ + } + if result.MaxAckMs > 0 { + c.allAck = recordMetric(c.allAck, result.MaxAckMs, c.allAckCount) + c.allAckCount++ + } + if result.FramesSent-result.AcksReceived > result.AbandonedFrames { + c.ackMismatchedStreams++ + } + c.targetStreams[result.Target]++ + } else { + c.failed++ + c.errors[result.Error]++ + } + if result.AckIntegrityErrors > 0 { + c.ackIntegrityErrors += result.AckIntegrityErrors + } + c.abandonedFrames += result.AbandonedFrames + c.routeAttempts += int64(len(result.RouteAttempts)) + if result.FailoverCount > 0 { + c.rerouteCauses["pool_failover"] += result.FailoverCount + } + if result.MigrationCount > 0 { + c.rerouteCauses["slow_ack_migration"] += result.MigrationCount + } + c.targetBytes[result.Target] += result.BytesSent + if result.RouteMode != "" { + if c.targetRouteModes[result.Target] == nil { + c.targetRouteModes[result.Target] = map[string]int{} + } + c.targetRouteModes[result.Target][result.RouteMode]++ + } + c.targetFrames[result.Target] += result.FramesSent + c.targetAcks[result.Target] += result.AcksReceived + if result.MaxAckMs > c.targetMaxAck[result.Target] { + c.targetMaxAck[result.Target] = result.MaxAckMs + } + if result.FailoverCount > 0 { + c.targetFailoverEntrypoint[result.InitialTarget]++ + } + if result.Degraded { + c.targetDegradedEvents[result.Target]++ + } +} + +func recordMetric(values []int64, value int64, ordinal int) []int64 { + if len(values) < maxMetricSamples { + return append(values, value) + } + values[ordinal%maxMetricSamples] = value + return values +} + +func summarizeResults(cfg loadtestConfig, started time.Time, finished time.Time, results *streamResultCollector) loadtestReport { + report := loadtestReport{ + SchemaVersion: "rap.fabric_loadtest.v1", + StartedAt: started, + FinishedAt: finished, + Config: cfg, + Errors: map[string]int{}, + RerouteCauses: map[string]int{}, + TargetBytes: map[string]int64{}, + TargetStreams: map[string]int{}, + TargetStats: map[string]targetStats{}, + } + if results == nil { + return report + } + results.mu.Lock() + defer results.mu.Unlock() + report.TotalStreams = results.total + report.SuccessfulStreams = results.successful + report.FailedStreams = results.failed + report.Errors = cloneStringIntMap(results.errors) + report.RerouteCauses = cloneStringIntMap(results.rerouteCauses) + report.TargetBytes = cloneStringInt64Map(results.targetBytes) + report.TargetStreams = cloneStringIntMap(results.targetStreams) + report.RouteAttemptsTotal = results.routeAttempts + report.ControlStreams = results.controlStreams + report.BulkStreams = results.bulkStreams + report.AckMismatchedStreams = results.ackMismatchedStreams + report.AckIntegrityErrors = results.ackIntegrityErrors + report.AbandonedFrames = results.abandonedFrames + report.StreamSamples = append(report.StreamSamples, results.streamSamples...) + report.ErrorSamples = append(report.ErrorSamples, results.errorSamples...) + if len(report.Errors) == 0 { + report.Errors = map[string]int{} + } + for target, streams := range report.TargetStreams { + report.TargetStats[target] = targetStats{ + Streams: streams, + BytesSent: report.TargetBytes[target], + FramesSent: results.targetFrames[target], + AcksReceived: results.targetAcks[target], + MaxAckMs: results.targetMaxAck[target], + SetupLatencyP50Ms: percentile(results.targetSetup[target], 50), + SetupLatencyP95Ms: percentile(results.targetSetup[target], 95), + DurationP50Ms: percentile(results.targetDurations[target], 50), + DurationP95Ms: percentile(results.targetDurations[target], 95), + FailoverEntrypoint: results.targetFailoverEntrypoint[target], + DegradedEvents: results.targetDegradedEvents[target], + RouteModes: cloneStringIntMap(results.targetRouteModes[target]), + } + } + report.SetupLatencyP50Ms = percentile(results.setup, 50) + report.SetupLatencyP95Ms = percentile(results.setup, 95) + report.SetupLatencyP99Ms = percentile(results.setup, 99) + report.ChannelOpenP50Ms = report.SetupLatencyP50Ms + report.ChannelOpenP95Ms = report.SetupLatencyP95Ms + report.ChannelOpenP99Ms = report.SetupLatencyP99Ms + report.RerouteLatencyP95Ms = percentile(results.rerouteLatency, 95) + report.RerouteLatencyP99Ms = percentile(results.rerouteLatency, 99) + report.StreamDurationP95Ms = percentile(results.durations, 95) + report.AckP95Ms = percentile(results.allAck, 95) + report.AckP99Ms = percentile(results.allAck, 99) + report.ControlAckP95Ms = percentile(results.controlAck, 95) + report.BulkAckP95Ms = percentile(results.bulkAck, 95) + if len(report.RerouteCauses) == 0 { + report.RerouteCauses = nil + } + return report +} + +func cloneStringIntMap(in map[string]int) map[string]int { + out := make(map[string]int, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func cloneStringInt64Map(in map[string]int64) map[string]int64 { + out := make(map[string]int64, len(in)) + for key, value := range in { + out[key] = value + } + return out +} + +func applyRoutePressureToTargetStats(report *loadtestReport) { + if report == nil || len(report.TargetStats) == 0 || len(report.RoutePressure.MaxActive) == 0 { + return + } + targetMaxActive := map[string]int{} + for index, target := range report.Config.Targets { + routeID := loadtestRouteID(index, target) + if maxActive := report.RoutePressure.MaxActive[routeID]; maxActive > targetMaxActive[target] { + targetMaxActive[target] = maxActive + } + } + if len(targetMaxActive) == 0 { + return + } + for target, stats := range report.TargetStats { + stats.MaxActiveChannels = targetMaxActive[target] + report.TargetStats[target] = stats + } +} + +func verdict(report loadtestReport) (string, []string) { + var reasons []string + if report.FailedStreams > 0 { + reasons = append(reasons, fmt.Sprintf("failed_streams=%d", report.FailedStreams)) + } + if report.Config.MaxSetupP95Ms > 0 && report.SetupLatencyP95Ms > report.Config.MaxSetupP95Ms { + reasons = append(reasons, fmt.Sprintf("setup_p95_ms=%d > %d", report.SetupLatencyP95Ms, report.Config.MaxSetupP95Ms)) + } + if report.Config.MaxSetupP99Ms > 0 && report.SetupLatencyP99Ms > report.Config.MaxSetupP99Ms { + reasons = append(reasons, fmt.Sprintf("setup_p99_ms=%d > %d", report.SetupLatencyP99Ms, report.Config.MaxSetupP99Ms)) + } + if report.Config.MaxRerouteP95Ms > 0 && report.RerouteLatencyP95Ms > report.Config.MaxRerouteP95Ms { + reasons = append(reasons, fmt.Sprintf("reroute_p95_ms=%d > %d", report.RerouteLatencyP95Ms, report.Config.MaxRerouteP95Ms)) + } + if report.Config.MaxRerouteP99Ms > 0 && report.RerouteLatencyP99Ms > report.Config.MaxRerouteP99Ms { + reasons = append(reasons, fmt.Sprintf("reroute_p99_ms=%d > %d", report.RerouteLatencyP99Ms, report.Config.MaxRerouteP99Ms)) + } + if report.BytesSent <= 0 { + reasons = append(reasons, "no_bytes_sent") + } + if report.Config.MinThroughputMbps > 0 { + minThroughputBps := report.Config.MinThroughputMbps * 1000 * 1000 + if report.ThroughputBps < minThroughputBps { + reasons = append(reasons, fmt.Sprintf("throughput_bps=%d < %d", report.ThroughputBps, minThroughputBps)) + } + } + if report.Config.MinChannelChurn > 0 && report.ChannelChurnPerSec < report.Config.MinChannelChurn { + reasons = append(reasons, fmt.Sprintf("channel_churn_per_sec=%d < %d", report.ChannelChurnPerSec, report.Config.MinChannelChurn)) + } + if report.AckMismatchedStreams > 0 { + reasons = append(reasons, fmt.Sprintf("ack_mismatched_streams=%d", report.AckMismatchedStreams)) + } + if report.AckIntegrityErrors > 0 { + reasons = append(reasons, fmt.Sprintf("ack_integrity_errors=%d", report.AckIntegrityErrors)) + } + if report.RoutePressure.ActiveTotal != 0 || len(report.RoutePressure.Active) != 0 { + reasons = append(reasons, fmt.Sprintf("route_pressure_active_leak=%d", report.RoutePressure.ActiveTotal)) + } + if report.RoutePressure.AcquiredTotal != report.RoutePressure.ReleasedTotal { + reasons = append(reasons, fmt.Sprintf("route_pressure_acquire_release_mismatch=%d/%d", report.RoutePressure.AcquiredTotal, report.RoutePressure.ReleasedTotal)) + } + if report.ChannelLeaks != 0 { + reasons = append(reasons, fmt.Sprintf("channel_leaks=%d", report.ChannelLeaks)) + } + if report.ChannelOpens != report.ChannelCloses { + reasons = append(reasons, fmt.Sprintf("channel_open_close_mismatch=%d/%d", report.ChannelOpens, report.ChannelCloses)) + } + routeAcquiresAndProbes := report.RoutePressure.AcquiredTotal + uint64(len(report.TargetProbes)) + if report.RoutePressure.AcquiredTotal > 0 && report.ChannelOpens > routeAcquiresAndProbes { + reasons = append(reasons, fmt.Sprintf("channel_opens_exceed_route_acquires_and_probes=%d/%d", report.ChannelOpens, routeAcquiresAndProbes)) + } + if report.SuccessfulStreams > 0 && report.RoutePressure.AcquiredTotal < uint64(report.SuccessfulStreams) { + reasons = append(reasons, fmt.Sprintf("route_pressure_missing_acquires=%d < successful_streams=%d", report.RoutePressure.AcquiredTotal, report.SuccessfulStreams)) + } + if report.Config.Concurrency > 0 && report.RoutePressure.MaxActiveTotal > report.Config.Concurrency { + reasons = append(reasons, fmt.Sprintf("route_pressure_max_active_total=%d > concurrency=%d", report.RoutePressure.MaxActiveTotal, report.Config.Concurrency)) + } + if report.Config.MaxAckP95Ms > 0 && report.AckP95Ms > report.Config.MaxAckP95Ms { + reasons = append(reasons, fmt.Sprintf("ack_p95_ms=%d > %d", report.AckP95Ms, report.Config.MaxAckP95Ms)) + } + if report.Config.MaxAckP99Ms > 0 && report.AckP99Ms > report.Config.MaxAckP99Ms { + reasons = append(reasons, fmt.Sprintf("ack_p99_ms=%d > %d", report.AckP99Ms, report.Config.MaxAckP99Ms)) + } + if report.ControlStreams > 0 && report.Config.MaxControlP95 > 0 && report.ControlAckP95Ms > report.Config.MaxControlP95 { + reasons = append(reasons, fmt.Sprintf("control_ack_p95_ms=%d > %d", report.ControlAckP95Ms, report.Config.MaxControlP95)) + } + if report.Config.MaxGoroutineDelta > 0 && report.ResourceSummary.GoroutinesDelta > report.Config.MaxGoroutineDelta { + reasons = append(reasons, fmt.Sprintf("goroutine_delta=%d > %d", report.ResourceSummary.GoroutinesDelta, report.Config.MaxGoroutineDelta)) + } + if report.Config.MaxHeapDeltaMB > 0 { + maxHeapDeltaBytes := report.Config.MaxHeapDeltaMB * 1024 * 1024 + if report.ResourceSummary.HeapAllocDeltaBytes > maxHeapDeltaBytes { + reasons = append(reasons, fmt.Sprintf("heap_alloc_delta_bytes=%d > %d", report.ResourceSummary.HeapAllocDeltaBytes, maxHeapDeltaBytes)) + } + } + if report.Config.MaxOpenFDDelta > 0 && report.ResourceSummary.OpenFDsStart >= 0 && report.ResourceSummary.OpenFDsDelta > report.Config.MaxOpenFDDelta { + reasons = append(reasons, fmt.Sprintf("open_fd_delta=%d > %d", report.ResourceSummary.OpenFDsDelta, report.Config.MaxOpenFDDelta)) + } + if report.Config.MaxOpenFDs > 0 && report.ResourceSummary.OpenFDsMax >= 0 && report.ResourceSummary.OpenFDsMax > report.Config.MaxOpenFDs { + reasons = append(reasons, fmt.Sprintf("open_fds_max=%d > %d", report.ResourceSummary.OpenFDsMax, report.Config.MaxOpenFDs)) + } + if report.Config.ImpairTarget >= 0 && report.Config.MigrateSlow && report.Config.MaxAckMs > 0 && len(report.DegradedTargets) == 0 { + reasons = append(reasons, "expected_degraded_target_not_observed") + } + if len(report.DegradedTargets) > 0 && report.MigrationEvents == 0 && report.Config.MigrateSlow { + reasons = append(reasons, "degraded_targets_without_migration") + } + reasons = append(reasons, targetDistributionVerdictReasons(report)...) + reasons = append(reasons, targetByteDistributionVerdictReasons(report)...) + reasons = append(reasons, targetAckVerdictReasons(report)...) + reasons = append(reasons, routePressureDistributionVerdictReasons(report)...) + reasons = append(reasons, targetEndpointPolicyVerdictReasons(report)...) + reasons = append(reasons, legacyRouteModeVerdictReasons(report)...) + reasons = append(reasons, routeModeCoverageVerdictReasons(report)...) + if len(reasons) > 0 { + return "fail", reasons + } + return "pass", nil +} + +func targetDistributionVerdictReasons(report loadtestReport) []string { + targets := loadBalancedVerdictTargets(report) + if len(targets) <= 1 || report.SuccessfulStreams < len(targets) { + return nil + } + if report.Config.ImpairTarget >= 0 { + return nil + } + minStreams := report.SuccessfulStreams + maxStreams := 0 + usedTargets := 0 + for _, target := range targets { + streams := report.TargetStreams[target] + if streams > 0 { + usedTargets++ + } + if streams < minStreams { + minStreams = streams + } + if streams > maxStreams { + maxStreams = streams + } + } + if usedTargets < len(targets) { + return []string{fmt.Sprintf("target_distribution_collapsed=%d/%d_targets_used", usedTargets, len(targets))} + } + if loadtestProbeRTTHeterogeneous(report.TargetProbes) { + return nil + } + allowedSkew := report.Config.Concurrency + if quarter := report.SuccessfulStreams / 4; quarter > allowedSkew { + allowedSkew = quarter + } + if allowedSkew < 1 { + allowedSkew = 1 + } + if maxStreams-minStreams > allowedSkew { + return []string{fmt.Sprintf("target_distribution_skew=max_%d_min_%d_allowed_%d", maxStreams, minStreams, allowedSkew)} + } + return nil +} + +func targetByteDistributionVerdictReasons(report loadtestReport) []string { + targets := loadBalancedVerdictTargets(report) + if len(targets) <= 1 || report.SuccessfulStreams < len(targets) || report.BytesSent <= 0 { + return nil + } + if report.Config.ImpairTarget >= 0 { + return nil + } + minBytes := report.BytesSent + maxBytes := int64(0) + usedTargets := 0 + for _, target := range targets { + bytesSent := report.TargetBytes[target] + if bytesSent > 0 { + usedTargets++ + } + if bytesSent < minBytes { + minBytes = bytesSent + } + if bytesSent > maxBytes { + maxBytes = bytesSent + } + } + if usedTargets < len(targets) { + return nil + } + if loadtestProbeRTTHeterogeneous(report.TargetProbes) { + return nil + } + avgBytes := report.BytesSent / int64(len(targets)) + allowedSkew := avgBytes / 4 + if concurrencyBudget := int64(report.Config.Concurrency) * report.Config.BytesPerStream; concurrencyBudget > allowedSkew { + allowedSkew = concurrencyBudget + } + if allowedSkew < 1 { + allowedSkew = 1 + } + if maxBytes-minBytes > allowedSkew { + return []string{fmt.Sprintf("target_byte_distribution_skew=max_%d_min_%d_allowed_%d", maxBytes, minBytes, allowedSkew)} + } + return nil +} + +func targetAckVerdictReasons(report loadtestReport) []string { + if report.Config.MaxTargetAckMs <= 0 || len(report.TargetStats) == 0 { + return nil + } + if report.Config.ImpairTarget >= 0 { + return nil + } + var reasons []string + for _, target := range loadBalancedVerdictTargets(report) { + stats, ok := report.TargetStats[target] + if !ok || stats.Streams == 0 { + continue + } + if stats.MaxAckMs > report.Config.MaxTargetAckMs { + reasons = append(reasons, fmt.Sprintf("target_ack_ms=%s:%d>%d", target, stats.MaxAckMs, report.Config.MaxTargetAckMs)) + } + } + return reasons +} + +func routePressureDistributionVerdictReasons(report loadtestReport) []string { + targets := loadBalancedVerdictTargets(report) + if len(targets) <= 1 || report.Config.Concurrency <= 1 || report.RoutePressure.MaxActiveTotal <= 0 { + return nil + } + if report.Config.ImpairTarget >= 0 { + return nil + } + minActive := report.Config.Concurrency + maxActive := 0 + usedTargets := 0 + for _, target := range targets { + index := loadtestTargetIndex(report.Config.Targets, target) + routeID := loadtestRouteID(index, target) + active := report.RoutePressure.MaxActive[routeID] + if active > 0 { + usedTargets++ + } + if active < minActive { + minActive = active + } + if active > maxActive { + maxActive = active + } + } + if usedTargets < len(targets) { + return []string{fmt.Sprintf("route_pressure_distribution_collapsed=%d/%d_targets_used", usedTargets, len(targets))} + } + if loadtestProbeRTTHeterogeneous(report.TargetProbes) { + return nil + } + allowedSkew := report.Config.Concurrency / 2 + if allowedSkew < 1 { + allowedSkew = 1 + } + if maxActive-minActive > allowedSkew { + return []string{fmt.Sprintf("route_pressure_distribution_skew=max_%d_min_%d_allowed_%d", maxActive, minActive, allowedSkew)} + } + return nil +} + +func loadtestProbeRTTHeterogeneous(probes []targetProbeResult) bool { + minRTT := int64(0) + maxRTT := int64(0) + for _, probe := range probes { + if !probe.Usable || probe.RTTMs <= 0 { + continue + } + if minRTT == 0 || probe.RTTMs < minRTT { + minRTT = probe.RTTMs + } + if probe.RTTMs > maxRTT { + maxRTT = probe.RTTMs + } + } + return minRTT > 0 && maxRTT >= minRTT*4 +} + +func loadBalancedVerdictTargets(report loadtestReport) []string { + excluded := map[string]struct{}{} + for _, target := range report.ExcludedTargets { + excluded[strings.TrimSpace(target)] = struct{}{} + } + targets := make([]string, 0, len(report.Config.Targets)) + for index, target := range report.Config.Targets { + target = strings.TrimSpace(target) + if target == "" { + continue + } + if index == report.Config.FailTarget { + continue + } + if _, skip := excluded[target]; skip { + continue + } + targets = append(targets, target) + } + return targets +} + +func loadtestTargetIndex(targets []string, target string) int { + for index, candidate := range targets { + if strings.TrimSpace(candidate) == target { + return index + } + } + return -1 +} + +func targetEndpointPolicyVerdictReasons(report loadtestReport) []string { + if len(report.Config.Targets) == 0 { + return nil + } + var invalid []string + for _, target := range report.Config.Targets { + trimmed := strings.TrimSpace(target) + normalized := strings.ToLower(trimmed) + if normalized == "" { + invalid = append(invalid, "") + continue + } + if !strings.HasPrefix(normalized, "quic://") { + invalid = append(invalid, trimmed) + } + } + if len(invalid) == 0 { + return nil + } + sort.Strings(invalid) + return []string{fmt.Sprintf("non_quic_targets=%s", strings.Join(invalid, ","))} +} + +func legacyRouteModeVerdictReasons(report loadtestReport) []string { + if len(report.TargetStats) == 0 { + return nil + } + legacyModes := map[string]struct{}{ + "relay": {}, + "outbound_reverse": {}, + "websocket": {}, + "ws": {}, + "wss": {}, + "direct_http": {}, + "direct_https": {}, + "direct_tcp_tls": {}, + } + found := map[string]int{} + for _, stats := range report.TargetStats { + for mode, count := range stats.RouteModes { + mode = strings.ToLower(strings.TrimSpace(mode)) + if _, legacy := legacyModes[mode]; legacy && count > 0 { + found[mode] += count + } + } + } + if len(found) == 0 { + return nil + } + modes := make([]string, 0, len(found)) + for mode, count := range found { + modes = append(modes, fmt.Sprintf("%s:%d", mode, count)) + } + sort.Strings(modes) + return []string{fmt.Sprintf("legacy_route_modes_observed=%s", strings.Join(modes, ","))} +} + +func routeModeCoverageVerdictReasons(report loadtestReport) []string { + profile := strings.ToLower(strings.TrimSpace(report.Config.TopologyProfile)) + if profile != "mixed-public-nat-lan-relay" && profile != "nat-lan-relay" { + return nil + } + if len(report.Config.Targets) < 4 || report.SuccessfulStreams < len(report.Config.Targets) { + return nil + } + if report.Config.FailTarget >= 0 || report.Config.ImpairTarget >= 0 || len(report.ExcludedTargets) > 0 { + return nil + } + observed := map[string]int{} + for _, stats := range report.TargetStats { + for mode, count := range stats.RouteModes { + observed[mode] += count + } + } + required := []string{ + string(mesh.FabricRouteLAN), + string(mesh.FabricRouteICE), + string(mesh.FabricRouteReverse), + string(mesh.FabricRouteRelay), + } + var missing []string + for _, mode := range required { + if observed[mode] <= 0 { + missing = append(missing, mode) + } + } + if len(missing) > 0 { + return []string{fmt.Sprintf("route_mode_coverage_missing=%s", strings.Join(missing, ","))} + } + return nil +} + +func percentile(values []int64, p int) int64 { + if len(values) == 0 { + return 0 + } + values = append([]int64(nil), values...) + sort.Slice(values, func(i, j int) bool { return values[i] < values[j] }) + index := ((len(values) * p) + 99) / 100 + if index <= 0 { + index = 1 + } + if index > len(values) { + index = len(values) + } + return values[index-1] +} + +func writeReport(report loadtestReport) { + if path := strings.TrimSpace(report.Config.ReportPath); path != "" { + file, err := os.Create(path) + if err != nil { + log.Fatal(err) + } + encoder := json.NewEncoder(file) + encoder.SetIndent("", " ") + if err := encoder.Encode(report); err != nil { + _ = file.Close() + log.Fatal(err) + } + if err := file.Close(); err != nil { + log.Fatal(err) + } + } + encoder := json.NewEncoder(os.Stdout) + encoder.SetIndent("", " ") + if err := encoder.Encode(report); err != nil { + log.Fatal(err) + } +} + +func splitCSV(value string) []string { + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func loadtestQUICConfig(cfg loadtestConfig) *quic.Config { + maxStreams := int64(cfg.Concurrency) + if maxStreams < 1000 { + maxStreams = 1000 + } + return &quic.Config{ + EnableDatagrams: true, + MaxIncomingStreams: maxStreams, + MaxIncomingUniStreams: maxStreams, + MaxIdleTimeout: 2 * time.Minute, + KeepAlivePeriod: 15 * time.Second, + } +} + +func selfSignedTLSConfig() (*tls.Config, string, error) { + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + return nil, "", err + } + template := x509.Certificate{ + SerialNumber: big.NewInt(time.Now().UnixNano()), + Subject: pkix.Name{CommonName: "rap-fabric-loadtest"}, + NotBefore: time.Now().Add(-time.Minute), + NotAfter: time.Now().Add(24 * time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + DNSNames: []string{"localhost"}, + } + certDER, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + return nil, "", err + } + sum := sha256.Sum256(certDER) + return &tls.Config{ + Certificates: []tls.Certificate{{ + Certificate: [][]byte{certDER}, + PrivateKey: key, + }}, + NextProtos: []string{"rap-fabric-data-session-v1"}, + }, hex.EncodeToString(sum[:]), nil +} diff --git a/agents/rap-node-agent/cmd/fabric-loadtest/main_test.go b/agents/rap-node-agent/cmd/fabric-loadtest/main_test.go new file mode 100644 index 0000000..1702e9c --- /dev/null +++ b/agents/rap-node-agent/cmd/fabric-loadtest/main_test.go @@ -0,0 +1,760 @@ +package main + +import ( + "bytes" + "context" + "strings" + "testing" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh" +) + +func TestRouteModeCoverageVerdictRequiresMixedModes(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + TopologyProfile: "mixed-public-nat-lan-relay", + Targets: []string{"a", "b", "c", "d"}, + FailTarget: -1, + ImpairTarget: -1, + }, + SuccessfulStreams: 4, + TargetStats: map[string]targetStats{ + "a": {RouteModes: map[string]int{string(mesh.FabricRouteLAN): 1}}, + "b": {RouteModes: map[string]int{string(mesh.FabricRouteICE): 1}}, + "c": {RouteModes: map[string]int{string(mesh.FabricRouteReverse): 1}}, + "d": {RouteModes: map[string]int{}}, + }, + } + + reasons := routeModeCoverageVerdictReasons(report) + if len(reasons) != 1 || !strings.Contains(reasons[0], string(mesh.FabricRouteRelay)) { + t.Fatalf("reasons = %v, want missing relay route mode", reasons) + } + + report.TargetStats["d"] = targetStats{RouteModes: map[string]int{string(mesh.FabricRouteRelay): 1}} + if reasons := routeModeCoverageVerdictReasons(report); len(reasons) != 0 { + t.Fatalf("reasons = %v, want full coverage pass", reasons) + } +} + +func TestLegacyRouteModeVerdictRejectsNonQUICModes(t *testing.T) { + report := loadtestReport{ + TargetStats: map[string]targetStats{ + "a": {RouteModes: map[string]int{ + "direct_quic": 4, + "relay": 1, + "outbound_reverse": 2, + "wss": 3, + }}, + }, + } + reasons := legacyRouteModeVerdictReasons(report) + if len(reasons) != 1 || + !strings.Contains(reasons[0], "relay:1") || + !strings.Contains(reasons[0], "outbound_reverse:2") || + !strings.Contains(reasons[0], "wss:3") { + t.Fatalf("reasons = %v, want legacy route mode failure", reasons) + } + + report.TargetStats["a"] = targetStats{RouteModes: map[string]int{ + string(mesh.FabricRouteDirect): 1, + string(mesh.FabricRouteLAN): 1, + string(mesh.FabricRouteICE): 1, + string(mesh.FabricRouteReverse): 1, + string(mesh.FabricRouteRelay): 1, + }} + if reasons := legacyRouteModeVerdictReasons(report); len(reasons) != 0 { + t.Fatalf("reasons = %v, want QUIC modes accepted", reasons) + } +} + +func TestTargetEndpointPolicyVerdictRejectsNonQUICTargets(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + Targets: []string{ + "quic://a:19443", + "http://b:19443", + "ws://c:19443", + "d:19443", + "", + }, + }, + } + + reasons := targetEndpointPolicyVerdictReasons(report) + if len(reasons) != 1 || + !strings.Contains(reasons[0], "http://b:19443") || + !strings.Contains(reasons[0], "ws://c:19443") || + !strings.Contains(reasons[0], "d:19443") || + !strings.Contains(reasons[0], "") { + t.Fatalf("reasons = %v, want non-QUIC target failure", reasons) + } + + report.Config.Targets = []string{"quic://a:19443", " QUIC://b:19443 "} + if reasons := targetEndpointPolicyVerdictReasons(report); len(reasons) != 0 { + t.Fatalf("reasons = %v, want QUIC targets accepted", reasons) + } +} + +func TestRunClientRejectsNonQUICTargetBeforeDial(t *testing.T) { + _, err := runClient(context.Background(), loadtestConfig{ + Targets: []string{"http://127.0.0.1:19443"}, + Streams: 1, + Concurrency: 1, + BytesPerStream: 1, + PayloadSize: 1, + }) + if err == nil || !strings.Contains(err.Error(), "non_quic_targets=http://127.0.0.1:19443") { + t.Fatalf("err = %v, want non-QUIC target validation error", err) + } +} + +func TestFillLoadtestPayloadVariesByStreamAndSequence(t *testing.T) { + first := make([]byte, 128) + second := make([]byte, 128) + third := make([]byte, 128) + + fillLoadtestPayload(first, 7, 9, 1, 0) + fillLoadtestPayload(second, 7, 9, 2, int64(len(first))) + fillLoadtestPayload(third, 8, 10, 1, 0) + + if bytes.Equal(first, second) { + t.Fatal("payload did not vary by sequence/offset") + } + if bytes.Equal(first, third) { + t.Fatal("payload did not vary by stream") + } + if bytes.Count(first, []byte{first[0]}) == len(first) { + t.Fatal("payload collapsed to a constant byte") + } +} + +func TestFillLoadtestPayloadIsDeterministic(t *testing.T) { + first := make([]byte, 128) + second := make([]byte, 128) + + fillLoadtestPayload(first, 7, 9, 1, 0) + fillLoadtestPayload(second, 7, 9, 1, 0) + + if !bytes.Equal(first, second) { + t.Fatal("payload is not deterministic") + } +} + +func TestFillLoadtestPayloadHandlesShortFinalChunk(t *testing.T) { + chunk := make([]byte, 17) + fillLoadtestPayload(chunk, 7, 9, 3, 256) + if bytes.Equal(chunk, make([]byte, len(chunk))) { + t.Fatal("short payload chunk stayed zeroed") + } +} + +func TestVerdictFailsSuccessfulStreamAckMismatch(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + FailTarget: -1, + ImpairTarget: -1, + Concurrency: 1, + }, + TotalStreams: 1, + SuccessfulStreams: 1, + BytesSent: 1024, + FramesSent: 2, + AcksReceived: 1, + AckMismatchedStreams: 1, + ChannelOpens: 1, + ChannelCloses: 1, + RoutePressure: mesh.FabricRoutePressureSnapshot{AcquiredTotal: 1, ReleasedTotal: 1, MaxActiveTotal: 1}, + } + + gotVerdict, reasons := verdict(report) + if gotVerdict != "fail" { + t.Fatalf("verdict = %q, want fail", gotVerdict) + } + found := false + for _, reason := range reasons { + if reason == "ack_mismatched_streams=1" { + found = true + } + } + if !found { + t.Fatalf("reasons = %v, want ack mismatch reason", reasons) + } +} + +func TestVerdictFailsAckIntegrityError(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + FailTarget: -1, + ImpairTarget: -1, + Concurrency: 1, + }, + TotalStreams: 1, + FailedStreams: 1, + BytesSent: 1024, + FramesSent: 1, + AcksReceived: 1, + AckIntegrityErrors: 1, + ChannelOpens: 1, + ChannelCloses: 1, + RoutePressure: mesh.FabricRoutePressureSnapshot{AcquiredTotal: 1, ReleasedTotal: 1, MaxActiveTotal: 1}, + } + + gotVerdict, reasons := verdict(report) + if gotVerdict != "fail" { + t.Fatalf("verdict = %q, want fail", gotVerdict) + } + found := false + for _, reason := range reasons { + if reason == "ack_integrity_errors=1" { + found = true + } + } + if !found { + t.Fatalf("reasons = %v, want ack integrity reason", reasons) + } +} + +func TestVerdictFailsBelowMinimumThroughput(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + FailTarget: -1, + ImpairTarget: -1, + Concurrency: 1, + MinThroughputMbps: 100, + }, + TotalStreams: 1, + SuccessfulStreams: 1, + BytesSent: 1024, + FramesSent: 1, + AcksReceived: 1, + ThroughputBps: 99 * 1000 * 1000, + ChannelOpens: 1, + ChannelCloses: 1, + RoutePressure: mesh.FabricRoutePressureSnapshot{AcquiredTotal: 1, ReleasedTotal: 1, MaxActiveTotal: 1}, + } + + gotVerdict, reasons := verdict(report) + if gotVerdict != "fail" { + t.Fatalf("verdict = %q, want fail", gotVerdict) + } + found := false + for _, reason := range reasons { + if strings.HasPrefix(reason, "throughput_bps=") { + found = true + } + } + if !found { + t.Fatalf("reasons = %v, want throughput reason", reasons) + } + + report.ThroughputBps = 100 * 1000 * 1000 + if gotVerdict, reasons := verdict(report); gotVerdict != "pass" { + t.Fatalf("verdict = %q reasons=%v, want pass at threshold", gotVerdict, reasons) + } +} + +func TestVerdictFailsBelowMinimumChannelChurn(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + FailTarget: -1, + ImpairTarget: -1, + Concurrency: 1, + MinChannelChurn: 1000, + }, + TotalStreams: 1, + SuccessfulStreams: 1, + BytesSent: 1024, + FramesSent: 1, + AcksReceived: 1, + ChannelOpens: 1, + ChannelCloses: 1, + ChannelChurnPerSec: 999, + RoutePressure: mesh.FabricRoutePressureSnapshot{AcquiredTotal: 1, ReleasedTotal: 1, MaxActiveTotal: 1}, + } + + gotVerdict, reasons := verdict(report) + if gotVerdict != "fail" { + t.Fatalf("verdict = %q, want fail", gotVerdict) + } + found := false + for _, reason := range reasons { + if strings.HasPrefix(reason, "channel_churn_per_sec=") { + found = true + } + } + if !found { + t.Fatalf("reasons = %v, want channel churn reason", reasons) + } + + report.ChannelChurnPerSec = 1000 + if gotVerdict, reasons := verdict(report); gotVerdict != "pass" { + t.Fatalf("verdict = %q reasons=%v, want pass at threshold", gotVerdict, reasons) + } +} + +func TestTargetByteDistributionVerdictDetectsSkew(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + Targets: []string{"a", "b", "c", "d"}, + FailTarget: -1, + ImpairTarget: -1, + Concurrency: 1, + BytesPerStream: 100, + }, + SuccessfulStreams: 40, + BytesSent: 4000, + TargetStreams: map[string]int{ + "a": 10, + "b": 10, + "c": 10, + "d": 10, + }, + TargetBytes: map[string]int64{ + "a": 2500, + "b": 500, + "c": 500, + "d": 500, + }, + } + + reasons := targetByteDistributionVerdictReasons(report) + if len(reasons) != 1 || !strings.HasPrefix(reasons[0], "target_byte_distribution_skew=") { + t.Fatalf("reasons = %v, want byte skew reason", reasons) + } + + report.TargetBytes = map[string]int64{ + "a": 1000, + "b": 1000, + "c": 1000, + "d": 1000, + } + if reasons := targetByteDistributionVerdictReasons(report); len(reasons) != 0 { + t.Fatalf("reasons = %v, want balanced bytes pass", reasons) + } +} + +func TestDistributionVerdictChecksSurvivingTargetsAfterFailure(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + Targets: []string{"quic://a:1", "quic://b:1", "quic://c:1", "quic://d:1"}, + FailTarget: 0, + ImpairTarget: -1, + Concurrency: 8, + }, + SuccessfulStreams: 90, + TargetStreams: map[string]int{ + "quic://b:1": 90, + }, + } + + reasons := targetDistributionVerdictReasons(report) + if len(reasons) != 1 || !strings.HasPrefix(reasons[0], "target_distribution_collapsed=1/3_targets_used") { + t.Fatalf("reasons = %v, want surviving-target collapse", reasons) + } + + report.TargetStreams = map[string]int{ + "quic://b:1": 30, + "quic://c:1": 30, + "quic://d:1": 30, + } + if reasons := targetDistributionVerdictReasons(report); len(reasons) != 0 { + t.Fatalf("reasons = %v, want balanced surviving targets pass", reasons) + } +} + +func TestRoutePressureVerdictChecksSurvivingTargetsAfterFailure(t *testing.T) { + targets := []string{"quic://a:1", "quic://b:1", "quic://c:1", "quic://d:1"} + report := loadtestReport{ + Config: loadtestConfig{ + Targets: targets, + FailTarget: 0, + ImpairTarget: -1, + Concurrency: 12, + }, + RoutePressure: mesh.FabricRoutePressureSnapshot{ + MaxActive: map[string]int{ + loadtestRouteID(1, targets[1]): 12, + }, + MaxActiveTotal: 12, + }, + } + + reasons := routePressureDistributionVerdictReasons(report) + if len(reasons) != 1 || !strings.HasPrefix(reasons[0], "route_pressure_distribution_collapsed=1/3_targets_used") { + t.Fatalf("reasons = %v, want surviving-route-pressure collapse", reasons) + } + + report.RoutePressure.MaxActive = map[string]int{ + loadtestRouteID(1, targets[1]): 4, + loadtestRouteID(2, targets[2]): 4, + loadtestRouteID(3, targets[3]): 4, + } + if reasons := routePressureDistributionVerdictReasons(report); len(reasons) != 0 { + t.Fatalf("reasons = %v, want balanced surviving route pressure pass", reasons) + } +} + +func TestVerdictFailsOverallAckLatencySLO(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + FailTarget: -1, + ImpairTarget: -1, + Concurrency: 1, + MaxAckP95Ms: 10, + MaxAckP99Ms: 20, + }, + TotalStreams: 1, + SuccessfulStreams: 1, + BytesSent: 1024, + FramesSent: 1, + AcksReceived: 1, + AckP95Ms: 11, + AckP99Ms: 21, + ChannelOpens: 1, + ChannelCloses: 1, + RoutePressure: mesh.FabricRoutePressureSnapshot{AcquiredTotal: 1, ReleasedTotal: 1, MaxActiveTotal: 1}, + } + + gotVerdict, reasons := verdict(report) + if gotVerdict != "fail" { + t.Fatalf("verdict = %q, want fail", gotVerdict) + } + foundP95 := false + foundP99 := false + for _, reason := range reasons { + if strings.HasPrefix(reason, "ack_p95_ms=") { + foundP95 = true + } + if strings.HasPrefix(reason, "ack_p99_ms=") { + foundP99 = true + } + } + if !foundP95 || !foundP99 { + t.Fatalf("reasons = %v, want ACK p95 and p99 reasons", reasons) + } +} + +func TestTargetAckVerdictDetectsSlowHealthyTarget(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + Targets: []string{"a", "b"}, + FailTarget: -1, + ImpairTarget: -1, + MaxTargetAckMs: 10, + }, + TargetStats: map[string]targetStats{ + "a": {Streams: 10, MaxAckMs: 4}, + "b": {Streams: 10, MaxAckMs: 11}, + }, + } + + reasons := targetAckVerdictReasons(report) + if len(reasons) != 1 || !strings.HasPrefix(reasons[0], "target_ack_ms=b:11>10") { + t.Fatalf("reasons = %v, want slow target ack reason", reasons) + } + + report.TargetStats["b"] = targetStats{Streams: 10, MaxAckMs: 10} + if reasons := targetAckVerdictReasons(report); len(reasons) != 0 { + t.Fatalf("reasons = %v, want target ack pass at threshold", reasons) + } +} + +func TestVerdictFailsSetupLatencySLO(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + FailTarget: -1, + ImpairTarget: -1, + Concurrency: 1, + MaxSetupP95Ms: 10, + MaxSetupP99Ms: 20, + }, + TotalStreams: 1, + SuccessfulStreams: 1, + BytesSent: 1024, + FramesSent: 1, + AcksReceived: 1, + SetupLatencyP95Ms: 11, + SetupLatencyP99Ms: 21, + ChannelOpens: 1, + ChannelCloses: 1, + RoutePressure: mesh.FabricRoutePressureSnapshot{AcquiredTotal: 1, ReleasedTotal: 1, MaxActiveTotal: 1}, + } + + gotVerdict, reasons := verdict(report) + if gotVerdict != "fail" { + t.Fatalf("verdict = %q, want fail", gotVerdict) + } + foundP95 := false + foundP99 := false + for _, reason := range reasons { + if strings.HasPrefix(reason, "setup_p95_ms=") { + foundP95 = true + } + if strings.HasPrefix(reason, "setup_p99_ms=") { + foundP99 = true + } + } + if !foundP95 || !foundP99 { + t.Fatalf("reasons = %v, want setup p95 and p99 reasons", reasons) + } +} + +func TestVerdictFailsRerouteLatencySLO(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + FailTarget: -1, + ImpairTarget: -1, + Concurrency: 1, + MaxRerouteP95Ms: 10, + MaxRerouteP99Ms: 20, + }, + TotalStreams: 1, + SuccessfulStreams: 1, + BytesSent: 1024, + FramesSent: 1, + AcksReceived: 1, + RerouteLatencyP95Ms: 11, + RerouteLatencyP99Ms: 21, + ChannelOpens: 1, + ChannelCloses: 1, + RoutePressure: mesh.FabricRoutePressureSnapshot{AcquiredTotal: 1, ReleasedTotal: 1, MaxActiveTotal: 1}, + } + + gotVerdict, reasons := verdict(report) + if gotVerdict != "fail" { + t.Fatalf("verdict = %q, want fail", gotVerdict) + } + foundP95 := false + foundP99 := false + for _, reason := range reasons { + if strings.HasPrefix(reason, "reroute_p95_ms=") { + foundP95 = true + } + if strings.HasPrefix(reason, "reroute_p99_ms=") { + foundP99 = true + } + } + if !foundP95 || !foundP99 { + t.Fatalf("reasons = %v, want reroute p95 and p99 reasons", reasons) + } +} + +func TestShouldQuarantineTarget(t *testing.T) { + quarantined := []string{ + "ack timeout or session closed", + "deadline exceeded", + "connection refused", + "connection reset by peer", + "no route to host", + } + for _, reason := range quarantined { + if !shouldQuarantineTarget(reason) { + t.Fatalf("shouldQuarantineTarget(%q) = false, want true", reason) + } + } + if shouldQuarantineTarget("ack payload checksum mismatch") { + t.Fatal("checksum mismatch should not quarantine a target") + } + if shouldQuarantineTarget("context deadline exceeded") { + t.Fatal("context deadline should not quarantine a target") + } +} + +func TestSpreadStartDistributesQuarantinedSlot(t *testing.T) { + targets := []string{"a", "b", "c", "d"} + health := newTargetHealthTracker() + health.MarkDegraded("a", "connection refused", time.Minute) + counts := map[string]int{} + for index := 0; index < 40; index += len(targets) { + initial, spread := loadtestSpreadStart(index, len(targets)) + targetIndex := loadtestPreferredTargetIndex(targets, initial, spread, health, -1) + counts[targets[targetIndex]]++ + } + if counts["b"] == 0 || counts["c"] == 0 || counts["d"] == 0 { + t.Fatalf("counts = %v, want degraded slot spread across surviving targets", counts) + } +} + +func TestSpreadUsableTargetDistributesRetries(t *testing.T) { + targets := []string{"a", "b", "c", "d"} + health := newTargetHealthTracker() + health.MarkDegraded("a", "connection refused", time.Minute) + counts := map[string]int{} + for cohort := 0; cohort < 90; cohort++ { + targetIndex := loadtestSpreadUsableTargetIndex(targets, cohort, health, 0) + counts[targets[targetIndex]]++ + } + if counts["b"] != 30 || counts["c"] != 30 || counts["d"] != 30 { + t.Fatalf("counts = %v, want retry load spread evenly across surviving targets", counts) + } +} + +func TestLoadtestLogicalStreamIDAvoidsReservedTransportStreams(t *testing.T) { + for _, index := range []int{-1, 0, 1, 999, 1000, 10_000} { + streamID := loadtestLogicalStreamID(index) + if streamID == mesh.ProductionForwardQUICStreamID || streamID == mesh.SyntheticForwardQUICStreamID { + t.Fatalf("loadtestLogicalStreamID(%d) = %d, collides with reserved transport stream", index, streamID) + } + if streamID < 10_000 { + t.Fatalf("loadtestLogicalStreamID(%d) = %d, want loadtest stream range", index, streamID) + } + } +} + +func TestLatencyAwareTargetIndexKeepsSlowWANFromOwningPool(t *testing.T) { + targets := []string{"lan-a", "lan-b", "wan"} + health := newTargetHealthTracker() + health.RecordProbes([]targetProbeResult{ + {Target: "lan-a", RTTMs: 4, Usable: true}, + {Target: "lan-b", RTTMs: 5, Usable: true}, + {Target: "wan", RTTMs: 400, Usable: true}, + }) + counts := map[string]int{} + for index := 0; index < 300; index++ { + targetIndex := loadtestSpreadUsableTargetIndex(targets, index, health, -1) + counts[targets[targetIndex]]++ + } + if counts["wan"] == 0 { + t.Fatalf("counts = %v, want slow WAN to stay represented", counts) + } + if counts["wan"] >= counts["lan-a"] || counts["wan"] >= counts["lan-b"] { + t.Fatalf("counts = %v, want latency-aware placement to prefer LAN capacity", counts) + } +} + +func TestLatencyAwarePreferredTargetUsesAbsolutePlacementOrdinal(t *testing.T) { + targets := []string{"lan-a", "lan-b", "lan-c", "wan"} + health := newTargetHealthTracker() + health.RecordProbes([]targetProbeResult{ + {Target: "lan-a", RTTMs: 4, Usable: true}, + {Target: "lan-b", RTTMs: 4, Usable: true}, + {Target: "lan-c", RTTMs: 4, Usable: true}, + {Target: "wan", RTTMs: 400, Usable: true}, + }) + counts := map[string]int{} + for index := 0; index < 500; index++ { + preferred, spread := loadtestSpreadStart(index, len(targets)) + targetIndex := loadtestPreferredTargetIndex(targets, preferred, spread, health, -1) + counts[targets[targetIndex]]++ + } + if len(counts) < len(targets) { + t.Fatalf("counts = %v, want every probed target represented", counts) + } + if counts["wan"] >= counts["lan-a"] || counts["wan"] >= counts["lan-b"] || counts["wan"] >= counts["lan-c"] { + t.Fatalf("counts = %v, want slow WAN weighted below LAN targets", counts) + } +} + +func TestHeterogeneousProbeRTTRelaxesEqualDistributionVerdict(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + Targets: []string{"lan", "wan"}, + Concurrency: 64, + }, + SuccessfulStreams: 100, + BytesSent: 100 * 1024, + TargetStreams: map[string]int{ + "lan": 96, + "wan": 4, + }, + TargetBytes: map[string]int64{ + "lan": 96 * 1024, + "wan": 4 * 1024, + }, + TargetProbes: []targetProbeResult{ + {Target: "lan", RTTMs: 4, Usable: true}, + {Target: "wan", RTTMs: 400, Usable: true}, + }, + RoutePressure: mesh.FabricRoutePressureSnapshot{ + MaxActive: map[string]int{ + loadtestRouteID(0, "lan"): 32, + loadtestRouteID(1, "wan"): 1, + }, + MaxActiveTotal: 32, + }, + } + if reasons := targetDistributionVerdictReasons(report); len(reasons) != 0 { + t.Fatalf("targetDistributionVerdictReasons = %v, want heterogeneous RTT tolerated", reasons) + } + if reasons := targetByteDistributionVerdictReasons(report); len(reasons) != 0 { + t.Fatalf("targetByteDistributionVerdictReasons = %v, want heterogeneous RTT tolerated", reasons) + } + if reasons := routePressureDistributionVerdictReasons(report); len(reasons) != 0 { + t.Fatalf("routePressureDistributionVerdictReasons = %v, want heterogeneous RTT tolerated", reasons) + } +} + +func TestTargetHealthQuarantineExpiresButSnapshotKeepsObservation(t *testing.T) { + health := newTargetHealthTracker() + health.MarkDegraded("a", "ack timeout", time.Nanosecond) + if !health.IsDegraded("a") { + t.Fatal("target should be degraded immediately") + } + time.Sleep(time.Millisecond) + if health.IsDegraded("a") { + t.Fatal("target quarantine did not expire") + } + snapshot := health.Snapshot() + if snapshot["a"] != "ack timeout" { + t.Fatalf("snapshot = %v, want historical degraded observation", snapshot) + } +} + +func TestRoutePressureDistributionVerdictDetectsCollapse(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + Targets: []string{"a", "b", "c", "d"}, + FailTarget: -1, + ImpairTarget: -1, + Concurrency: 16, + }, + RoutePressure: mesh.FabricRoutePressureSnapshot{ + MaxActive: map[string]int{ + loadtestRouteID(0, "a"): 16, + }, + MaxActiveTotal: 16, + }, + } + + reasons := routePressureDistributionVerdictReasons(report) + if len(reasons) != 1 || !strings.HasPrefix(reasons[0], "route_pressure_distribution_collapsed=") { + t.Fatalf("reasons = %v, want collapsed route pressure reason", reasons) + } +} + +func TestRoutePressureDistributionVerdictDetectsSkew(t *testing.T) { + report := loadtestReport{ + Config: loadtestConfig{ + Targets: []string{"a", "b", "c", "d"}, + FailTarget: -1, + ImpairTarget: -1, + Concurrency: 16, + }, + RoutePressure: mesh.FabricRoutePressureSnapshot{ + MaxActive: map[string]int{ + loadtestRouteID(0, "a"): 14, + loadtestRouteID(1, "b"): 2, + loadtestRouteID(2, "c"): 2, + loadtestRouteID(3, "d"): 2, + }, + MaxActiveTotal: 16, + }, + } + + reasons := routePressureDistributionVerdictReasons(report) + if len(reasons) != 1 || !strings.HasPrefix(reasons[0], "route_pressure_distribution_skew=") { + t.Fatalf("reasons = %v, want route pressure skew reason", reasons) + } + + report.RoutePressure.MaxActive = map[string]int{ + loadtestRouteID(0, "a"): 6, + loadtestRouteID(1, "b"): 6, + loadtestRouteID(2, "c"): 5, + loadtestRouteID(3, "d"): 5, + } + if reasons := routePressureDistributionVerdictReasons(report); len(reasons) != 0 { + t.Fatalf("reasons = %v, want balanced route pressure pass", reasons) + } +} diff --git a/agents/rap-node-agent/cmd/fabric-production-smoke/main.go b/agents/rap-node-agent/cmd/fabric-production-smoke/main.go new file mode 100644 index 0000000..d74a604 --- /dev/null +++ b/agents/rap-node-agent/cmd/fabric-production-smoke/main.go @@ -0,0 +1,199 @@ +package main + +import ( + "context" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "encoding/json" + "errors" + "flag" + "fmt" + "os" + "strings" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh" +) + +type smokeOutput struct { + OK bool `json:"ok"` + Endpoint string `json:"endpoint"` + EntryNodeID string `json:"entry_node_id"` + NextHopID string `json:"next_hop_node_id"` + RouteID string `json:"route_id"` + ElapsedMS int64 `json:"elapsed_ms"` + Result mesh.ProductionForwardResult `json:"result"` + Error string `json:"error,omitempty"` + EnvelopePath []string `json:"envelope_path,omitempty"` +} + +type productionForwardResponse struct { + Result mesh.ProductionForwardResult `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +func main() { + var ( + endpoint = flag.String("endpoint", "", "QUIC fabric endpoint for the entry node, for example quic://host:19131.") + peerCert = flag.String("peer-cert-sha256", "", "Expected entry node QUIC TLS certificate SHA-256 fingerprint.") + clusterID = flag.String("cluster-id", "", "Cluster ID.") + routeID = flag.String("route-id", "", "Configured production route ID.") + sourceNodeID = flag.String("source-node-id", "", "Route source node ID.") + destNodeID = flag.String("destination-node-id", "", "Route destination node ID.") + currentNodeID = flag.String("current-hop-node-id", "", "Current hop node ID expected by the entry node.") + nextHopNodeID = flag.String("next-hop-node-id", "", "Next hop node ID from the entry node.") + routePath = flag.String("route-path", "", "Comma-separated route path.") + channel = flag.String("channel", mesh.ProductionChannelFabricControl, "Production channel class.") + timeout = flag.Duration("timeout", 10*time.Second, "Smoke request timeout.") + payloadText = flag.String("payload", `{"kind":"fabric-production-smoke"}`, "JSON payload string.") + payloadB64 = flag.String("payload-b64", "", "Base64-encoded JSON payload string.") + ) + flag.Parse() + + if *endpoint == "" || *clusterID == "" || *routeID == "" || *sourceNodeID == "" || *destNodeID == "" || *currentNodeID == "" || *nextHopNodeID == "" { + writeOutput(smokeOutput{OK: false, Error: "endpoint, cluster-id, route-id, source-node-id, destination-node-id, current-hop-node-id and next-hop-node-id are required"}) + os.Exit(2) + } + path := splitRoutePath(*routePath) + payloadSource := strings.TrimSpace(*payloadText) + if strings.TrimSpace(*payloadB64) != "" { + decoded, err := base64.StdEncoding.DecodeString(strings.TrimSpace(*payloadB64)) + if err != nil { + writeOutput(smokeOutput{OK: false, Error: "payload-b64 must be valid base64"}) + os.Exit(2) + } + payloadSource = string(decoded) + } + payload := json.RawMessage(strings.TrimSpace(payloadSource)) + if !json.Valid(payload) { + writeOutput(smokeOutput{OK: false, Error: "payload must be valid JSON"}) + os.Exit(2) + } + now := time.Now().UTC() + messageType := mesh.ProductionMessageFabricControl + if strings.TrimSpace(*channel) == mesh.ProductionChannelVPNPacket { + messageType = mesh.ProductionMessageVPNPacketBatch + } + sum := sha256.Sum256(payload) + envelope := mesh.ProductionEnvelope{ + FabricProtocolVersion: mesh.ProtocolVersion, + MessageID: fmt.Sprintf("fabric-production-smoke-%d", now.UnixNano()), + RouteID: strings.TrimSpace(*routeID), + ClusterID: strings.TrimSpace(*clusterID), + SourceNodeID: strings.TrimSpace(*sourceNodeID), + DestinationNodeID: strings.TrimSpace(*destNodeID), + CurrentHopNodeID: strings.TrimSpace(*currentNodeID), + NextHopNodeID: strings.TrimSpace(*nextHopNodeID), + RoutePath: path, + ChannelClass: strings.TrimSpace(*channel), + MessageType: messageType, + TTL: 8, + HopCount: 0, + CreatedAt: now, + ExpiresAt: now.Add(time.Minute), + PayloadLength: len(payload), + PayloadHash: hex.EncodeToString(sum[:]), + Payload: payload, + } + + transport := mesh.NewQUICFabricTransport(nil) + ctx, cancel := context.WithTimeout(context.Background(), *timeout) + defer cancel() + started := time.Now() + result, err := sendProductionEnvelope(ctx, transport, mesh.FabricTransportTarget{ + EndpointID: "fabric-production-smoke-entry", + PeerID: envelope.CurrentHopNodeID, + Endpoint: strings.TrimSpace(*endpoint), + Transport: "quic", + PeerCertSHA256: strings.TrimSpace(*peerCert), + Timeout: *timeout, + InboundBuffer: 8, + ErrorBuffer: 4, + }, envelope) + output := smokeOutput{ + OK: err == nil && result.Accepted, + Endpoint: *endpoint, + EntryNodeID: envelope.CurrentHopNodeID, + NextHopID: envelope.NextHopNodeID, + RouteID: envelope.RouteID, + ElapsedMS: time.Since(started).Milliseconds(), + Result: result, + EnvelopePath: path, + } + if err != nil { + output.Error = err.Error() + writeOutput(output) + os.Exit(1) + } + writeOutput(output) +} + +func sendProductionEnvelope(ctx context.Context, transport *mesh.QUICFabricTransport, target mesh.FabricTransportTarget, envelope mesh.ProductionEnvelope) (mesh.ProductionForwardResult, error) { + session, err := transport.Connect(ctx, target) + if err != nil { + return mesh.ProductionForwardResult{}, err + } + defer session.Close() + payload, err := json.Marshal(envelope) + if err != nil { + return mesh.ProductionForwardResult{}, err + } + if err := session.Send(ctx, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: fabricproto.TrafficClassReliable, + StreamID: mesh.ProductionForwardQUICStreamID, + Sequence: 1, + Payload: payload, + }); err != nil { + return mesh.ProductionForwardResult{}, err + } + for { + select { + case <-ctx.Done(): + return mesh.ProductionForwardResult{}, ctx.Err() + case err := <-session.Errors(): + if err != nil { + return mesh.ProductionForwardResult{}, err + } + case frame := <-session.Frames(): + if frame.Type != fabricproto.FrameData || frame.StreamID != mesh.ProductionForwardQUICStreamID || frame.Sequence != 1 { + continue + } + var response productionForwardResponse + if err := json.Unmarshal(frame.Payload, &response); err != nil { + return mesh.ProductionForwardResult{}, err + } + if strings.TrimSpace(response.Error) != "" { + return mesh.ProductionForwardResult{}, errors.New(response.Error) + } + return response.Result, nil + } + } +} + +func splitRoutePath(value string) []string { + value = strings.TrimSpace(value) + if value == "" { + return nil + } + parts := strings.Split(value, ",") + out := make([]string, 0, len(parts)) + for _, part := range parts { + part = strings.TrimSpace(part) + if part != "" { + out = append(out, part) + } + } + return out +} + +func writeOutput(output smokeOutput) { + payload, err := json.MarshalIndent(output, "", " ") + if err != nil { + fmt.Fprintf(os.Stderr, "marshal smoke output: %v\n", err) + return + } + fmt.Println(string(payload)) +} 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 7c49d93..eab9208 100644 --- a/agents/rap-node-agent/cmd/mesh-live-smoke/main.go +++ b/agents/rap-node-agent/cmd/mesh-live-smoke/main.go @@ -28,6 +28,18 @@ type smokeNode struct { server *httptest.Server } +type smokeSyntheticTransport struct { + peers map[string]string +} + +func (t smokeSyntheticTransport) SendSynthetic(ctx context.Context, nextNodeID string, envelope mesh.SyntheticEnvelope) (mesh.SyntheticEnvelope, error) { + baseURL := t.peers[nextNodeID] + if baseURL == "" { + return mesh.SyntheticEnvelope{}, mesh.ErrSyntheticPeerUnavailable + } + return mesh.NewClient(baseURL).SendSynthetic(ctx, envelope) +} + type smokeReport struct { Stage string `json:"stage"` ProductionForwarding bool `json:"production_forwarding"` @@ -433,7 +445,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}.Handler().ServeHTTP(w, r) + mesh.Server{Local: node.Local, SyntheticRuntime: node.Runtime, FabricSessionEnabled: true, FabricSessionWebSocketEnabled: true}.Handler().ServeHTTP(w, r) })) node.URL = node.server.URL return node @@ -454,7 +466,7 @@ func smokeRuntime(local mesh.PeerIdentity, routes []mesh.SyntheticRoute, peers m mesh.SyntheticChannelFabricControl, mesh.SyntheticChannelRouteControl, }, - Transport: mesh.NewHTTPPeerTransport(peers), + Transport: smokeSyntheticTransport{peers: peers}, }) } 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 6f122e3..ec475b1 100644 --- a/agents/rap-node-agent/cmd/rap-host-agent/main.go +++ b/agents/rap-node-agent/cmd/rap-host-agent/main.go @@ -217,7 +217,7 @@ func runInstallLinux(ctx context.Context, args []string) error { 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.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 WebSocket endpoint.") + 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.") @@ -230,7 +230,7 @@ func runInstallLinux(ctx context.Context, args []string) error { 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.") fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseEndpoint, "mesh-advertise-endpoint", getenv("RAP_MESH_ADVERTISE_ENDPOINT", ""), "Advertised mesh endpoint.") fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseEndpointsJSON, "mesh-advertise-endpoints-json", getenv("RAP_MESH_ADVERTISE_ENDPOINTS_JSON", ""), "Advertised endpoint candidates JSON.") - fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseTransport, "mesh-advertise-transport", getenv("RAP_MESH_ADVERTISE_TRANSPORT", "direct_http"), "Advertised transport.") + fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseTransport, "mesh-advertise-transport", getenv("RAP_MESH_ADVERTISE_TRANSPORT", "quic"), "Advertised transport.") fs.StringVar(&cfg.RuntimeConfig.MeshConnectivityMode, "mesh-connectivity-mode", getenv("RAP_MESH_CONNECTIVITY_MODE", "outbound_only"), "Connectivity mode hint.") fs.StringVar(&cfg.RuntimeConfig.MeshNATType, "mesh-nat-type", getenv("RAP_MESH_NAT_TYPE", "unknown"), "NAT type hint.") fs.StringVar(&cfg.RuntimeConfig.MeshRegion, "mesh-region", getenv("RAP_MESH_REGION", "linux"), "Region/site hint.") @@ -305,7 +305,7 @@ func runInstallWindows(ctx context.Context, args []string) error { 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.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 WebSocket endpoint.") + 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.") @@ -318,7 +318,7 @@ func runInstallWindows(ctx context.Context, args []string) error { 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.") fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseEndpoint, "mesh-advertise-endpoint", getenv("RAP_MESH_ADVERTISE_ENDPOINT", ""), "Advertised mesh endpoint.") fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseEndpointsJSON, "mesh-advertise-endpoints-json", getenv("RAP_MESH_ADVERTISE_ENDPOINTS_JSON", ""), "Advertised endpoint candidates JSON.") - fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseTransport, "mesh-advertise-transport", getenv("RAP_MESH_ADVERTISE_TRANSPORT", "direct_http"), "Advertised transport.") + fs.StringVar(&cfg.RuntimeConfig.MeshAdvertiseTransport, "mesh-advertise-transport", getenv("RAP_MESH_ADVERTISE_TRANSPORT", "quic"), "Advertised transport.") fs.StringVar(&cfg.RuntimeConfig.MeshConnectivityMode, "mesh-connectivity-mode", getenv("RAP_MESH_CONNECTIVITY_MODE", "outbound_only"), "Connectivity mode hint.") fs.StringVar(&cfg.RuntimeConfig.MeshNATType, "mesh-nat-type", getenv("RAP_MESH_NAT_TYPE", "unknown"), "NAT type hint.") fs.StringVar(&cfg.RuntimeConfig.MeshRegion, "mesh-region", getenv("RAP_MESH_REGION", "windows"), "Region/site hint.") @@ -799,7 +799,7 @@ func parseInstall(args []string) (installCommandConfig, error) { 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.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 WebSocket endpoint.") + 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.") @@ -812,7 +812,7 @@ func parseInstall(args []string) (installCommandConfig, error) { 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.") fs.StringVar(&cfg.MeshAdvertiseEndpoint, "mesh-advertise-endpoint", getenv("RAP_MESH_ADVERTISE_ENDPOINT", ""), "Advertised mesh endpoint.") fs.StringVar(&cfg.MeshAdvertiseEndpointsJSON, "mesh-advertise-endpoints-json", getenv("RAP_MESH_ADVERTISE_ENDPOINTS_JSON", ""), "Advertised endpoint candidates JSON.") - fs.StringVar(&cfg.MeshAdvertiseTransport, "mesh-advertise-transport", getenv("RAP_MESH_ADVERTISE_TRANSPORT", ""), "Advertised transport.") + fs.StringVar(&cfg.MeshAdvertiseTransport, "mesh-advertise-transport", getenv("RAP_MESH_ADVERTISE_TRANSPORT", "quic"), "Advertised transport.") fs.StringVar(&cfg.MeshConnectivityMode, "mesh-connectivity-mode", getenv("RAP_MESH_CONNECTIVITY_MODE", ""), "Connectivity mode hint.") fs.StringVar(&cfg.MeshNATType, "mesh-nat-type", getenv("RAP_MESH_NAT_TYPE", ""), "NAT type hint.") fs.StringVar(&cfg.MeshRegion, "mesh-region", getenv("RAP_MESH_REGION", ""), "Region/site hint.") 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 9d07232..b7446dd 100644 --- a/agents/rap-node-agent/cmd/rap-node-agent/main.go +++ b/agents/rap-node-agent/cmd/rap-node-agent/main.go @@ -10,12 +10,14 @@ import ( "crypto/x509/pkix" "encoding/hex" "encoding/json" + "encoding/pem" "errors" "fmt" "log" "math/big" "net" "net/http" + "net/url" "os" "os/exec" "os/signal" @@ -38,6 +40,7 @@ import ( "github.com/example/remote-access-platform/agents/rap-node-agent/internal/state" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/supervisor" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/vpnruntime" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/webingress" ) const ( @@ -102,7 +105,9 @@ func main() { defer stopMeshEndpoint() supervisor := supervisor.StubSupervisor{ - Version: agent.Version, + Version: agent.Version, + WebIngressRuntimeEnabled: cfg.WebIngressRuntimeEnabled, + WebIngressManager: webingress.NewManager(), RemoteWorkspaceRealAdapter: supervisor.RemoteWorkspaceRealAdapterConfig{ EnabledRequested: cfg.RemoteWorkspaceRealAdapterEnabled, Command: cfg.RemoteWorkspaceRealAdapterCommand, @@ -180,17 +185,18 @@ type joinRequestEnvelope struct { } type nodeApprovalAuthorityPayload struct { - SchemaVersion string `json:"schema_version"` - ClusterID string `json:"cluster_id"` - JoinRequestID string `json:"join_request_id"` - NodeID string `json:"node_id"` - NodeFingerprint string `json:"node_fingerprint"` - IdentityStatus string `json:"identity_status"` - HeartbeatEndpoint string `json:"heartbeat_endpoint"` - ApprovedByUserID string `json:"approved_by_user_id"` - IssuedAt time.Time `json:"issued_at"` - ControlPlaneOnly bool `json:"control_plane_only"` - ProductionForwarding bool `json:"production_forwarding"` + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + JoinRequestID string `json:"join_request_id"` + NodeID string `json:"node_id"` + NodeFingerprint string `json:"node_fingerprint"` + IdentityStatus string `json:"identity_status"` + HeartbeatEndpoint string `json:"heartbeat_endpoint"` + ApprovedByUserID string `json:"approved_by_user_id"` + ClusterAuthorityQuorumSHA256 string `json:"cluster_authority_quorum_sha256,omitempty"` + IssuedAt time.Time `json:"issued_at"` + ControlPlaneOnly bool `json:"control_plane_only"` + ProductionForwarding bool `json:"production_forwarding"` } func ensureApprovedIdentity(ctx context.Context, cfg config.Config, identity state.Identity, api *client.Client) (state.Identity, error) { @@ -237,13 +243,14 @@ func ensureApprovedIdentity(ctx context.Context, cfg config.Config, identity sta if err := verifyEnrollmentBootstrap(*response.Bootstrap, identity, cfg); err != nil { return state.Identity{}, err } - approved, err := state.MarkApprovedWithAuthority( + approved, err := state.MarkApprovedWithAuthorityAndQuorum( cfg.StateDir, response.Bootstrap.NodeID, response.Bootstrap.ClusterID, response.Bootstrap.IdentityStatus, response.Bootstrap.ClusterAuthority.PublicKey, response.Bootstrap.ClusterAuthority.PublicKeyFingerprint, + response.Bootstrap.ClusterAuthorityQuorum, ) if err != nil { return state.Identity{}, fmt.Errorf("persist approved identity: %w", err) @@ -330,6 +337,22 @@ func verifyEnrollmentBootstrap(bootstrap client.NodeBootstrap, identity state.Id if identity.PendingJoinRequestID != "" && payload.JoinRequestID != identity.PendingJoinRequestID { return fmt.Errorf("node bootstrap authority payload join request mismatch") } + if !rawMessageEmpty(bootstrap.ClusterAuthorityQuorum) { + var descriptor authority.QuorumDescriptor + if err := json.Unmarshal(bootstrap.ClusterAuthorityQuorum, &descriptor); err != nil { + return fmt.Errorf("decode node bootstrap cluster authority quorum: %w", err) + } + if descriptor.SchemaVersion != authority.QuorumSchemaVersion || descriptor.ClusterID != bootstrap.ClusterID { + return fmt.Errorf("node bootstrap cluster authority quorum descriptor mismatch") + } + descriptorHash, err := authority.QuorumDescriptorHash(descriptor) + if err != nil { + return fmt.Errorf("hash node bootstrap cluster authority quorum: %w", err) + } + if payload.ClusterAuthorityQuorumSHA256 == "" || payload.ClusterAuthorityQuorumSHA256 != descriptorHash { + return fmt.Errorf("node bootstrap cluster authority quorum hash mismatch") + } + } return nil } @@ -365,10 +388,11 @@ type syntheticMeshState struct { ProductionObservationSink *mesh.ProductionEnvelopeObservationSink ProductionForwardTransport mesh.ProductionForwardTransport ProductionForwardingEnabled bool + SyntheticForwardTransport *mesh.QUICSyntheticTransport VPNFabricInbox *vpnruntime.FabricPacketInbox VPNFabricIngress *vpnruntime.FabricClientPacketIngress + VPNPacketSessionPeers *vpnruntime.FabricSessionPacketPeerRegistry VPNFabricSessionPeers *mesh.FabricSessionPeerManager - VPNFabricTransport *mesh.WebSocketFabricTransport VPNFabricQUICTransport *mesh.QUICFabricTransport VPNFabricSessionDialStats *vpnFabricSessionDialStats VPNFabricEndpointObservations *vpnFabricEndpointObservationStore @@ -385,6 +409,8 @@ type syntheticMeshState struct { ListenerHandler *dynamicHTTPHandler StopListener func() QUICFabricServer *mesh.QUICFabricServer + QUICFabricConfiguredKey string + QUICFabricConfiguredListenAddr string QUICFabricListenAddr string QUICFabricCertSHA256 string QUICFabricError string @@ -420,8 +446,6 @@ type vpnFabricSessionDialStats struct { CapacityLimited atomic.Int64 AllCandidatesFailed atomic.Int64 QUICSelected atomic.Int64 - WebSocketSelected atomic.Int64 - LegacySelected atomic.Int64 PinnedCertSelected atomic.Int64 LastTransport atomic.Value LastEndpoint atomic.Value @@ -526,7 +550,27 @@ func (s *vpnFabricEndpointObservationStore) pruneLocked(now time.Time, maxAge ti } func (s *vpnFabricEndpointObservationStore) Report(observedAt time.Time, maxEntries int) map[string]any { - snapshot := s.Snapshot() + if observedAt.IsZero() { + observedAt = time.Now().UTC() + } + if s == nil { + return map[string]any{ + "schema_version": "rap.vpn_fabric_endpoint_health_report.v1", + "reporter_node_id": "", + "observed_at": observedAt.UTC().Format(time.RFC3339Nano), + "total": 0, + "reported": 0, + "dropped": 0, + "observations": []mesh.EndpointCandidateHealthObservation{}, + } + } + s.mu.Lock() + s.pruneLocked(observedAt.UTC(), vpnFabricEndpointObservationMaxAge, maxVPNFabricEndpointObservationEntries) + snapshot := make(map[string]mesh.EndpointCandidateHealthObservation, len(s.observations)) + for key, value := range s.observations { + snapshot[key] = value + } + s.mu.Unlock() if len(snapshot) == 0 { return map[string]any{ "schema_version": "rap.vpn_fabric_endpoint_health_report.v1", @@ -621,20 +665,61 @@ func (s *vpnFabricEndpointObservationStore) ObserveCapacity(endpointID string) { func fabricTransportLabelIsQUIC(label string) bool { switch strings.ToLower(strings.TrimSpace(label)) { - case "quic", "direct_quic", "udp_quic", "quic_udp": + case "quic", "direct_quic", "udp_quic", "quic_udp", "lan_quic", "reverse_quic", "relay_quic", "ice_quic": return true default: return false } } -func fabricTransportLabelIsWebSocket(label string) bool { - switch strings.ToLower(strings.TrimSpace(label)) { - case "websocket", "ws", "wss", "direct_http", "direct_https", "direct_tcp_tls": - return true - default: +func hasLegacyFabricEndpointScheme(endpoint string) bool { + endpoint = strings.ToLower(strings.TrimSpace(endpoint)) + return strings.HasPrefix(endpoint, "http://") || + strings.HasPrefix(endpoint, "https://") || + strings.HasPrefix(endpoint, "ws://") || + strings.HasPrefix(endpoint, "wss://") +} + +func fabricEndpointHasExplicitPort(endpoint string) bool { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { return false } + hostport := endpoint + if strings.Contains(endpoint, "://") { + parsed, err := url.Parse(endpoint) + if err != nil { + return false + } + hostport = parsed.Host + } + _, port, err := net.SplitHostPort(hostport) + return err == nil && strings.TrimSpace(port) != "" +} + +func normalizeFabricTransportLabelToQUIC(label string) string { + normalized := strings.ToLower(strings.TrimSpace(label)) + switch normalized { + case "direct_http", "direct_https", "direct_tcp_tls", "http", "https", "ws", "wss", "websocket": + return "direct_quic" + case "outbound_reverse", "reverse", "reverse_outbound": + return "reverse_quic" + case "relay", "relay_control": + return "relay_quic" + default: + return strings.TrimSpace(label) + } +} + +func normalizeFabricEndpointSchemeToQUIC(endpoint string) string { + trimmed := strings.TrimSpace(endpoint) + lowered := strings.ToLower(trimmed) + for _, prefix := range []string{"https://", "http://", "wss://", "ws://"} { + if strings.HasPrefix(lowered, prefix) { + return "quic://" + trimmed[len(prefix):] + } + } + return trimmed } func (s *vpnFabricSessionDialStats) ObserveCandidateFailure(reason string) { @@ -806,18 +891,13 @@ func (s *vpnFabricSessionDialStats) ObserveSelected(target mesh.FabricTransportT s.Selected.Add(1) transport := strings.TrimSpace(target.Transport) if transport == "" { - transport = "legacy_peer_endpoint" + transport = "quic" } s.LastTransport.Store(transport) s.LastEndpoint.Store(strings.TrimSpace(target.Endpoint)) s.LastSelectedUnixSec.Store(time.Now().UTC().Unix()) - switch { - case fabricTransportLabelIsQUIC(transport): + if fabricTransportLabelIsQUIC(transport) { s.QUICSelected.Add(1) - case transport == "legacy_peer_endpoint": - s.LegacySelected.Add(1) - case fabricTransportLabelIsWebSocket(transport): - s.WebSocketSelected.Add(1) } if strings.TrimSpace(target.PeerCertSHA256) != "" { s.PinnedCertSelected.Add(1) @@ -840,8 +920,6 @@ func (s *vpnFabricSessionDialStats) Report(observedAt time.Time) map[string]any "capacity_limited": s.CapacityLimited.Load(), "all_candidates_failed": s.AllCandidatesFailed.Load(), "quic_selected": s.QUICSelected.Load(), - "websocket_selected": s.WebSocketSelected.Load(), - "legacy_selected": s.LegacySelected.Load(), "pinned_cert_selected": s.PinnedCertSelected.Load(), "last_selected_unix_sec": s.LastSelectedUnixSec.Load(), "last_capacity_unix_sec": s.LastCapacityUnixSec.Load(), @@ -1238,14 +1316,20 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c PeerCache: peerCacheSnapshot, RecoveryPlan: peerRecoveryPlan, RendezvousLeases: loadedConfig.RendezvousLeases, + PreferredRegion: cfg.MeshRegion, Now: time.Now().UTC(), }) + vpnFabricQUICTransport := newVPNFabricQUICTransport(cfg) + vpnFabricQUICTransport.SetLocalPeerID(identity.NodeID) peerConnectionManager := mesh.NewPeerConnectionManager(mesh.PeerConnectionManagerConfig{ Local: local, PeerCache: peerCache, Tracker: peerConnections, RendezvousLeases: loadedConfig.RendezvousLeases, + QUICTransport: vpnFabricQUICTransport, + PreferredRegion: cfg.MeshRegion, }) + syntheticForwardTransport := mesh.NewQUICSyntheticTransportFromRouteSets(productionForwardRouteSetsFromCandidates(loadedConfig.PeerEndpointCandidates, loadedConfig.PeerEndpointObservations, identity.ClusterID, identity.NodeID), vpnFabricQUICTransport) routeGenerationTracker := newMeshRouteGenerationTracker(loadedConfig.RoutePathDecisions, time.Now().UTC()) gateEnabled, runtimeEnabled := productionForwardingLogState(cfg, loadedConfig.ProductionForwarding) log.Printf( @@ -1273,7 +1357,7 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c Local: local, Routes: routes, RouteHealthRoutes: routeHealthRoutes, - Transport: mesh.NewHTTPPeerTransport(peerEndpoints), + Transport: syntheticForwardTransport, Logger: func(entry mesh.SyntheticLogEntry) { payload, err := json.Marshal(entry) if err != nil { @@ -1290,7 +1374,7 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c } var productionForwardTransport mesh.ProductionForwardTransport if productionForwardingEnabled { - productionForwardTransport = mesh.NewHTTPProductionForwardTransport(peerEndpoints) + productionForwardTransport = mesh.NewQUICProductionForwardTransportFromRouteSets(productionForwardRouteSetsFromCandidates(loadedConfig.PeerEndpointCandidates, loadedConfig.PeerEndpointObservations, identity.ClusterID, identity.NodeID), vpnFabricQUICTransport) } vpnFabricInbox := vpnruntime.NewFabricPacketInbox(4096) serviceChannelAccessStats := newFabricServiceChannelAccessStats() @@ -1312,13 +1396,14 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c initialRouteManagerAt := time.Now().UTC() vpnFabricIngress.UpdateRouteManager(routeManagerDecisionsFromControlPlane(loadedConfig.RoutePathDecisions, loadedConfig.ServiceChannelRemediationCommands), loadedConfig.ConfigVersion, initialRouteManagerAt) vpnFabricIngress.UpdateRouteQualityPreferences(routeQualityPreferencesFromServiceChannelFeedback(loadedConfig.ServiceChannelFeedback, initialRouteManagerAt), initialRouteManagerAt) - serverHandler := mesh.Server{ + serverRuntime := mesh.Server{ Local: local, SyntheticRuntime: runtime, ProductionForwardingEnabled: productionForwardingEnabled, ProductionEnvelopeObserver: productionEnvelopeObserver, ProductionEnvelopeDelivery: vpnFabricInbox.DeliverProductionEnvelope, ProductionForwardTransport: productionForwardTransport, + DisableHTTPDataPlane: true, ProductionForwardLogger: func(entry mesh.ProductionForwardLogEntry) { payload, err := json.Marshal(entry) if err != nil { @@ -1350,13 +1435,13 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c VPNPacketIngress: vpnFabricIngress, BackendProxyBaseURL: cfg.BackendURL, ClusterAuthorityPublicKey: firstNonEmpty(identity.ClusterAuthorityPublicKey, cfg.ClusterAuthorityPublicKey), - }.Handler() + } + serverHandler := serverRuntime.Handler() dynamicListenerHandler := newDynamicHTTPHandler(serverHandler) listenerCfg := meshListenerRuntimeConfig(cfg, loadedConfig.MeshListener) listenerReport, stopListener := startSyntheticMeshHTTPServer(ctx, listenerCfg, identity, dynamicListenerHandler, len(peerEndpoints), len(routes), gateEnabled, runtimeEnabled) vpnFabricSessionPeers := mesh.NewFabricSessionPeerManager() - quicFabricServer, quicFabricAddr, quicFabricCertSHA256, quicFabricErr := startQUICFabricEndpoint(ctx, cfg, identity) - return &syntheticMeshState{ + meshState := &syntheticMeshState{ Runtime: runtime, Routes: routes, RouteHealthRoutes: routeHealthRoutes, @@ -1378,11 +1463,12 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c ProductionObservationSink: productionObservationSink, ProductionForwardTransport: productionForwardTransport, ProductionForwardingEnabled: productionForwardingEnabled, + SyntheticForwardTransport: syntheticForwardTransport, VPNFabricInbox: vpnFabricInbox, VPNFabricIngress: vpnFabricIngress, + VPNPacketSessionPeers: vpnruntime.NewFabricSessionPacketPeerRegistry(), VPNFabricSessionPeers: vpnFabricSessionPeers, - VPNFabricTransport: mesh.NewWebSocketFabricTransport(vpnFabricSessionPeers), - VPNFabricQUICTransport: newVPNFabricQUICTransport(cfg), + VPNFabricQUICTransport: vpnFabricQUICTransport, VPNFabricSessionDialStats: newVPNFabricSessionDialStats(), VPNFabricEndpointObservations: newVPNFabricEndpointObservationStore(identity.NodeID), PeerEndpoints: copyStringMap(peerEndpoints), @@ -1396,12 +1482,30 @@ func startSyntheticMeshEndpoint(ctx context.Context, _ context.CancelFunc, cfg c ListenerRuntimeConfig: listenerCfg, ListenerHandler: dynamicListenerHandler, StopListener: stopListener, - QUICFabricServer: quicFabricServer, - QUICFabricListenAddr: quicFabricAddr, - QUICFabricCertSHA256: quicFabricCertSHA256, - QUICFabricError: errorString(quicFabricErr), ConfigLoadError: errorString(err), - }, stopListener, nil + } + vpnFabricQUICTransport.SetInboundHandlersWithWebIngress( + productionForwardHandlerFromMeshState(identity, meshState), + webIngressForwardHandlerFromConfig(cfg, identity, api), + syntheticForwardHandlerFromMeshState(meshState), + func(entry mesh.FabricSessionEventLogEntry) { + payload, err := json.Marshal(entry) + if err != nil { + log.Printf("fabric quic reverse event marshal failed: %v", err) + return + } + log.Printf("fabric_quic_reverse_event=%s", string(payload)) + }, + ) + 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)) + meshState.QUICFabricServer = quicFabricServer + meshState.QUICFabricConfiguredKey = quicFabricConfigKey(cfg) + meshState.QUICFabricConfiguredListenAddr = strings.TrimSpace(cfg.MeshQUICFabricListenAddr) + meshState.QUICFabricListenAddr = quicFabricAddr + meshState.QUICFabricCertSHA256 = quicFabricCertSHA256 + meshState.QUICFabricError = errorString(quicFabricErr) + return meshState, stopListener, nil } func productionForwardingLogState(cfg config.Config, signedControlPlaneEnabled bool) (gateEnabled bool, runtimeEnabled bool) { @@ -1696,6 +1800,13 @@ func meshListenerRuntimeConfig(base config.Config, desired *client.MeshListenerC if desired.AdvertiseTransport != "" { 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 + } + } if desired.ConnectivityMode != "" { out.MeshConnectivityMode = strings.TrimSpace(desired.ConnectivityMode) } @@ -1749,20 +1860,26 @@ func bindSyntheticMeshListener(cfg config.Config) (net.Listener, string, bool, e return nil, "", false, err } -func startQUICFabricEndpoint(ctx context.Context, cfg config.Config, identity state.Identity) (*mesh.QUICFabricServer, string, string, error) { +func startQUICFabricEndpoint(ctx context.Context, cfg config.Config, identity state.Identity, reverseTransport *mesh.QUICFabricTransport, fabricFrameHandler mesh.FabricFrameHandler, productionForwardHandler func(context.Context, mesh.ProductionEnvelope) (mesh.ProductionForwardResult, error), webIngressForwardHandler func(context.Context, []byte) ([]byte, error), fabricControlHandler func(context.Context, []byte) ([]byte, error), syntheticForwardHandler func(context.Context, mesh.SyntheticEnvelope) (mesh.SyntheticEnvelope, error)) (*mesh.QUICFabricServer, string, string, error) { if !cfg.MeshQUICFabricEnabled { return nil, "", "", nil } if strings.TrimSpace(cfg.MeshQUICFabricListenAddr) == "" { return nil, "", "", fmt.Errorf("quic fabric enabled but listen addr is empty") } - tlsConfig, certSHA256, err := quicFabricTLSConfig(identity) + tlsConfig, certSHA256, err := quicFabricTLSConfig(cfg, identity) if err != nil { return nil, "", "", err } server, err := mesh.StartQUICFabricServer(ctx, mesh.QUICFabricServerConfig{ - ListenAddr: cfg.MeshQUICFabricListenAddr, - TLSConfig: tlsConfig, + ListenAddr: cfg.MeshQUICFabricListenAddr, + TLSConfig: tlsConfig, + ReverseTransport: reverseTransport, + FabricFrameHandler: fabricFrameHandler, + ProductionForwardHandler: productionForwardHandler, + WebIngressForwardHandler: webIngressForwardHandler, + FabricControlHandler: fabricControlHandler, + SyntheticForwardHandler: syntheticForwardHandler, Logger: func(entry mesh.FabricSessionEventLogEntry) { payload, err := json.Marshal(entry) if err != nil { @@ -1783,7 +1900,10 @@ func startQUICFabricEndpoint(ctx context.Context, cfg config.Config, identity st return server, addr, certSHA256, nil } -func quicFabricTLSConfig(identity state.Identity) (*tls.Config, string, error) { +func quicFabricTLSConfig(cfg config.Config, identity state.Identity) (*tls.Config, string, error) { + if tlsConfig, certSHA256, err := loadQUICFabricTLSConfig(cfg, identity); err == nil { + return tlsConfig, certSHA256, nil + } key, err := rsa.GenerateKey(rand.Reader, 2048) if err != nil { return nil, "", err @@ -1802,6 +1922,9 @@ func quicFabricTLSConfig(identity state.Identity) (*tls.Config, string, error) { if err != nil { return nil, "", err } + if err := storeQUICFabricTLSConfig(cfg, certDER, key); err != nil { + log.Printf("quic fabric certificate persist skipped: state_dir=%s error=%v", cfg.StateDir, err) + } sum := sha256.Sum256(certDER) return &tls.Config{ Certificates: []tls.Certificate{{ @@ -1812,6 +1935,68 @@ func quicFabricTLSConfig(identity state.Identity) (*tls.Config, string, error) { }, hex.EncodeToString(sum[:]), nil } +func loadQUICFabricTLSConfig(cfg config.Config, identity state.Identity) (*tls.Config, string, error) { + certPath, keyPath := quicFabricTLSConfigPaths(cfg) + if certPath == "" || keyPath == "" { + return nil, "", fmt.Errorf("state dir is required") + } + certPEM, err := os.ReadFile(certPath) + if err != nil { + return nil, "", err + } + keyPEM, err := os.ReadFile(keyPath) + if err != nil { + return nil, "", err + } + cert, err := tls.X509KeyPair(certPEM, keyPEM) + if err != nil { + return nil, "", err + } + if len(cert.Certificate) == 0 { + return nil, "", fmt.Errorf("persisted quic fabric certificate is empty") + } + parsed, err := x509.ParseCertificate(cert.Certificate[0]) + if err != nil { + return nil, "", err + } + if time.Until(parsed.NotAfter) < time.Hour { + return nil, "", fmt.Errorf("persisted quic fabric certificate is expired or near expiry") + } + commonName := firstNonEmpty(identity.NodeID, "rap-fabric-node") + if parsed.Subject.CommonName != commonName { + return nil, "", fmt.Errorf("persisted quic fabric certificate belongs to %q", parsed.Subject.CommonName) + } + sum := sha256.Sum256(cert.Certificate[0]) + return &tls.Config{ + Certificates: []tls.Certificate{cert}, + NextProtos: []string{"rap-fabric-data-session-v1"}, + }, hex.EncodeToString(sum[:]), nil +} + +func storeQUICFabricTLSConfig(cfg config.Config, certDER []byte, key *rsa.PrivateKey) error { + certPath, keyPath := quicFabricTLSConfigPaths(cfg) + if certPath == "" || keyPath == "" { + return fmt.Errorf("state dir is required") + } + if err := os.MkdirAll(filepath.Dir(certPath), 0o700); err != nil { + return err + } + certPEM := pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: certDER}) + keyPEM := pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}) + if err := os.WriteFile(certPath, certPEM, 0o644); err != nil { + return err + } + return os.WriteFile(keyPath, keyPEM, 0o600) +} + +func quicFabricTLSConfigPaths(cfg config.Config) (string, string) { + stateDir := strings.TrimSpace(cfg.StateDir) + if stateDir == "" { + return "", "" + } + return filepath.Join(stateDir, "quic-fabric.crt"), filepath.Join(stateDir, "quic-fabric.key") +} + func isAddressInUse(err error) bool { if err == nil { return false @@ -1861,7 +2046,7 @@ func loadSyntheticMeshConfig(ctx context.Context, cfg config.Config, identity st if err != nil { return loadedSyntheticMeshConfig{}, err } - return loadedSyntheticMeshConfig{ + loaded := loadedSyntheticMeshConfig{ PeerEndpoints: scoped.PeerEndpoints, PeerEndpointCandidates: scoped.PeerEndpointCandidates, PeerEndpointObservations: scoped.PeerEndpointObservations, @@ -1875,7 +2060,12 @@ func loadSyntheticMeshConfig(ctx context.Context, cfg config.Config, identity st PeerDirectoryVersion: scoped.PeerDirectoryVersion, PolicyVersion: scoped.PolicyVersion, ProductionForwarding: false, - }, nil + } + normalizeLoadedSyntheticMeshConfigQUICOnly(&loaded) + if err := validateLoadedSyntheticMeshConfigQUICOnly(loaded); err != nil { + return loadedSyntheticMeshConfig{}, err + } + return loaded, nil } if api != nil { remote, err := api.SyntheticMeshConfig(ctx, local.ClusterID, local.NodeID) @@ -1885,7 +2075,7 @@ func loadSyntheticMeshConfig(ctx context.Context, cfg config.Config, identity st } } if err == nil && remote.Enabled { - return loadedSyntheticMeshConfig{ + loaded := loadedSyntheticMeshConfig{ PeerEndpoints: remote.PeerEndpoints, PeerEndpointCandidates: peerEndpointCandidatesFromControlPlane(remote.PeerEndpointCandidates), PeerEndpointObservations: endpointCandidateObservationsFromControlPlane(remote.PeerEndpointObservations), @@ -1903,7 +2093,12 @@ func loadSyntheticMeshConfig(ctx context.Context, cfg config.Config, identity st PeerDirectoryVersion: remote.PeerDirectoryVersion, PolicyVersion: remote.PolicyVersion, ProductionForwarding: remote.ProductionForwarding, - }, nil + } + normalizeLoadedSyntheticMeshConfigQUICOnly(&loaded) + if err := validateLoadedSyntheticMeshConfigQUICOnly(loaded); err != nil { + return loadedSyntheticMeshConfig{}, err + } + return loaded, nil } if err != nil { log.Printf("control-plane synthetic mesh config unavailable, falling back to debug JSON: %v", err) @@ -1917,11 +2112,108 @@ func loadSyntheticMeshConfig(ctx context.Context, cfg config.Config, identity st if err != nil { return loadedSyntheticMeshConfig{}, err } - return loadedSyntheticMeshConfig{ + loaded := loadedSyntheticMeshConfig{ PeerEndpoints: peerEndpoints, Routes: routes, Source: "debug_json", - }, nil + } + normalizeLoadedSyntheticMeshConfigQUICOnly(&loaded) + if err := validateLoadedSyntheticMeshConfigQUICOnly(loaded); err != nil { + return loadedSyntheticMeshConfig{}, err + } + return loaded, nil +} + +func normalizeLoadedSyntheticMeshConfigQUICOnly(loaded *loadedSyntheticMeshConfig) { + if loaded == nil { + return + } + for nodeID, endpoint := range loaded.PeerEndpoints { + loaded.PeerEndpoints[nodeID] = normalizeFabricEndpointSchemeToQUIC(endpoint) + } + for nodeID, candidates := range loaded.PeerEndpointCandidates { + for index := range candidates { + candidates[index].Transport = normalizeFabricTransportLabelToQUIC(candidates[index].Transport) + candidates[index].Address = normalizeFabricEndpointSchemeToQUIC(candidates[index].Address) + if strings.TrimSpace(candidates[index].Transport) == "" && strings.HasPrefix(strings.ToLower(strings.TrimSpace(candidates[index].Address)), "quic://") { + candidates[index].Transport = "direct_quic" + } + } + loaded.PeerEndpointCandidates[nodeID] = candidates + } + for index := range loaded.RecoverySeeds { + loaded.RecoverySeeds[index].Transport = normalizeFabricTransportLabelToQUIC(loaded.RecoverySeeds[index].Transport) + loaded.RecoverySeeds[index].Endpoint = normalizeFabricEndpointSchemeToQUIC(loaded.RecoverySeeds[index].Endpoint) + } + for index := range loaded.RendezvousLeases { + loaded.RendezvousLeases[index].Transport = normalizeFabricTransportLabelToQUIC(loaded.RendezvousLeases[index].Transport) + loaded.RendezvousLeases[index].RelayEndpoint = normalizeFabricEndpointSchemeToQUIC(loaded.RendezvousLeases[index].RelayEndpoint) + } + if loaded.RoutePathDecisions != nil { + for index := range loaded.RoutePathDecisions.Decisions { + loaded.RoutePathDecisions.Decisions[index].SelectedRelayEndpoint = normalizeFabricEndpointSchemeToQUIC(loaded.RoutePathDecisions.Decisions[index].SelectedRelayEndpoint) + } + } +} + +func validateLoadedSyntheticMeshConfigQUICOnly(loaded loadedSyntheticMeshConfig) error { + for nodeID, endpoint := range loaded.PeerEndpoints { + if strings.TrimSpace(nodeID) == "" || strings.TrimSpace(endpoint) == "" { + return fmt.Errorf("synthetic mesh peer endpoint must include node_id and endpoint") + } + if hasLegacyFabricEndpointScheme(endpoint) { + return fmt.Errorf("synthetic mesh peer endpoint %q must be a QUIC endpoint", nodeID) + } + if !fabricEndpointHasExplicitPort(endpoint) { + return fmt.Errorf("synthetic mesh peer endpoint %q must include an explicit host:port", nodeID) + } + } + for nodeID, candidates := range loaded.PeerEndpointCandidates { + for _, candidate := range candidates { + if strings.TrimSpace(candidate.Transport) == "" || !fabricTransportLabelIsQUIC(candidate.Transport) { + return fmt.Errorf("peer endpoint candidate %q for node %q must use a QUIC transport label", candidate.EndpointID, nodeID) + } + if strings.TrimSpace(candidate.Address) == "" || hasLegacyFabricEndpointScheme(candidate.Address) { + return fmt.Errorf("peer endpoint candidate %q for node %q must use a QUIC endpoint", candidate.EndpointID, nodeID) + } + if !fabricEndpointHasExplicitPort(candidate.Address) { + return fmt.Errorf("peer endpoint candidate %q for node %q must include an explicit host:port", candidate.EndpointID, nodeID) + } + } + } + for _, seed := range loaded.RecoverySeeds { + if strings.TrimSpace(seed.Transport) == "" || !fabricTransportLabelIsQUIC(seed.Transport) { + return fmt.Errorf("recovery seed for node %q must use a QUIC transport label", seed.NodeID) + } + if strings.TrimSpace(seed.Endpoint) == "" || hasLegacyFabricEndpointScheme(seed.Endpoint) { + return fmt.Errorf("recovery seed for node %q must use a QUIC endpoint", seed.NodeID) + } + if !fabricEndpointHasExplicitPort(seed.Endpoint) { + return fmt.Errorf("recovery seed for node %q must include an explicit host:port", seed.NodeID) + } + } + for _, lease := range loaded.RendezvousLeases { + if strings.TrimSpace(lease.Transport) == "" || !fabricTransportLabelIsQUIC(lease.Transport) { + return fmt.Errorf("rendezvous lease %q must use a QUIC transport label", lease.LeaseID) + } + if strings.TrimSpace(lease.RelayEndpoint) == "" || hasLegacyFabricEndpointScheme(lease.RelayEndpoint) { + return fmt.Errorf("rendezvous lease %q must use a QUIC relay endpoint", lease.LeaseID) + } + if !fabricEndpointHasExplicitPort(lease.RelayEndpoint) { + return fmt.Errorf("rendezvous lease %q must include an explicit relay host:port", lease.LeaseID) + } + } + if loaded.RoutePathDecisions != nil { + for _, decision := range loaded.RoutePathDecisions.Decisions { + if strings.TrimSpace(decision.SelectedRelayEndpoint) != "" && hasLegacyFabricEndpointScheme(decision.SelectedRelayEndpoint) { + return fmt.Errorf("route path decision %q must use a QUIC selected relay endpoint", decision.DecisionID) + } + if strings.TrimSpace(decision.SelectedRelayEndpoint) != "" && !fabricEndpointHasExplicitPort(decision.SelectedRelayEndpoint) { + return fmt.Errorf("route path decision %q must include an explicit selected relay host:port", decision.DecisionID) + } + } + } + return nil } type controlPlaneMeshConfigAuthorityPayload struct { @@ -2259,6 +2551,7 @@ func applyRefreshedSyntheticMeshConfig(ctx context.Context, cfg config.Config, i PeerCache: peerCache.Snapshot(), RecoveryPlan: peerRecoveryPlan, RendezvousLeases: loadedConfig.RendezvousLeases, + PreferredRegion: preferredRegion, Now: observedAt, }) if meshState.PeerConnectionManager == nil { @@ -2267,14 +2560,12 @@ func applyRefreshedSyntheticMeshConfig(ctx context.Context, cfg config.Config, i PeerCache: peerCache, Tracker: meshState.PeerConnections, RendezvousLeases: loadedConfig.RendezvousLeases, + QUICTransport: meshState.VPNFabricQUICTransport, + PreferredRegion: preferredRegion, }) } else { meshState.PeerConnectionManager.UpdatePeerConfig(peerCache, loadedConfig.RendezvousLeases) } - if meshState.Runtime != nil { - meshState.Runtime.UpdateConfig(loadedConfig.Routes, mesh.NewHTTPPeerTransport(loadedConfig.PeerEndpoints)) - meshState.Runtime.UpdateRouteHealthConfig(routeHealthRoutes) - } if meshState.RouteGenerationTracker == nil { meshState.RouteGenerationTracker = newMeshRouteGenerationTracker(loadedConfig.RoutePathDecisions, observedAt) } else { @@ -2288,23 +2579,42 @@ func applyRefreshedSyntheticMeshConfig(ctx context.Context, cfg config.Config, i _ = meshState.VPNFabricQUICTransport.Close() } meshState.VPNFabricSessionPeers = mesh.NewFabricSessionPeerManager() - meshState.VPNFabricTransport = mesh.NewWebSocketFabricTransport(meshState.VPNFabricSessionPeers) meshState.VPNFabricQUICTransport = newVPNFabricQUICTransport(cfg) + meshState.VPNFabricQUICTransport.SetLocalPeerID(identity.NodeID) } if meshState.VPNFabricSessionPeers == nil { meshState.VPNFabricSessionPeers = mesh.NewFabricSessionPeerManager() } - if meshState.VPNFabricTransport == nil { - meshState.VPNFabricTransport = mesh.NewWebSocketFabricTransport(meshState.VPNFabricSessionPeers) - } if meshState.VPNFabricQUICTransport == nil { meshState.VPNFabricQUICTransport = newVPNFabricQUICTransport(cfg) + meshState.VPNFabricQUICTransport.SetLocalPeerID(identity.NodeID) } else if cfg.VPNFabricQUICMaxStreamsPerConn > 0 { meshState.VPNFabricQUICTransport.MaxStreamsPerConn = cfg.VPNFabricQUICMaxStreamsPerConn } + if meshState.VPNFabricQUICTransport != nil { + meshState.VPNFabricQUICTransport.SetLocalPeerID(identity.NodeID) + meshState.VPNFabricQUICTransport.SetInboundHandlers( + productionForwardHandlerFromMeshState(identity, meshState), + syntheticForwardHandlerFromMeshState(meshState), + func(entry mesh.FabricSessionEventLogEntry) { + payload, err := json.Marshal(entry) + if err != nil { + log.Printf("fabric quic reverse event marshal failed: %v", err) + return + } + log.Printf("fabric_quic_reverse_event=%s", string(payload)) + }, + ) + if meshState.QUICFabricServer != nil { + meshState.QUICFabricServer.SetReverseTransport(meshState.VPNFabricQUICTransport) + } + } if meshState.VPNFabricQUICTransport != nil && cfg.VPNFabricQUICIdleTTL > 0 { meshState.VPNFabricQUICTransport.IdleTTL = cfg.VPNFabricQUICIdleTTL } + if meshState.PeerConnectionManager != nil { + meshState.PeerConnectionManager.UpdateQUICTransport(meshState.VPNFabricQUICTransport) + } if meshState.VPNFabricSessionDialStats == nil { meshState.VPNFabricSessionDialStats = newVPNFabricSessionDialStats() } @@ -2314,8 +2624,13 @@ func applyRefreshedSyntheticMeshConfig(ctx context.Context, cfg config.Config, i meshState.PeerEndpoints = copyStringMap(loadedConfig.PeerEndpoints) meshState.PeerEndpointCandidates = copyPeerEndpointCandidatesMap(loadedConfig.PeerEndpointCandidates) meshState.PeerEndpointObservations = copyEndpointCandidateObservations(loadedConfig.PeerEndpointObservations) + meshState.SyntheticForwardTransport = mesh.NewQUICSyntheticTransportFromRouteSets(productionForwardRouteSetsFromCandidates(loadedConfig.PeerEndpointCandidates, loadedConfig.PeerEndpointObservations, identity.ClusterID, identity.NodeID), meshState.VPNFabricQUICTransport) + if meshState.Runtime != nil { + meshState.Runtime.UpdateConfig(loadedConfig.Routes, meshState.SyntheticForwardTransport) + meshState.Runtime.UpdateRouteHealthConfig(routeHealthRoutes) + } if productionForwardingEnabled { - meshState.ProductionForwardTransport = mesh.NewHTTPProductionForwardTransport(loadedConfig.PeerEndpoints) + meshState.ProductionForwardTransport = mesh.NewQUICProductionForwardTransportFromRouteSets(productionForwardRouteSetsFromCandidates(loadedConfig.PeerEndpointCandidates, loadedConfig.PeerEndpointObservations, identity.ClusterID, identity.NodeID), meshState.VPNFabricQUICTransport) } else { meshState.ProductionForwardTransport = nil } @@ -2338,6 +2653,7 @@ func applyRefreshedSyntheticMeshConfig(ctx context.Context, cfg config.Config, i return meshState.VPNFabricInbox.DeliverProductionEnvelope }(), ProductionForwardTransport: meshState.ProductionForwardTransport, + DisableHTTPDataPlane: true, ProductionForwardLogger: func(entry mesh.ProductionForwardLogEntry) { payload, err := json.Marshal(entry) if err != nil { @@ -2375,8 +2691,8 @@ func applyRefreshedSyntheticMeshConfig(ctx context.Context, cfg config.Config, i } else { meshState.ListenerHandler.Update(nextListenerHandler) } - applyQUICFabricConfigIfChanged(ctx, cfg, identity, meshState) applyMeshListenerConfigIfChanged(ctx, cfg, identity, meshState, loadedConfig, observedAt) + applyQUICFabricConfigIfChanged(ctx, meshState.ListenerRuntimeConfig, identity, meshState) meshState.Routes = loadedConfig.Routes meshState.RouteHealthRoutes = routeHealthRoutes meshState.Source = loadedConfig.Source @@ -2426,10 +2742,28 @@ func applyQUICFabricConfigIfChanged(ctx context.Context, cfg config.Config, iden if meshState == nil { return } + if meshState.VPNFabricQUICTransport != nil { + meshState.VPNFabricQUICTransport.SetInboundHandlersWithWebIngress( + productionForwardHandlerFromMeshState(identity, meshState), + webIngressForwardHandlerFromConfig(cfg, identity, client.New(cfg.BackendURL)), + syntheticForwardHandlerFromMeshState(meshState), + func(entry mesh.FabricSessionEventLogEntry) { + payload, err := json.Marshal(entry) + if err != nil { + log.Printf("fabric quic reverse event marshal failed: %v", err) + return + } + log.Printf("fabric_quic_reverse_event=%s", string(payload)) + }, + ) + } desiredAddr := strings.TrimSpace(cfg.MeshQUICFabricListenAddr) - if meshState.QUICFabricServer != nil && (!cfg.MeshQUICFabricEnabled || meshState.QUICFabricListenAddr != desiredAddr) { + desiredKey := quicFabricConfigKey(cfg) + if meshState.QUICFabricServer != nil && (!cfg.MeshQUICFabricEnabled || meshState.QUICFabricConfiguredListenAddr != desiredAddr || meshState.QUICFabricConfiguredKey != desiredKey) { _ = meshState.QUICFabricServer.Close() meshState.QUICFabricServer = nil + meshState.QUICFabricConfiguredKey = "" + meshState.QUICFabricConfiguredListenAddr = "" meshState.QUICFabricListenAddr = "" meshState.QUICFabricCertSHA256 = "" } @@ -2441,8 +2775,10 @@ func applyQUICFabricConfigIfChanged(ctx context.Context, cfg config.Config, iden if meshState.QUICFabricServer != nil { return } - server, addr, certSHA256, err := startQUICFabricEndpoint(ctx, cfg, identity) + server, addr, certSHA256, err := startQUICFabricEndpoint(ctx, cfg, identity, meshState.VPNFabricQUICTransport, vpnFabricFrameHandlerFromMeshState(meshState), productionForwardHandlerFromMeshState(identity, meshState), webIngressForwardHandlerFromConfig(cfg, identity, client.New(cfg.BackendURL)), syntheticForwardHandlerFromMeshState(meshState)) meshState.QUICFabricServer = server + meshState.QUICFabricConfiguredKey = desiredKey + meshState.QUICFabricConfiguredListenAddr = desiredAddr meshState.QUICFabricListenAddr = addr meshState.QUICFabricCertSHA256 = certSHA256 meshState.QUICFabricError = errorString(err) @@ -2451,6 +2787,155 @@ func applyQUICFabricConfigIfChanged(ctx context.Context, cfg config.Config, iden } } +func quicFabricConfigKey(cfg config.Config) string { + parts := []string{ + fmt.Sprintf("%t", cfg.MeshQUICFabricEnabled), + strings.TrimSpace(cfg.MeshQUICFabricListenAddr), + strings.TrimSpace(cfg.WebIngressTrustedKeysJSON), + strings.TrimSpace(cfg.WebIngressRuntimeServiceClasses), + } + return strings.Join(parts, "\x00") +} + +func syntheticForwardHandlerFromMeshState(meshState *syntheticMeshState) func(context.Context, mesh.SyntheticEnvelope) (mesh.SyntheticEnvelope, error) { + return func(ctx context.Context, envelope mesh.SyntheticEnvelope) (mesh.SyntheticEnvelope, error) { + if meshState == nil || meshState.Runtime == nil { + return mesh.SyntheticEnvelope{}, mesh.ErrMeshRuntimeDisabled + } + return meshState.Runtime.Receive(ctx, envelope) + } +} + +func webIngressForwardHandlerFromConfig(cfg config.Config, identity state.Identity, api *client.Client) func(context.Context, []byte) ([]byte, error) { + trustedKeys, err := webingress.ParseTrustedKeysJSON(cfg.WebIngressTrustedKeysJSON) + if err != nil || len(trustedKeys) == 0 { + return nil + } + receiver := webingress.FabricRuntimeReceiver{ + Config: webingress.ReceiverConfig{ + ServiceClasses: webIngressRuntimeServiceClassesFromConfig(cfg), + }, + Keys: trustedKeys, + Handler: webingress.AdminRuntimeDispatcher{ + ProjectionClient: controlAPIProjectionClient{API: api, ClusterID: identity.ClusterID, NodeID: identity.NodeID}, + }, + } + return receiver.Receive +} + +func webIngressRuntimeServiceClassesFromConfig(cfg config.Config) []string { + serviceClasses := strings.Split(strings.TrimSpace(cfg.WebIngressRuntimeServiceClasses), ",") + out := make([]string, 0, len(serviceClasses)) + seen := map[string]struct{}{} + for _, serviceClass := range serviceClasses { + serviceClass = strings.TrimSpace(serviceClass) + if serviceClass == "" || !webIngressRuntimeServiceClassAllowed(serviceClass) { + continue + } + if _, ok := seen[serviceClass]; ok { + continue + } + seen[serviceClass] = struct{}{} + out = append(out, serviceClass) + } + if len(out) > 0 { + return out + } + return []string{"platform_admin", "cluster_admin", "organization_portal", "user_portal"} +} + +func webIngressRuntimeServiceClassAllowed(serviceClass string) bool { + switch serviceClass { + case "platform_admin", "cluster_admin", "organization_portal", "user_portal": + return true + default: + return false + } +} + +type controlAPIProjectionClient struct { + API *client.Client + ClusterID string + NodeID string +} + +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") + } + response, err := c.API.AdminRuntimeProjection(ctx, c.ClusterID, c.NodeID, client.AdminRuntimeProjectionRequest{ + SchemaVersion: request.SchemaVersion, + Method: request.Method, + Path: request.Path, + Query: request.Query, + Host: request.Host, + Scope: request.Scope, + ServiceClass: request.ServiceClass, + ObservedAt: request.ObservedAt, + }) + if 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 +} + +func productionForwardHandlerFromMeshState(identity state.Identity, meshState *syntheticMeshState) func(context.Context, mesh.ProductionEnvelope) (mesh.ProductionForwardResult, error) { + return func(ctx context.Context, envelope mesh.ProductionEnvelope) (mesh.ProductionForwardResult, error) { + if meshState == nil { + return mesh.ProductionForwardResult{}, mesh.ErrForwardRuntimeUnavailable + } + var observer mesh.ProductionEnvelopeObserver + if meshState.ProductionObservationSink != nil { + observer = meshState.ProductionObservationSink.Observe + } + var delivery mesh.ProductionEnvelopeDelivery + if meshState.VPNFabricInbox != nil { + delivery = meshState.VPNFabricInbox.DeliverProductionEnvelope + } + server := mesh.Server{ + Local: mesh.PeerIdentity{ClusterID: identity.ClusterID, NodeID: identity.NodeID}, + ProductionForwardingEnabled: meshState.ProductionForwardingEnabled, + ProductionEnvelopeObserver: observer, + ProductionEnvelopeDelivery: delivery, + ProductionForwardTransport: meshState.ProductionForwardTransport, + ProductionRoutes: meshState.Routes, + } + return server.ForwardProduction(ctx, envelope) + } +} + +func vpnFabricFrameHandlerFromMeshState(meshState *syntheticMeshState) mesh.FabricFrameHandler { + return func(ctx context.Context, sender mesh.FabricFrameSender, frame fabricproto.Frame) (bool, error) { + if meshState == nil || meshState.VPNFabricInbox == nil || meshState.VPNPacketSessionPeers == nil { + return false, nil + } + handled, err := meshState.VPNPacketSessionPeers.RegisterFrame(ctx, sender, frame) + if err != nil { + if handled { + log.Printf("vpn fabric session peer registration failed after handling frame: %v", err) + return true, nil + } + log.Printf("vpn fabric session peer registration ignored frame error: %v", err) + return false, nil + } + if !handled { + return false, nil + } + if err := meshState.VPNFabricInbox.DeliverFabricSessionFrame(ctx, frame); err != nil { + log.Printf("vpn fabric session packet delivery failed; keeping quic session open: %v", err) + return true, nil + } + return true, nil + } +} + func meshRendezvousLeasePostureForState(meshState *syntheticMeshState, identity state.Identity, observedAt time.Time) meshRendezvousLeasePosture { posture := meshRendezvousLeasePosture{} if meshState == nil { @@ -2928,7 +3413,7 @@ func probeWarmPeerHealth(ctx context.Context, api *client.Client, identity state "backoff_until": result.ConnectionState.BackoffUntil, "service_workload_traffic": false, "persistent_connection_manager": true, - "persistent_connection_kind": "http_keepalive_control_health_or_relay_control_health", + "persistent_connection_kind": "quic_control_health_or_relay_control_health", } if result.FailureReason != "" { metadata["failure_reason"] = result.FailureReason @@ -3106,6 +3591,7 @@ func peerConnectionIntentPlan(meshState *syntheticMeshState, recoveryPlan mesh.P PeerCache: meshState.PeerCache.Snapshot(), RecoveryPlan: recoveryPlan, RendezvousLeases: meshState.RendezvousLeases, + PreferredRegion: meshState.ListenerRuntimeConfig.MeshRegion, Now: now, }) } @@ -3191,7 +3677,8 @@ func heartbeatPayload(cfg config.Config, identity state.Identity, meshState *syn log.Printf("mesh endpoint report skipped: %v", err) return payload } - if len(candidates) == 0 && (meshState == nil || (meshState.PeerCache == nil && meshState.ListenerReport.SchemaVersion == "")) { + webIngressReportRequested := cfg.MeshQUICFabricEnabled || strings.TrimSpace(cfg.WebIngressTrustedKeysJSON) != "" + if len(candidates) == 0 && !webIngressReportRequested && (meshState == nil || (meshState.PeerCache == nil && meshState.ListenerReport.SchemaVersion == "")) { return payload } if payload.Metadata == nil { @@ -3224,12 +3711,15 @@ func heartbeatPayload(cfg config.Config, identity state.Identity, meshState *syn if cfg.MeshProductionForwardingEnabled || (meshState != nil && meshState.ProductionForwardingEnabled) { payload.Capabilities["mesh_production_forwarding"] = true } + if meshState != nil { + payload.Metadata["fabric_runtime_report"] = fabricRuntimeReport(meshState, observedAt) + payload.Capabilities["fabric_runtime_telemetry"] = true + } if cfg.MeshFabricSessionEnabled { report := map[string]any{ "schema_version": "rap.fabric_session_endpoint_report.v1", "enabled": true, - "transport": "websocket_binary_frames", - "path": "/mesh/v1/fabric/session/ws", + "transport": "quic", "auth": "rap_fsn_token_with_optional_signed_authority", "protocol": "rap.fabric_data_session.v1", "service_neutral": true, @@ -3246,7 +3736,6 @@ func heartbeatPayload(cfg config.Config, identity state.Identity, meshState *syn } } payload.Metadata["fabric_session_endpoint_report"] = report - payload.Capabilities["fabric_session_websocket_endpoint"] = true payload.Capabilities["fabric_data_session_v1"] = true if cfg.MeshQUICFabricEnabled { payload.Capabilities["fabric_quic_endpoint"] = true @@ -3257,7 +3746,7 @@ func heartbeatPayload(cfg config.Config, identity state.Identity, meshState *syn "schema_version": "rap.vpn_fabric_session_transport_report.v1", "enabled": true, "transport": "fabric_session_binary_frames", - "carriers": []string{"quic", "websocket"}, + "carriers": []string{"quic"}, "packet_payload": "rap.vpn_packet_batch.fabric.v1", "gated": true, "stream_shards_per_class": cfg.VPNFabricSessionStreamShards, @@ -3267,9 +3756,7 @@ func heartbeatPayload(cfg config.Config, identity state.Identity, meshState *syn }, "observed_at": observedAt.UTC().Format(time.RFC3339Nano), } - if meshState != nil && meshState.VPNFabricTransport != nil { - report["peer_sessions"] = meshState.VPNFabricTransport.Snapshot() - } else if meshState != nil && meshState.VPNFabricSessionPeers != nil { + if meshState != nil && meshState.VPNFabricSessionPeers != nil { report["peer_sessions"] = meshState.VPNFabricSessionPeers.Snapshot() } if meshState != nil && meshState.VPNFabricQUICTransport != nil { @@ -3305,6 +3792,10 @@ func heartbeatPayload(cfg config.Config, identity state.Identity, meshState *syn payload.HealthStatus = "warning" } } + if webIngressReportRequested { + payload.Metadata["web_ingress_runtime_receiver_report"] = webIngressRuntimeReceiverReport(cfg, meshState, observedAt) + payload.Capabilities["web_ingress_runtime_receiver"] = true + } if len(candidates) > 0 { payload.Metadata["mesh_endpoint_report"] = meshEndpointReport(cfg, identity, meshState, observedAt, candidates) payload.Capabilities["mesh_dynamic_endpoint_reporting"] = true @@ -3372,6 +3863,65 @@ func fabricServiceChannelRuntimeReport(meshState *syntheticMeshState, identity s return report } +func webIngressRuntimeReceiverReport(cfg config.Config, meshState *syntheticMeshState, observedAt time.Time) map[string]any { + trustedKeys, err := webingress.ParseTrustedKeysJSON(cfg.WebIngressTrustedKeysJSON) + serviceClasses := webIngressRuntimeServiceClassesFromConfig(cfg) + report := map[string]any{ + "schema_version": "rap.web_ingress.runtime_receiver_report.v1", + "transport": "quic", + "quic_stream": "web_ingress_forward", + "quic_stream_id": mesh.WebIngressForwardQUICStreamID, + "request_schema": webingress.SignedFabricServiceChannelEnvelopeSchema, + "runtime_response_schema": webingress.FabricRuntimeResponseSchema, + "control_projection_schema": webingress.ControlAPIProjectionResponseSchema, + "trusted_key_count": len(trustedKeys), + "service_classes": serviceClasses, + "observed_at": observedAt.UTC().Format(time.RFC3339Nano), + } + if err != nil { + report["enabled"] = false + report["handler_installed"] = false + report["status"] = "blocked" + report["reason"] = "trusted_keys_invalid" + report["error"] = err.Error() + return report + } + if len(trustedKeys) == 0 { + report["enabled"] = false + report["handler_installed"] = false + report["status"] = "blocked" + report["reason"] = "trusted_keys_required" + return report + } + report["enabled"] = true + report["handler_installed"] = true + report["status"] = "degraded" + report["reason"] = "quic_fabric_status_unknown" + if meshState != nil { + quicFabricEnabled := meshState.QUICFabricServer != nil || strings.TrimSpace(meshState.QUICFabricListenAddr) != "" || strings.TrimSpace(cfg.MeshQUICFabricListenAddr) != "" + quicFabricReady := meshState.QUICFabricServer != nil + report["quic_fabric_enabled"] = quicFabricEnabled + report["quic_fabric_ready"] = quicFabricReady + report["quic_fabric_listen_addr"] = meshState.QUICFabricListenAddr + report["quic_fabric_error"] = meshState.QUICFabricError + switch { + case quicFabricReady: + report["status"] = "ready" + report["reason"] = "signed_envelope_receiver_available" + case strings.TrimSpace(meshState.QUICFabricError) != "": + report["status"] = "degraded" + report["reason"] = "quic_fabric_error" + case !quicFabricEnabled: + report["status"] = "degraded" + report["reason"] = "quic_fabric_not_enabled" + default: + report["status"] = "degraded" + report["reason"] = "quic_fabric_not_ready" + } + } + return report +} + func countVPNPacketRoutes(routes []mesh.SyntheticRoute, clusterID string, localNodeID string) int { count := 0 now := time.Now().UTC() @@ -3453,7 +4003,7 @@ func meshEndpointReport(cfg config.Config, identity state.Identity, meshState *s transport = strings.TrimSpace(cfg.MeshAdvertiseTransport) } if transport == "" { - transport = "direct_tcp_tls" + transport = "quic" } connectivityMode := cfg.MeshConnectivityMode if connectivityMode == "" { @@ -3521,6 +4071,118 @@ func meshEndpointReport(cfg config.Config, identity state.Identity, meshState *s return report } +func fabricRuntimeReport(meshState *syntheticMeshState, observedAt time.Time) map[string]any { + var mem runtime.MemStats + runtime.ReadMemStats(&mem) + report := map[string]any{ + "schema_version": "rap.fabric_runtime_report.v1", + "observed_at": observedAt.UTC().Format(time.RFC3339Nano), + "quic_only": true, + "transport": "quic", + "goroutines": runtime.NumGoroutine(), + "heap_alloc_bytes": mem.HeapAlloc, + "heap_inuse_bytes": mem.HeapInuse, + "heap_objects": mem.HeapObjects, + "gc_count": mem.NumGC, + "peer_candidate_nodes": 0, + "peer_candidate_total": 0, + "peer_candidate_quic_total": 0, + "peer_candidate_rejected_total": 0, + "route_set_nodes": 0, + "route_set_total": 0, + "route_pressure_active": 0, + "route_pressure_max": 0, + "production_forwarding": false, + "listener_quic_enabled": false, + "listener_quic_ready": false, + "listener_quic_error": "", + "http_data_plane_disabled": true, + "acceptance_soak_ready": true, + "recommended_next_gate": "30-120m-real-public-nat-lan-relay-soak", + } + if meshState == nil { + return report + } + report["production_forwarding"] = meshState.ProductionForwardingEnabled + report["listener_quic_enabled"] = meshState.QUICFabricListenAddr != "" || meshState.QUICFabricServer != nil + report["listener_quic_ready"] = meshState.QUICFabricServer != nil + report["listener_quic_addr"] = meshState.QUICFabricListenAddr + report["listener_quic_cert_sha256"] = meshState.QUICFabricCertSHA256 + report["listener_quic_error"] = meshState.QUICFabricError + candidateNodes, candidateTotal, quicCandidateTotal, rejectedCandidateTotal, rejectedByTransport := fabricCandidateTransportPolicyStats(meshState.PeerEndpointCandidates) + report["peer_candidate_nodes"] = candidateNodes + report["peer_candidate_total"] = candidateTotal + report["peer_candidate_quic_total"] = quicCandidateTotal + report["peer_candidate_rejected_total"] = rejectedCandidateTotal + if len(rejectedByTransport) > 0 { + report["peer_candidate_rejected_by_transport"] = rejectedByTransport + } + routeSets := productionForwardRouteSetsFromCandidates(meshState.PeerEndpointCandidates, meshState.PeerEndpointObservations, "", "") + routeSetTotal := 0 + for _, routeSet := range routeSets { + routeSetTotal += len(flattenFabricRouteSetForReport(routeSet)) + } + report["route_set_nodes"] = len(routeSets) + report["route_set_total"] = routeSetTotal + if transport, ok := meshState.ProductionForwardTransport.(*mesh.QUICProductionForwardTransport); ok && transport != nil { + snapshot := transport.Snapshot() + report["production_route_pressure"] = snapshot.RoutePressure + report["production_route_health"] = snapshot.RouteHealth + report["route_health_quarantined"] = len(snapshot.RouteHealth.Quarantined) + report["route_pressure_active"] = snapshot.RoutePressure.ActiveTotal + report["route_pressure_max"] = snapshot.RoutePressure.MaxActiveTotal + } + if meshState.SyntheticForwardTransport != nil { + snapshot := meshState.SyntheticForwardTransport.Snapshot() + report["synthetic_route_pressure"] = snapshot.RoutePressure + report["synthetic_route_health"] = snapshot.RouteHealth + } + if meshState.VPNFabricQUICTransport != nil { + report["quic_sessions"] = meshState.VPNFabricQUICTransport.Snapshot() + } + return report +} + +func fabricCandidateTransportPolicyStats(candidatesByNode map[string][]mesh.PeerEndpointCandidate) (int, int, int, int, map[string]int) { + candidateNodes := 0 + candidateTotal := 0 + quicCandidateTotal := 0 + rejectedCandidateTotal := 0 + rejectedByTransport := map[string]int{} + for _, candidates := range candidatesByNode { + if len(candidates) > 0 { + candidateNodes++ + candidateTotal += len(candidates) + } + for _, candidate := range candidates { + transport := strings.ToLower(strings.TrimSpace(candidate.Transport)) + if transport == "" { + transport = "empty" + } + if fabricTransportLabelIsQUIC(transport) { + quicCandidateTotal++ + continue + } + rejectedCandidateTotal++ + rejectedByTransport[transport]++ + } + } + if len(rejectedByTransport) == 0 { + rejectedByTransport = nil + } + return candidateNodes, candidateTotal, quicCandidateTotal, rejectedCandidateTotal, rejectedByTransport +} + +func flattenFabricRouteSetForReport(routeSet mesh.FabricRouteSet) []mesh.FabricRoute { + routes := make([]mesh.FabricRoute, 0, 1+len(routeSet.WarmStandby)+len(routeSet.ColdFallbacks)) + if strings.TrimSpace(routeSet.Primary.RouteID) != "" { + routes = append(routes, routeSet.Primary) + } + routes = append(routes, routeSet.WarmStandby...) + routes = append(routes, routeSet.ColdFallbacks...) + return routes +} + func meshPeerRecoveryReport(meshState *syntheticMeshState, observedAt time.Time) map[string]any { plan := peerRecoveryPlan(meshState, observedAt) meshState.LastPeerRecoveryPlan = &plan @@ -3596,7 +4258,7 @@ func meshPeerConnectionManagerReport(meshState *syntheticMeshState, observedAt t "service_workload_traffic": false, "production_payload_forwarding": false, "persistent_connection_transport": true, - "persistent_connection_kind": "http_keepalive_control_health_or_relay_control_health", + "persistent_connection_kind": "quic_control_health_or_relay_control_health", "observed_at": observedAt.UTC().Format(time.RFC3339Nano), } if meshState == nil || meshState.PeerConnectionManager == nil { @@ -3747,7 +4409,7 @@ func meshRendezvousLeaseReport(meshState *syntheticMeshState, identity state.Ide "peer_node_id": lease.PeerNodeID, "relay_node_id": lease.RelayNodeID, "relay_endpoint": strings.TrimRight(strings.TrimSpace(lease.RelayEndpoint), "/"), - "transport": defaultString(lease.Transport, "relay_control"), + "transport": defaultString(lease.Transport, "relay_quic"), "connectivity_mode": defaultString(lease.ConnectivityMode, "relay_required"), "route_ids": append([]string{}, lease.RouteIDs...), "allowed_channels": append([]string{}, lease.AllowedChannels...), @@ -4494,8 +5156,9 @@ func formatOptionalTime(value time.Time) string { func advertisedEndpointCandidates(cfg config.Config, identity state.Identity, meshState *syntheticMeshState, observedAt time.Time) ([]mesh.PeerEndpointCandidate, error) { var candidates []mesh.PeerEndpointCandidate + var configuredCandidates []mesh.PeerEndpointCandidate if cfg.MeshAdvertiseEndpointsJSON != "" { - if err := json.Unmarshal([]byte(cfg.MeshAdvertiseEndpointsJSON), &candidates); err != nil { + if err := json.Unmarshal([]byte(cfg.MeshAdvertiseEndpointsJSON), &configuredCandidates); err != nil { return nil, fmt.Errorf("parse RAP_MESH_ADVERTISE_ENDPOINTS_JSON: %w", err) } } @@ -4510,6 +5173,39 @@ func advertisedEndpointCandidates(cfg config.Config, identity state.Identity, me ConnectivityMode: cfg.MeshConnectivityMode, Region: cfg.MeshRegion, Priority: 10, + Metadata: advertisedEndpointMetadata(cfg, "operator-advertised-endpoint", ""), + }) + } + candidates = append(candidates, configuredCandidates...) + if cfg.MeshSTUNReflexiveEndpoint != "" { + candidates = append(candidates, mesh.PeerEndpointCandidate{ + EndpointID: identity.NodeID + "-stun-reflexive", + NodeID: identity.NodeID, + Transport: "ice_quic", + Address: cfg.MeshSTUNReflexiveEndpoint, + AddressFamily: addressFamilyForEndpoint(cfg.MeshSTUNReflexiveEndpoint), + Reachability: "public", + NATType: defaultString(cfg.MeshNATType, "unknown"), + ConnectivityMode: "direct", + Region: cfg.MeshRegion, + Priority: 4, + PolicyTags: []string{"stun-reflexive", "ice-candidate", "fast-path"}, + Metadata: advertisedEndpointMetadata(cfg, "stun-reflexive-endpoint", ""), + }) + } + if cfg.MeshRelayEndpoint != "" { + candidates = append(candidates, mesh.PeerEndpointCandidate{ + EndpointID: identity.NodeID + "-relay-fallback", + NodeID: identity.NodeID, + Transport: "relay_quic", + Address: cfg.MeshRelayEndpoint, + Reachability: "relay", + NATType: defaultString(cfg.MeshNATType, "unknown"), + ConnectivityMode: "relay_required", + Region: cfg.MeshRegion, + Priority: 90, + PolicyTags: []string{"relay-fallback"}, + Metadata: advertisedEndpointMetadata(cfg, "relay-fallback-endpoint", cfg.MeshRelayEndpoint), }) } if cfg.MeshQUICFabricEnabled && meshState != nil && strings.TrimSpace(meshState.QUICFabricListenAddr) != "" { @@ -4524,11 +5220,18 @@ func advertisedEndpointCandidates(cfg config.Config, identity state.Identity, me Region: cfg.MeshRegion, Priority: 5, PolicyTags: []string{"fast-path"}, - Metadata: quicFabricEndpointMetadata(meshState.QUICFabricCertSHA256), + Metadata: quicFabricEndpointMetadata(cfg, meshState.QUICFabricCertSHA256), }) } candidates = append(candidates, interfaceEndpointCandidates(cfg, identity, meshState, observedAt)...) + normalized := make([]mesh.PeerEndpointCandidate, 0, 1) for i := range candidates { + if candidates[i].Transport == "" { + candidates[i].Transport = defaultString(cfg.MeshAdvertiseTransport, "quic") + } + if !fabricTransportLabelIsQUIC(candidates[i].Transport) { + return nil, fmt.Errorf("advertised mesh endpoint candidate %q must use a QUIC transport label", candidates[i].EndpointID) + } if candidates[i].EndpointID == "" { candidates[i].EndpointID = fmt.Sprintf("%s-advertised-%d", identity.NodeID, i+1) } @@ -4539,8 +5242,11 @@ func advertisedEndpointCandidates(cfg config.Config, identity state.Identity, me return nil, fmt.Errorf("invalid advertised mesh endpoint candidate") } candidates[i].Address = strings.TrimRight(strings.TrimSpace(candidates[i].Address), "/") - if candidates[i].Transport == "" { - candidates[i].Transport = defaultString(cfg.MeshAdvertiseTransport, "direct_tcp_tls") + if hasLegacyFabricEndpointScheme(candidates[i].Address) { + return nil, fmt.Errorf("advertised mesh endpoint candidate %q must use a QUIC endpoint", candidates[i].EndpointID) + } + if !fabricEndpointHasExplicitPort(candidates[i].Address) { + return nil, fmt.Errorf("advertised mesh endpoint candidate %q must include an explicit host:port", candidates[i].EndpointID) } if candidates[i].ConnectivityMode == "" { candidates[i].ConnectivityMode = defaultString(cfg.MeshConnectivityMode, "direct") @@ -4559,40 +5265,87 @@ func advertisedEndpointCandidates(cfg config.Config, identity state.Identity, me } candidates[i].LastVerifiedAt = &observedAt if candidates[i].Metadata == nil { - metadata, err := json.Marshal(map[string]any{ - "source": "node-agent-heartbeat", - "runtime": "c17z7", - "synthetic_runtime": cfg.MeshSyntheticRuntimeEnabled, - "production_forwarding": cfg.MeshProductionForwardingEnabled, - "vpn_fabric_session": cfg.VPNFabricSessionTransportEnabled, - }) - if err != nil { - return nil, err - } - candidates[i].Metadata = metadata + candidates[i].Metadata = advertisedEndpointMetadata(cfg, "node-agent-heartbeat", "") } + if cfg.MeshQUICFabricEnabled && meshState != nil && fabricTransportLabelIsQUIC(candidates[i].Transport) { + candidates[i].Metadata = endpointMetadataWithTLSCert(candidates[i].Metadata, meshState.QUICFabricCertSHA256) + } + normalized = append(normalized, candidates[i]) } - sort.SliceStable(candidates, func(i, j int) bool { - if candidates[i].Priority == candidates[j].Priority { - return candidates[i].EndpointID < candidates[j].EndpointID - } - return candidates[i].Priority < candidates[j].Priority - }) - return candidates, nil + return normalized, nil } -func quicFabricEndpointMetadata(certSHA256 string) json.RawMessage { +func quicFabricEndpointMetadata(cfg config.Config, certSHA256 string) json.RawMessage { certSHA256 = strings.TrimSpace(certSHA256) - if certSHA256 == "" { - return nil + metadata := advertisedEndpointMetadataMap(cfg, "node-agent-quic-fabric-listener", "") + if certSHA256 != "" { + metadata["tls_cert_sha256"] = certSHA256 } - payload, err := json.Marshal(map[string]string{"tls_cert_sha256": certSHA256}) + payload, err := json.Marshal(metadata) if err != nil { return nil } return payload } +func advertisedEndpointMetadata(cfg config.Config, source string, relayEndpoint string) json.RawMessage { + payload, err := json.Marshal(advertisedEndpointMetadataMap(cfg, source, relayEndpoint)) + if err != nil { + return nil + } + return payload +} + +func endpointMetadataWithTLSCert(metadata json.RawMessage, certSHA256 string) json.RawMessage { + certSHA256 = strings.TrimSpace(certSHA256) + if certSHA256 == "" { + return metadata + } + values := map[string]any{} + if len(metadata) > 0 { + _ = json.Unmarshal(metadata, &values) + } + tlsCert, _ := values["tls_cert_sha256"].(string) + peerCert, _ := values["peer_cert_sha256"].(string) + if strings.TrimSpace(tlsCert) == "" && strings.TrimSpace(peerCert) == "" { + values["tls_cert_sha256"] = certSHA256 + } + payload, err := json.Marshal(values) + if err != nil { + return metadata + } + return payload +} + +func advertisedEndpointMetadataMap(cfg config.Config, source string, relayEndpoint string) map[string]any { + metadata := map[string]any{ + "source": strings.TrimSpace(source), + "runtime": "c17z25", + "synthetic_runtime": cfg.MeshSyntheticRuntimeEnabled, + "production_forwarding": cfg.MeshProductionForwardingEnabled, + "vpn_fabric_session": cfg.VPNFabricSessionTransportEnabled, + } + if cfg.MeshLocalSegmentID != "" { + metadata["local_segment_id"] = cfg.MeshLocalSegmentID + } + if cfg.MeshNATGroupID != "" { + metadata["nat_group_id"] = cfg.MeshNATGroupID + } + if cfg.MeshSTUNServer != "" { + metadata["stun_server"] = cfg.MeshSTUNServer + metadata["ice_foundation"] = safeEndpointIDPart(cfg.MeshSTUNServer) + } + if cfg.MeshRelayNodeID != "" { + metadata["relay_node_id"] = cfg.MeshRelayNodeID + } + if relayEndpoint != "" { + metadata["relay_endpoint"] = strings.TrimRight(strings.TrimSpace(relayEndpoint), "/") + } else if cfg.MeshRelayEndpoint != "" { + metadata["relay_endpoint"] = cfg.MeshRelayEndpoint + } + return metadata +} + func interfaceEndpointCandidates(cfg config.Config, identity state.Identity, meshState *syntheticMeshState, observedAt time.Time) []mesh.PeerEndpointCandidate { if meshState == nil { return nil @@ -4646,6 +5399,8 @@ func interfaceEndpointCandidates(cfg config.Config, identity state.Identity, mes metadata, _ := json.Marshal(map[string]any{ "source": "node-agent-interface-discovery", "runtime": "c17z24", + "local_segment_id": cfg.MeshLocalSegmentID, + "nat_group_id": cfg.MeshNATGroupID, "interface_name": iface.Name, "interface_index": iface.Index, "interface_type": interfaceType, @@ -4660,8 +5415,8 @@ func interfaceEndpointCandidates(cfg config.Config, identity state.Identity, mes candidates = append(candidates, mesh.PeerEndpointCandidate{ EndpointID: fmt.Sprintf("%s-if-%s-%s-%s", identity.NodeID, safeEndpointIDPart(iface.Name), safeEndpointIDPart(ip.String()), addressFamily), NodeID: identity.NodeID, - Transport: defaultString(cfg.MeshAdvertiseTransport, "direct_http"), - Address: endpointAddress(defaultString(cfg.MeshAdvertiseTransport, "direct_http"), ip, port), + Transport: defaultString(cfg.MeshAdvertiseTransport, "quic"), + Address: endpointAddress(defaultString(cfg.MeshAdvertiseTransport, "quic"), ip, port), AddressFamily: addressFamily, Reachability: reachability, NATType: defaultString(cfg.MeshNATType, "unknown"), @@ -4748,14 +5503,35 @@ func endpointAddress(transport string, ip net.IP, port string) string { } scheme := "http" switch strings.ToLower(strings.TrimSpace(transport)) { - case "wss": - scheme = "wss" - case "https", "direct_https": - scheme = "https" + case "quic", "direct_quic", "udp_quic", "quic_udp": + scheme = "quic" } return scheme + "://" + host + ":" + port } +func addressFamilyForEndpoint(endpoint string) string { + endpoint = strings.TrimSpace(endpoint) + if endpoint == "" { + return "" + } + host := endpoint + if strings.Contains(endpoint, "://") { + if parsed, err := url.Parse(endpoint); err == nil { + host = parsed.Hostname() + } + } else if splitHost, _, err := net.SplitHostPort(endpoint); err == nil { + host = splitHost + } + ip := net.ParseIP(strings.Trim(host, "[]")) + if ip == nil { + return "" + } + if ip.To4() != nil { + return "ipv4" + } + return "ipv6" +} + func endpointPriority(reachability string, addressFamily string, interfaceType string, offset int) int { base := 40 if reachability == "public" { @@ -4996,6 +5772,10 @@ func reportVPNAssignmentStatus(ctx context.Context, api *client.Client, identity "schema_version": "rap.node_vpn_assignment_status.v1", "assignment_reason": assignment.AssignmentReason, "protocol_family": assignment.ProtocolFamily, + "service_role": "ipv4-egress", + "service_class": "vpn_packets", + "service_adapter": "fabric_channel_to_ipv4_nat", + "transport_owner": "fabric_farm", "runtime_available": runtimeAvailable, "packet_forwarding": packetForwarding, "reason": reason, @@ -5192,7 +5972,12 @@ func ensureVPNGatewayRuntime(ctx context.Context, api *client.Client, cfg config gateway.AddressCIDR = "10.77.0.1/24" gateway.RouteCIDR = "10.77.0.0/24" gateway.PollTimeout = 25 * time.Second - if transport := fabricGatewayTransportForAssignment(ctx, cfg, identity, assignment, meshState, api); transport != nil { + if transport := registeredFabricSessionGatewayTransportForAssignment(assignment, meshState); transport != nil { + if _, ok := gateway.Transport.(vpnruntime.BackendPacketTransport); ok { + gateway.Stop() + } + gateway.Transport = transport + } else if transport := fabricGatewayTransportForAssignment(ctx, cfg, identity, assignment, meshState, api); transport != nil { if _, ok := gateway.Transport.(vpnruntime.BackendPacketTransport); ok { gateway.Stop() } @@ -5229,6 +6014,17 @@ func ensureVPNGatewayRuntime(ctx context.Context, api *client.Client, cfg config return nil } +func registeredFabricSessionGatewayTransportForAssignment(assignment client.NodeVPNAssignment, meshState *syntheticMeshState) vpnruntime.PacketTransport { + if meshState == nil || meshState.VPNPacketSessionPeers == nil || meshState.VPNFabricInbox == nil || assignment.VPNConnectionID == "" { + return nil + } + return &vpnruntime.FabricSessionPacketPeerTransport{ + Registry: meshState.VPNPacketSessionPeers, + Inbox: meshState.VPNFabricInbox, + VPNConnectionID: assignment.VPNConnectionID, + } +} + func vpnAssignmentLeaseAutoAcquireAllowed(localNodeID string, assignment client.NodeVPNAssignment) bool { localNodeID = strings.TrimSpace(localNodeID) if localNodeID == "" { @@ -5274,6 +6070,7 @@ func fabricGatewayTransportForAssignment(ctx context.Context, cfg config.Config, if transport := fabricSessionGatewayTransportForAssignment(ctx, cfg, identity, assignment, meshState, nextHop); transport != nil { return transport } + return nil } return &vpnruntime.FabricPacketTransport{ ForwardTransport: meshState.ProductionForwardTransport, @@ -5309,9 +6106,6 @@ func fabricSessionGatewayTransportForAssignment(ctx context.Context, cfg config. if meshState.VPNFabricSessionPeers == nil { meshState.VPNFabricSessionPeers = mesh.NewFabricSessionPeerManager() } - if meshState.VPNFabricTransport == nil { - meshState.VPNFabricTransport = mesh.NewWebSocketFabricTransport(meshState.VPNFabricSessionPeers) - } token := fabricSessionGatewayToken(identity, assignment, nextHop) for index, target := range targets { startedAt := time.Now() @@ -5322,7 +6116,7 @@ func fabricSessionGatewayTransportForAssignment(ctx context.Context, cfg config. target.OutboundBuffer = 256 target.InboundBuffer = 256 target.ErrorBuffer = 16 - carrier, selectedTarget, err := mesh.FabricTransportForTarget(target, meshState.VPNFabricTransport, meshState.VPNFabricQUICTransport) + carrier, selectedTarget, err := mesh.FabricTransportForTarget(target, meshState.VPNFabricQUICTransport) if err != nil { cancel() meshState.VPNFabricSessionDialStats.ObserveCandidateFailure("transport_select_failed") @@ -5440,54 +6234,69 @@ func vpnFabricSessionTargets(meshState *syntheticMeshState, nextHop string) []me if meshState == nil { return nil } - out := make([]mesh.FabricTransportTarget, 0, len(meshState.PeerEndpointCandidates[nextHop])+1) + out := make([]mesh.FabricTransportTarget, 0, len(meshState.PeerEndpointCandidates[nextHop])) seen := map[string]struct{}{} if candidates := meshState.PeerEndpointCandidates[nextHop]; len(candidates) > 0 { + now := time.Now().UTC() var capacityPressure map[string]mesh.EndpointCandidateCapacityPressure if meshState.VPNFabricSessionDialStats != nil { capacityPressure = meshState.VPNFabricSessionDialStats.capacityPressureForScoring(2 * time.Minute) } capacityPressure = mergeEndpointCapacityPressure( capacityPressure, - quicEndpointCapacityPressureForScoring(candidates, meshState.VPNFabricQUICTransport, time.Now().UTC()), + quicEndpointCapacityPressureForScoring(candidates, meshState.VPNFabricQUICTransport, now), ) + localObservations := map[string]mesh.EndpointCandidateHealthObservation{} + if meshState.VPNFabricEndpointObservations != nil { + localObservations = meshState.VPNFabricEndpointObservations.Snapshot() + } ranked := mesh.RankPeerEndpointCandidates(candidates, mesh.EndpointCandidateScoreOptions{ ChannelClass: mesh.SyntheticChannelFabricControl, - Now: time.Now().UTC(), + Now: now, MaxVerificationAge: 5 * time.Minute, - Observations: mergedEndpointCandidateObservations(meshState.PeerEndpointObservations, meshState.VPNFabricEndpointObservations.Snapshot()), + Observations: mergedEndpointCandidateObservations(meshState.PeerEndpointObservations, localObservations), MaxObservationAge: 5 * time.Minute, CapacityPressure: capacityPressure, MaxCapacityPressureAge: 2 * time.Minute, }) for _, item := range ranked { - endpoint := strings.TrimRight(strings.TrimSpace(item.Candidate.Address), "/") + candidate := item.Candidate + if !fabricTransportLabelIsQUIC(candidate.Transport) { + continue + } + endpoint := strings.TrimRight(strings.TrimSpace(candidate.Address), "/") if endpoint == "" { continue } - key := item.Candidate.Transport + "\x00" + endpoint + key := candidate.Transport + "\x00" + endpoint if _, ok := seen[key]; ok { continue } seen[key] = struct{}{} out = append(out, mesh.FabricTransportTarget{ - EndpointID: item.Candidate.EndpointID, + EndpointID: candidate.EndpointID, Endpoint: endpoint, - Transport: item.Candidate.Transport, - PeerCertSHA256: endpointCandidateTLSCertSHA256(item.Candidate), + Transport: candidate.Transport, + PeerCertSHA256: endpointCandidateTLSCertSHA256(candidate), }) } } - endpoint := strings.TrimRight(strings.TrimSpace(meshState.PeerEndpoints[nextHop]), "/") - if endpoint != "" { - key := "\x00" + endpoint - if _, ok := seen[key]; !ok { - out = append(out, mesh.FabricTransportTarget{Endpoint: endpoint}) - } - } return out } +func productionForwardRouteSetsFromCandidates(candidates map[string][]mesh.PeerEndpointCandidate, observations map[string]mesh.EndpointCandidateHealthObservation, clusterID string, localNodeID string) map[string]mesh.FabricRouteSet { + return mesh.FabricRouteSetsForPeerEndpointCandidates(candidates, mesh.FabricRoutePlannerConfig{ + ClusterID: clusterID, + LocalNodeID: localNodeID, + DefaultCapacity: 100, + RelayCapacity: 64, + ReverseCapacity: 64, + Observations: observations, + MaxObservationAge: 30 * time.Second, + Now: time.Now().UTC(), + }) +} + func quicEndpointCapacityPressureForScoring(candidates []mesh.PeerEndpointCandidate, transport *mesh.QUICFabricTransport, now time.Time) map[string]mesh.EndpointCandidateCapacityPressure { if len(candidates) == 0 || transport == nil { return nil 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 65144d3..1c76831 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 @@ -3,12 +3,20 @@ 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" "errors" "fmt" "io" "log" + "math/big" "net/http" "net/http/httptest" "os" @@ -23,6 +31,7 @@ import ( "github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/state" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/vpnruntime" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/webingress" ) func TestLoadSyntheticMeshConfigPrefersScopedFile(t *testing.T) { @@ -42,12 +51,12 @@ func TestLoadSyntheticMeshConfigPrefersScopedFile(t *testing.T) { SchemaVersion: "c17f.synthetic.v1", ClusterID: "cluster-1", LocalNodeID: "node-a", - PeerEndpoints: map[string]string{"node-b": "http://127.0.0.1:19002"}, + PeerEndpoints: map[string]string{"node-b": "quic://127.0.0.1:19002"}, PeerDirectory: []mesh.PeerDirectoryEntry{ {NodeID: "node-b", RouteIDs: []string{"route-file"}, EndpointCount: 1}, }, RecoverySeeds: []mesh.PeerRecoverySeed{ - {NodeID: "node-b", Endpoint: "http://127.0.0.1:19002", Transport: "direct_tcp_tls", Priority: 10}, + {NodeID: "node-b", Endpoint: "quic://127.0.0.1:19002", Transport: "direct_quic", Priority: 10}, }, Routes: []mesh.SyntheticRoute{route}, }) @@ -61,7 +70,7 @@ func TestLoadSyntheticMeshConfigPrefersScopedFile(t *testing.T) { loaded, err := loadSyntheticMeshConfig(context.Background(), config.Config{ MeshSyntheticConfigPath: path, - MeshPeerEndpointsJSON: `{"node-b":"http://debug.invalid"}`, + MeshPeerEndpointsJSON: `{"node-b":"quic://debug.invalid:19443"}`, MeshSyntheticRoutesJSON: `[]`, }, state.Identity{ClusterID: "cluster-1", NodeID: "node-a"}, nil) if err != nil { @@ -70,7 +79,7 @@ func TestLoadSyntheticMeshConfigPrefersScopedFile(t *testing.T) { if loaded.Source != "scoped_config" { t.Fatalf("source = %q, want scoped_config", loaded.Source) } - if loaded.PeerEndpoints["node-b"] != "http://127.0.0.1:19002" { + if loaded.PeerEndpoints["node-b"] != "quic://127.0.0.1:19002" { t.Fatalf("peer endpoint = %q", loaded.PeerEndpoints["node-b"]) } if len(loaded.Routes) != 1 || loaded.Routes[0].RouteID != "route-file" { @@ -81,6 +90,29 @@ func TestLoadSyntheticMeshConfigPrefersScopedFile(t *testing.T) { } } +func TestQUICFabricTLSConfigPersistsCertificateAcrossRestarts(t *testing.T) { + cfg := config.Config{StateDir: t.TempDir()} + identity := state.Identity{NodeID: "node-a"} + + _, firstFingerprint, err := quicFabricTLSConfig(cfg, identity) + if err != nil { + t.Fatalf("create quic fabric tls config: %v", err) + } + _, secondFingerprint, err := quicFabricTLSConfig(cfg, identity) + if err != nil { + t.Fatalf("reload quic fabric tls config: %v", err) + } + if firstFingerprint == "" || secondFingerprint == "" || firstFingerprint != secondFingerprint { + t.Fatalf("fingerprints = %q then %q, want stable persisted certificate", firstFingerprint, secondFingerprint) + } + if _, err := os.Stat(filepath.Join(cfg.StateDir, "quic-fabric.crt")); err != nil { + t.Fatalf("persisted certificate missing: %v", err) + } + if _, err := os.Stat(filepath.Join(cfg.StateDir, "quic-fabric.key")); err != nil { + t.Fatalf("persisted private key missing: %v", err) + } +} + func TestSyntheticMeshConfigAuthorityHashUsesRawConfigPayload(t *testing.T) { raw := json.RawMessage(`{ "enabled": true, @@ -228,10 +260,14 @@ func TestGatewayTransportForAssignmentUsesFabricWithoutBackendFallback(t *testin } func TestGatewayTransportForAssignmentUsesFabricSessionWhenEnabled(t *testing.T) { - server := httptest.NewServer(mesh.Server{ - Local: mesh.PeerIdentity{ClusterID: "cluster-1", NodeID: "entry-1"}, - FabricSessionEnabled: true, - }.Handler()) + tlsConfig := testMainQUICTLSConfig(t) + server, err := mesh.StartQUICFabricServer(context.Background(), mesh.QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: tlsConfig, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } defer server.Close() inbox := vpnruntime.NewFabricPacketInbox(4) @@ -244,7 +280,17 @@ func TestGatewayTransportForAssignmentUsesFabricSessionWhenEnabled(t *testing.T) ProductionForwardTransport: noopProductionForwardTransport{}, VPNFabricInbox: inbox, VPNFabricSessionPeers: mesh.NewFabricSessionPeerManager(), - PeerEndpoints: map[string]string{"entry-1": server.URL}, + PeerEndpointCandidates: map[string][]mesh.PeerEndpointCandidate{ + "entry-1": {{ + EndpointID: "entry-1-quic", + NodeID: "entry-1", + Transport: "direct_quic", + Address: "quic://" + server.Addr().String(), + Reachability: "public", + ConnectivityMode: "direct", + Metadata: json.RawMessage(fmt.Sprintf(`{"tls_cert_sha256":%q}`, testMainQUICCertSHA256(t, tlsConfig))), + }}, + }, Routes: []mesh.SyntheticRoute{{ RouteID: "route-exit-entry", ClusterID: "cluster-1", @@ -290,8 +336,8 @@ func TestGatewayTransportForAssignmentFallsBackWhenFabricSessionUnavailable(t *t }, nil, ) - if _, ok := transport.(*vpnruntime.FabricPacketTransport); !ok { - t.Fatalf("transport = %T, want fallback fabric packet transport", transport) + if transport != nil { + t.Fatalf("transport = %T, want nil when QUIC fabric session is unavailable", transport) } } @@ -342,6 +388,42 @@ func (noopProductionForwardTransport) SendProduction(context.Context, string, me return mesh.ProductionForwardResult{}, nil } +func testMainQUICTLSConfig(t *testing.T) *tls.Config { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate rsa key: %v", err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "rap-quic-test"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + ExtKeyUsage: []x509.ExtKeyUsage{x509.ExtKeyUsageServerAuth}, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create certificate: %v", err) + } + return &tls.Config{ + Certificates: []tls.Certificate{{ + Certificate: [][]byte{der}, + PrivateKey: key, + }}, + NextProtos: []string{"rap-fabric-data-session-v1"}, + } +} + +func testMainQUICCertSHA256(t *testing.T, config *tls.Config) string { + t.Helper() + if config == nil || len(config.Certificates) == 0 || len(config.Certificates[0].Certificate) == 0 { + t.Fatal("missing test certificate") + } + sum := sha256.Sum256(config.Certificates[0].Certificate[0]) + return hex.EncodeToString(sum[:]) +} + func TestRouteManagerDecisionsFromControlPlaneKeepsExplicitRemediationCommand(t *testing.T) { now := time.Now().UTC() report := &client.RoutePathDecisionReport{Decisions: []client.RoutePathDecision{{ @@ -475,6 +557,90 @@ func TestVerifyEnrollmentBootstrapAcceptsSignedApproval(t *testing.T) { } } +func TestVerifyEnrollmentBootstrapAcceptsSignedQuorumDescriptor(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("generate key: %v", err) + } + publicKeyB64 := base64.StdEncoding.EncodeToString(publicKey) + fingerprint := agentauthority.Fingerprint(publicKey) + descriptor := agentauthority.QuorumDescriptor{ + SchemaVersion: agentauthority.QuorumSchemaVersion, + ClusterID: "cluster-1", + Epoch: "epoch-1", + Threshold: 1, + Members: []agentauthority.QuorumMember{ + { + NodeID: "authority-1", + Role: "update-authority", + PublicKey: publicKeyB64, + PublicKeyFingerprint: fingerprint, + Scopes: []string{"update-authority"}, + }, + }, + } + descriptorHash, err := agentauthority.QuorumDescriptorHash(descriptor) + if err != nil { + t.Fatalf("hash quorum descriptor: %v", err) + } + rawDescriptor, err := json.Marshal(descriptor) + if err != nil { + t.Fatalf("marshal quorum descriptor: %v", err) + } + payload, err := json.Marshal(map[string]any{ + "schema_version": "rap.cluster.node_approval.v1", + "cluster_id": "cluster-1", + "join_request_id": "join-request-1", + "node_id": "node-1", + "node_fingerprint": "fp-1", + "identity_status": "active", + "heartbeat_endpoint": "/api/v1/clusters/cluster-1/nodes/node-1/heartbeats", + "approved_by_user_id": "admin-1", + "cluster_authority_quorum_sha256": descriptorHash, + "issued_at": "2026-04-28T12:00:00Z", + "control_plane_only": true, + "production_forwarding": false, + }) + if err != nil { + t.Fatalf("marshal authority payload: %v", err) + } + canonical, err := agentauthority.CanonicalJSON(payload) + if err != nil { + t.Fatalf("canonical json: %v", err) + } + bootstrap := client.NodeBootstrap{ + NodeID: "node-1", + ClusterID: "cluster-1", + IdentityStatus: "active", + HeartbeatEndpoint: "/api/v1/clusters/cluster-1/nodes/node-1/heartbeats", + ClusterAuthorityQuorum: rawDescriptor, + ClusterAuthority: &client.ClusterAuthorityDescriptor{ + SchemaVersion: agentauthority.AuthoritySchemaVersion, + ClusterID: "cluster-1", + AuthorityState: "active", + KeyAlgorithm: agentauthority.AlgorithmEd25519, + PublicKey: publicKeyB64, + PublicKeyFingerprint: fingerprint, + }, + AuthorityPayload: payload, + AuthoritySignature: &client.ClusterSignature{ + SchemaVersion: agentauthority.SignatureSchemaVersion, + Algorithm: agentauthority.AlgorithmEd25519, + KeyFingerprint: fingerprint, + Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)), + SignedAt: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC), + }, + } + + err = verifyEnrollmentBootstrap(bootstrap, state.Identity{ + ClusterID: "cluster-1", + NodeFingerprint: "fp-1", + }, config.Config{ClusterAuthorityFingerprint: fingerprint}) + if err != nil { + t.Fatalf("verify enrollment bootstrap: %v", err) + } +} + func TestVerifyControlPlaneSyntheticMeshConfigAcceptsSignedServiceChannelFeedback(t *testing.T) { publicKey, privateKey, err := ed25519.GenerateKey(nil) if err != nil { @@ -624,6 +790,76 @@ func TestVerifyEnrollmentBootstrapRejectsPinnedAuthorityMismatch(t *testing.T) { } } +func TestNormalizeLoadedSyntheticMeshConfigMigratesLegacyControlPlaneSurfaces(t *testing.T) { + loaded := loadedSyntheticMeshConfig{ + PeerEndpoints: map[string]string{ + "node-a": "https://node-a.example.test:443", + }, + PeerEndpointCandidates: map[string][]mesh.PeerEndpointCandidate{ + "node-b": { + { + EndpointID: "node-b-legacy", + NodeID: "node-b", + Transport: "direct_http", + Address: "https://node-b.example.test:443", + Reachability: "public", + ConnectivityMode: "direct", + }, + }, + }, + RecoverySeeds: []mesh.PeerRecoverySeed{ + { + NodeID: "node-c", + Endpoint: "ws://node-c.example.test:19001", + Transport: "reverse", + }, + }, + RendezvousLeases: []mesh.PeerRendezvousLease{ + { + LeaseID: "lease-legacy", + PeerNodeID: "node-b", + RelayNodeID: "node-r", + RelayEndpoint: "http://node-r.example.test:19001", + Transport: "relay_control", + }, + }, + RoutePathDecisions: &client.RoutePathDecisionReport{ + Decisions: []client.RoutePathDecision{{DecisionID: "decision-legacy", SelectedRelayEndpoint: "http://node-r.example.test:19001"}}, + }, + } + normalizeLoadedSyntheticMeshConfigQUICOnly(&loaded) + if err := validateLoadedSyntheticMeshConfigQUICOnly(loaded); err != nil { + t.Fatalf("expected normalized config to validate, got %v", err) + } + if got := loaded.PeerEndpoints["node-a"]; got != "quic://node-a.example.test:443" { + t.Fatalf("peer endpoint was not normalized: %s", got) + } + candidate := loaded.PeerEndpointCandidates["node-b"][0] + if candidate.Transport != "direct_quic" || candidate.Address != "quic://node-b.example.test:443" { + t.Fatalf("candidate was not normalized: %+v", candidate) + } + if seed := loaded.RecoverySeeds[0]; seed.Transport != "reverse_quic" || seed.Endpoint != "quic://node-c.example.test:19001" { + t.Fatalf("recovery seed was not normalized: %+v", seed) + } + if lease := loaded.RendezvousLeases[0]; lease.Transport != "relay_quic" || lease.RelayEndpoint != "quic://node-r.example.test:19001" { + t.Fatalf("rendezvous lease was not normalized: %+v", lease) + } + if got := loaded.RoutePathDecisions.Decisions[0].SelectedRelayEndpoint; got != "quic://node-r.example.test:19001" { + t.Fatalf("route decision endpoint was not normalized: %s", got) + } +} + +func TestValidateLoadedSyntheticMeshConfigRejectsUnnormalizedLegacyControlPlaneSurfaces(t *testing.T) { + err := validateLoadedSyntheticMeshConfigQUICOnly(loadedSyntheticMeshConfig{ + RoutePathDecisions: &client.RoutePathDecisionReport{ + Decisions: []client.RoutePathDecision{{DecisionID: "decision-legacy", 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) + } +} + func TestEnsureApprovedIdentityKeepsPollingWhenTimeoutDisabled(t *testing.T) { var bootstrapPolls int ctx, cancel := context.WithCancel(context.Background()) @@ -699,8 +935,8 @@ func TestProductionEnvelopeObservationSinkFromConfigIsDisabledByDefault(t *testi func TestHeartbeatPayloadIncludesMeshEndpointReport(t *testing.T) { payload := heartbeatPayload(config.Config{ - MeshAdvertiseEndpoint: "https://node-a.example.test:443", - MeshAdvertiseTransport: "wss", + MeshAdvertiseEndpoint: "quic://node-a.example.test:19443", + MeshAdvertiseTransport: "direct_quic", MeshConnectivityMode: "outbound_only", MeshNATType: "symmetric", MeshRegion: "eu", @@ -734,7 +970,7 @@ func TestHeartbeatPayloadIncludesMeshEndpointReport(t *testing.T) { if !ok { t.Fatalf("mesh endpoint report missing: %+v", payload.Metadata) } - if report["peer_endpoint"] != "quic://127.0.0.1:19443" || + if report["peer_endpoint"] != "quic://node-a.example.test:19443" || report["transport"] != "direct_quic" || report["connectivity_mode"] != "outbound_only" || report["nat_type"] != "symmetric" || @@ -742,16 +978,16 @@ func TestHeartbeatPayloadIncludesMeshEndpointReport(t *testing.T) { t.Fatalf("unexpected endpoint report: %+v", report) } candidates, ok := report["endpoint_candidates"].([]mesh.PeerEndpointCandidate) - if !ok || len(candidates) < 2 || candidates[0].Transport != "direct_quic" || candidates[1].Transport != "wss" { + if !ok || len(candidates) != 2 || candidates[0].Transport != "direct_quic" || candidates[1].EndpointID != "node-a-quic-fabric" { t.Fatalf("unexpected endpoint candidates: %+v", report["endpoint_candidates"]) } if payload.Capabilities["mesh_dynamic_endpoint_reporting"] != true { t.Fatalf("dynamic endpoint capability missing: %+v", payload.Capabilities) } - if payload.Capabilities["fabric_session_websocket_endpoint"] != true || payload.Capabilities["fabric_data_session_v1"] != true { + if payload.Capabilities["fabric_session_websocket_endpoint"] == true || payload.Capabilities["fabric_data_session_v1"] != true { t.Fatalf("fabric session capabilities missing: %+v", payload.Capabilities) } - if report, ok := payload.Metadata["fabric_session_endpoint_report"].(map[string]any); !ok || report["path"] != "/mesh/v1/fabric/session/ws" { + if report, ok := payload.Metadata["fabric_session_endpoint_report"].(map[string]any); !ok || report["transport"] != "quic" { t.Fatalf("fabric session endpoint report missing: %+v", payload.Metadata) } else if quic, ok := report["quic"].(map[string]any); !ok || quic["listen_addr"] != ":19443" || quic["effective_listen_addr"] != "127.0.0.1:19443" { t.Fatalf("fabric quic endpoint report missing: %+v", report) @@ -759,6 +995,15 @@ func TestHeartbeatPayloadIncludesMeshEndpointReport(t *testing.T) { if payload.Capabilities["fabric_quic_endpoint"] != true { t.Fatalf("fabric quic capability missing: %+v", payload.Capabilities) } + if payload.Capabilities["web_ingress_runtime_receiver"] != true { + t.Fatalf("web ingress runtime receiver capability missing: %+v", payload.Capabilities) + } + if report, ok := payload.Metadata["web_ingress_runtime_receiver_report"].(map[string]any); !ok || + report["schema_version"] != "rap.web_ingress.runtime_receiver_report.v1" || + report["quic_stream_id"] != mesh.WebIngressForwardQUICStreamID || + report["reason"] != "trusted_keys_required" { + t.Fatalf("web ingress runtime receiver report missing: %+v", payload.Metadata) + } if payload.Capabilities["vpn_fabric_session_transport"] != true || payload.Capabilities["vpn_packet_batch_binary_frames"] != true { t.Fatalf("vpn fabric session capabilities missing: %+v", payload.Capabilities) } @@ -794,6 +1039,31 @@ func TestHeartbeatPayloadIncludesMeshEndpointReport(t *testing.T) { } } +func TestHeartbeatPayloadReportsWebIngressReceiverWithoutSyntheticRuntime(t *testing.T) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + payload := heartbeatPayload(config.Config{ + WebIngressTrustedKeysJSON: webingress.TrustedKeysJSONForPublicKey("web-key-1", publicKey), + WebIngressRuntimeServiceClasses: "cluster_admin", + }, state.Identity{ClusterID: "cluster-1", NodeID: "node-a"}, nil, time.Date(2026, 5, 17, 1, 2, 3, 0, time.UTC)) + + report, ok := payload.Metadata["web_ingress_runtime_receiver_report"].(map[string]any) + if !ok { + t.Fatalf("web ingress runtime receiver report missing: %+v", payload.Metadata) + } + if payload.Capabilities["web_ingress_runtime_receiver"] != true || + report["enabled"] != true || + report["trusted_key_count"] != 1 { + t.Fatalf("payload=%+v report=%+v", payload, report) + } + classes, ok := report["service_classes"].([]string) + if !ok || len(classes) != 1 || classes[0] != "cluster_admin" { + t.Fatalf("service_classes = %#v", report["service_classes"]) + } +} + func TestVPNFabricSessionDialStatsReport(t *testing.T) { stats := newVPNFabricSessionDialStats() stats.Attempts.Add(1) @@ -941,10 +1211,10 @@ func TestVPNFabricSessionTargetPrefersRankedQUICCandidate(t *testing.T) { PeerEndpointCandidates: map[string][]mesh.PeerEndpointCandidate{ "node-b": { { - EndpointID: "node-b-wss", + EndpointID: "node-b-relay", NodeID: "node-b", - Transport: "wss", - Address: "https://node-b.example.test:443", + Transport: "relay_quic", + Address: "quic://relay.example.test:19443", Reachability: "public", ConnectivityMode: "direct", Priority: 10, @@ -973,20 +1243,17 @@ func TestVPNFabricSessionTargetPrefersRankedQUICCandidate(t *testing.T) { } func TestVPNFabricSessionTargetFallsBackToLegacyPeerEndpoint(t *testing.T) { - target, ok := vpnFabricSessionTarget(&syntheticMeshState{ + _, ok := vpnFabricSessionTarget(&syntheticMeshState{ PeerEndpoints: map[string]string{ "node-b": "https://node-b.example.test:443/", }, }, "node-b") - if !ok { - t.Fatal("target missing") - } - if target.Endpoint != "https://node-b.example.test:443" || target.Transport != "" { - t.Fatalf("target = %+v, want legacy endpoint fallback", target) + if ok { + t.Fatal("legacy peer endpoint unexpectedly produced a QUIC target") } } -func TestVPNFabricSessionTargetsIncludeRankedCandidatesThenLegacyFallback(t *testing.T) { +func TestVPNFabricSessionTargetsIncludeRankedQUICCandidatesWithoutLegacyFallback(t *testing.T) { now := time.Now().UTC() targets := vpnFabricSessionTargets(&syntheticMeshState{ PeerEndpoints: map[string]string{ @@ -995,10 +1262,10 @@ func TestVPNFabricSessionTargetsIncludeRankedCandidatesThenLegacyFallback(t *tes PeerEndpointCandidates: map[string][]mesh.PeerEndpointCandidate{ "node-b": { { - EndpointID: "node-b-wss", + EndpointID: "node-b-relay", NodeID: "node-b", - Transport: "wss", - Address: "https://node-b.example.test:443", + Transport: "relay_quic", + Address: "quic://relay.example.test:19443", Reachability: "public", ConnectivityMode: "direct", Priority: 10, @@ -1017,11 +1284,11 @@ func TestVPNFabricSessionTargetsIncludeRankedCandidatesThenLegacyFallback(t *tes }, }, }, "node-b") - if len(targets) != 3 { - t.Fatalf("target count = %d, want 3: %+v", len(targets), targets) + if len(targets) != 2 { + t.Fatalf("target count = %d, want 2 ranked QUIC candidates: %+v", len(targets), targets) } - if targets[0].Transport != "direct_quic" || targets[1].Transport != "wss" || targets[2].Endpoint != "https://node-b-legacy.example.test:443" { - t.Fatalf("targets not ordered by ranked candidates then fallback: %+v", targets) + if targets[0].Transport != "direct_quic" || targets[0].Endpoint != "quic://node-b.example.test:19443" || targets[1].Transport != "relay_quic" { + t.Fatalf("targets were not ranked as direct QUIC then relay fallback: %+v", targets) } } @@ -1045,10 +1312,10 @@ func TestVPNFabricSessionTargetsUseLocalHealthObservations(t *testing.T) { LastVerifiedAt: &now, }, { - EndpointID: "node-b-wss", + EndpointID: "node-b-ice", NodeID: "node-b", - Transport: "wss", - Address: "https://node-b.example.test:443", + Transport: "ice_quic", + Address: "quic://node-b.example.test:19444", Reachability: "public", ConnectivityMode: "direct", Priority: 10, @@ -1057,11 +1324,8 @@ func TestVPNFabricSessionTargetsUseLocalHealthObservations(t *testing.T) { }, }, }, "node-b") - if len(targets) != 2 { - t.Fatalf("target count = %d, want 2: %+v", len(targets), targets) - } - if targets[0].EndpointID != "node-b-wss" || targets[1].EndpointID != "node-b-quic" { - t.Fatalf("targets did not apply local health observations: %+v", targets) + if len(targets) != 2 || targets[0].EndpointID != "node-b-ice" || targets[1].EndpointID != "node-b-quic" { + t.Fatalf("targets must prefer healthy ICE QUIC while keeping direct QUIC fallback: %+v", targets) } } @@ -1090,10 +1354,10 @@ func TestVPNFabricSessionTargetsUseRemoteHealthObservations(t *testing.T) { LastVerifiedAt: &now, }, { - EndpointID: "node-b-wss", + EndpointID: "node-b-ice", NodeID: "node-b", - Transport: "wss", - Address: "https://node-b.example.test:443", + Transport: "ice_quic", + Address: "quic://node-b.example.test:19444", Reachability: "public", ConnectivityMode: "direct", Priority: 10, @@ -1102,8 +1366,8 @@ func TestVPNFabricSessionTargetsUseRemoteHealthObservations(t *testing.T) { }, }, }, "node-b") - if len(targets) != 2 || targets[0].EndpointID != "node-b-wss" { - t.Fatalf("targets did not apply remote health observations: %+v", targets) + if len(targets) != 2 || targets[0].EndpointID != "node-b-ice" || targets[1].EndpointID != "node-b-quic" { + t.Fatalf("targets must prefer remotely healthy ICE QUIC while keeping direct QUIC fallback: %+v", targets) } } @@ -1143,8 +1407,8 @@ func TestVPNFabricSessionTargetsUseCapacityPressureForLoadSpread(t *testing.T) { }, }, }, "node-b") - if len(targets) != 2 || targets[0].EndpointID != "node-b-quic-b" { - t.Fatalf("targets did not spread away from pressured endpoint: %+v", targets) + if len(targets) != 2 || targets[0].EndpointID != "node-b-quic-b" || targets[1].EndpointID != "node-b-quic-a" { + t.Fatalf("targets must prefer less pressured QUIC endpoint while keeping busy fallback: %+v", targets) } } @@ -1190,17 +1454,37 @@ func TestMergeEndpointCapacityPressureKeepsStrongerSignal(t *testing.T) { }, map[string]mesh.EndpointCandidateCapacityPressure{ "node-b-quic": {EndpointID: "node-b-quic", Count: 1, LastSeenUnixSec: 20}, - "node-b-wss": {EndpointID: "node-b-wss", Count: 2, LastSeenUnixSec: 20}, + "node-b-ice": {EndpointID: "node-b-ice", Count: 2, LastSeenUnixSec: 20}, }, ) if merged["node-b-quic"].Count != 9 || merged["node-b-quic"].LastSeenUnixSec != 10 { t.Fatalf("weaker fresh pressure replaced stronger signal: %+v", merged["node-b-quic"]) } - if merged["node-b-wss"].Count != 2 { + if merged["node-b-ice"].Count != 2 { t.Fatalf("new pressure missing: %+v", merged) } } +func TestFabricCandidateTransportPolicyStatsCountsRejectedLegacyCandidates(t *testing.T) { + nodes, total, quicTotal, rejectedTotal, rejectedByTransport := fabricCandidateTransportPolicyStats(map[string][]mesh.PeerEndpointCandidate{ + "node-b": { + {EndpointID: "node-b-direct", Transport: "direct_quic"}, + {EndpointID: "node-b-relay", Transport: "relay"}, + {EndpointID: "node-b-wss", Transport: "wss"}, + }, + "node-c": { + {EndpointID: "node-c-ice", Transport: "ice_quic"}, + {EndpointID: "node-c-empty"}, + }, + }) + if nodes != 2 || total != 5 || quicTotal != 2 || rejectedTotal != 3 { + t.Fatalf("stats = nodes:%d total:%d quic:%d rejected:%d", nodes, total, quicTotal, rejectedTotal) + } + if rejectedByTransport["relay"] != 1 || rejectedByTransport["wss"] != 1 || rejectedByTransport["empty"] != 1 { + t.Fatalf("rejected transports = %+v", rejectedByTransport) + } +} + func TestVPNFabricQUICPressureReportRanksBusyConnections(t *testing.T) { report := vpnFabricQUICPressureReport(mesh.QUICFabricTransportSnapshot{ Connections: []mesh.QUICFabricConnSnapshot{ @@ -1334,9 +1618,9 @@ func TestHeartbeatPayloadReportsMeshListenerFailureWithoutKillingHeartbeat(t *te func TestAdvertisedEndpointCandidatesPreferManualEndpoints(t *testing.T) { now := time.Date(2026, 4, 30, 9, 0, 0, 0, time.UTC) candidates, err := advertisedEndpointCandidates(config.Config{ - MeshAdvertiseEndpointsJSON: `[{"endpoint_id":"node-a-json","node_id":"node-a","transport":"direct_http","address":"http://10.10.10.10:19131","priority":12,"connectivity_mode":"private_lan","reachability":"private"}]`, - MeshAdvertiseEndpoint: "http://203.0.113.10:19131", - MeshAdvertiseTransport: "direct_http", + MeshAdvertiseEndpointsJSON: `[{"endpoint_id":"node-a-json","node_id":"node-a","transport":"direct_quic","address":"quic://10.10.10.10:19443","priority":12,"connectivity_mode":"private_lan","reachability":"private"}]`, + MeshAdvertiseEndpoint: "quic://203.0.113.10:19443", + MeshAdvertiseTransport: "direct_quic", MeshConnectivityMode: "direct", MeshNATType: "port_restricted", MeshRegion: "edge", @@ -1348,13 +1632,33 @@ func TestAdvertisedEndpointCandidatesPreferManualEndpoints(t *testing.T) { t.Fatalf("advertised endpoint candidates failed: %v", err) } if len(candidates) != 2 { - t.Fatalf("expected two manual candidates, got %d: %+v", len(candidates), candidates) + t.Fatalf("expected manual and JSON QUIC candidates, got %d: %+v", len(candidates), candidates) } - if candidates[0].Address != "http://203.0.113.10:19131" || candidates[0].Priority != 10 { + if candidates[0].Address != "quic://203.0.113.10:19443" || candidates[0].Priority != 10 { t.Fatalf("explicit advertise endpoint must win: %+v", candidates) } - if candidates[1].Address != "http://10.10.10.10:19131" || candidates[1].Priority != 12 { - t.Fatalf("json candidate order mismatch: %+v", candidates) + if candidates[1].EndpointID != "node-a-json" { + t.Fatalf("configured endpoint must remain as fallback candidate: %+v", candidates) + } +} + +func TestAdvertisedEndpointCandidatesRejectsLegacyConfiguredCandidateTransport(t *testing.T) { + _, err := advertisedEndpointCandidates(config.Config{ + MeshAdvertiseEndpointsJSON: `[{"endpoint_id":"node-a-ws","node_id":"node-a","transport":"websocket","address":"quic://10.10.10.10:19443","connectivity_mode":"direct","reachability":"public"}]`, + MeshAdvertiseTransport: "direct_quic", + }, state.Identity{ClusterID: "cluster-1", NodeID: "node-a"}, nil, time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)) + if err == nil || !strings.Contains(err.Error(), "QUIC transport") { + t.Fatalf("expected QUIC transport validation error, got %v", err) + } +} + +func TestAdvertisedEndpointCandidatesRejectsLegacyConfiguredCandidateScheme(t *testing.T) { + _, err := advertisedEndpointCandidates(config.Config{ + MeshAdvertiseEndpointsJSON: `[{"endpoint_id":"node-a-https","node_id":"node-a","transport":"direct_quic","address":"https://node-a.example.test:443","connectivity_mode":"direct","reachability":"public"}]`, + MeshAdvertiseTransport: "direct_quic", + }, state.Identity{ClusterID: "cluster-1", NodeID: "node-a"}, nil, time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)) + if err == nil || !strings.Contains(err.Error(), "QUIC endpoint") { + t.Fatalf("expected QUIC endpoint validation error, got %v", err) } } @@ -1463,8 +1767,8 @@ func TestHeartbeatPayloadReportsMultipleMeshEndpoints(t *testing.T) { payload := heartbeatPayload(config.Config{ MeshAdvertiseEndpointsJSON: `[{ "endpoint_id": "node-a-lan", - "address": "http://10.24.10.10:19001", - "transport": "direct_tcp_tls", + "address": "quic://10.24.10.10:19443", + "transport": "direct_quic", "reachability": "private", "connectivity_mode": "direct", "nat_type": "none", @@ -1473,8 +1777,8 @@ func TestHeartbeatPayloadReportsMultipleMeshEndpoints(t *testing.T) { "policy_tags": ["corp-lan", "same-site"] },{ "endpoint_id": "node-a-public", - "address": "https://node-a.example.test:443", - "transport": "direct_tcp_tls", + "address": "quic://node-a.example.test:19443", + "transport": "direct_quic", "reachability": "public", "connectivity_mode": "direct", "nat_type": "none", @@ -1497,20 +1801,55 @@ func TestHeartbeatPayloadReportsMultipleMeshEndpoints(t *testing.T) { if candidates[0].EndpointID != "node-a-lan" || candidates[0].Reachability != "private" { t.Fatalf("internal endpoint candidate not preserved: %+v", candidates[0]) } - if report["peer_endpoint"] != "http://10.24.10.10:19001" { + if report["peer_endpoint"] != "quic://10.24.10.10:19443" { t.Fatalf("default peer endpoint = %v", report["peer_endpoint"]) } } +func TestAdvertisedEndpointCandidatesIncludeSTUNAndRelayFallback(t *testing.T) { + now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) + candidates, err := advertisedEndpointCandidates(config.Config{ + MeshAdvertiseTransport: "quic", + MeshConnectivityMode: "outbound_only", + MeshNATType: "symmetric", + MeshLocalSegmentID: "site-a", + MeshNATGroupID: "nat-a", + MeshSTUNReflexiveEndpoint: "quic://203.0.113.22:19443", + MeshSTUNServer: "stun.example.test:3478", + MeshRelayNodeID: "node-r", + MeshRelayEndpoint: "quic://node-r.example.test:19443", + MeshProductionForwardingEnabled: true, + }, state.Identity{ClusterID: "cluster-1", NodeID: "node-a"}, nil, now) + if err != nil { + t.Fatalf("advertised endpoint candidates: %v", err) + } + if len(candidates) != 2 { + t.Fatalf("candidates = %+v, want STUN and relay fallback", candidates) + } + if candidates[0].EndpointID != "node-a-stun-reflexive" || candidates[0].Transport != "ice_quic" || candidates[0].Reachability != "public" { + t.Fatalf("unexpected STUN candidate: %+v", candidates[0]) + } + if candidates[1].EndpointID != "node-a-relay-fallback" || candidates[1].Transport != "relay_quic" || candidates[1].ConnectivityMode != "relay_required" { + t.Fatalf("unexpected relay candidate: %+v", candidates[1]) + } + var metadata map[string]any + if err := json.Unmarshal(candidates[0].Metadata, &metadata); err != nil { + t.Fatalf("metadata parse: %v", err) + } + if metadata["local_segment_id"] != "site-a" || metadata["nat_group_id"] != "nat-a" || metadata["stun_server"] != "stun.example.test:3478" { + t.Fatalf("missing discovery metadata: %+v", metadata) + } +} + func TestHeartbeatPayloadIncludesPeerRecoveryReportWithoutAdvertisedEndpoint(t *testing.T) { now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) local := mesh.PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"} peerCache := mesh.NewPeerCache(mesh.PeerCacheConfig{ Local: local, PeerEndpoints: map[string]string{ - "node-b": "http://node-b:19001", - "node-c": "http://node-c:19001", - "node-d": "http://node-d:19001", + "node-b": "quic://node-b:19443", + "node-c": "quic://node-c:19443", + "node-d": "quic://node-d:19443", }, WarmPeerLimit: 3, Now: now, @@ -1569,8 +1908,8 @@ func TestHeartbeatPayloadIncludesRendezvousLeaseAdmissionReport(t *testing.T) { LeaseID: "lease-node-b-via-node-a", PeerNodeID: "node-b", RelayNodeID: "node-a", - RelayEndpoint: "http://node-a:19001", - Transport: "relay_control", + RelayEndpoint: "quic://node-a:19443", + Transport: "relay_quic", ConnectivityMode: "relay_required", RouteIDs: []string{"route-ab"}, AllowedChannels: []string{mesh.SyntheticChannelFabricControl}, @@ -1583,8 +1922,8 @@ func TestHeartbeatPayloadIncludesRendezvousLeaseAdmissionReport(t *testing.T) { LeaseID: "lease-node-a-via-node-r", PeerNodeID: "node-a", RelayNodeID: "node-r", - RelayEndpoint: "http://node-r:19001", - Transport: "relay_control", + RelayEndpoint: "quic://node-r:19443", + Transport: "relay_quic", ConnectivityMode: "relay_required", RouteIDs: []string{"route-ra"}, Priority: 20, @@ -1596,8 +1935,8 @@ func TestHeartbeatPayloadIncludesRendezvousLeaseAdmissionReport(t *testing.T) { LeaseID: "lease-node-c-via-node-r-expired", PeerNodeID: "node-c", RelayNodeID: "node-r", - RelayEndpoint: "http://node-r:19001", - Transport: "relay_control", + RelayEndpoint: "quic://node-r:19443", + Transport: "relay_quic", ConnectivityMode: "relay_required", RouteIDs: []string{"route-cr"}, Priority: 30, @@ -1615,13 +1954,13 @@ func TestHeartbeatPayloadIncludesRendezvousLeaseAdmissionReport(t *testing.T) { tracker := mesh.NewPeerConnectionTracker(cache.Snapshot(), now) tracker.RecordRelayReady(mesh.PeerCacheEntry{ NodeID: "node-b", - Endpoint: "http://node-a:19001", + Endpoint: "quic://node-a:19443", Warm: true, RendezvousLeaseID: "lease-node-b-via-node-a", RelayNodeID: "node-a", - RelayEndpoint: "http://node-a:19001", + RelayEndpoint: "quic://node-a:19443", RelayControl: true, - BestTransport: "relay_control", + BestTransport: "relay_quic", BestReachability: "relay", BestConnectivity: "relay_required", BestCandidateScore: 500, @@ -1680,8 +2019,8 @@ func TestHeartbeatPayloadReportsStaleRelayWithdrawalTelemetry(t *testing.T) { LeaseID: "lease-node-b-via-node-r", PeerNodeID: "node-b", RelayNodeID: "node-r", - RelayEndpoint: "http://node-r:19001", - Transport: "relay_control", + RelayEndpoint: "quic://node-r:19443", + Transport: "relay_quic", ConnectivityMode: "relay_required", RouteIDs: []string{"route-rb"}, Priority: 10, @@ -1692,7 +2031,7 @@ func TestHeartbeatPayloadReportsStaleRelayWithdrawalTelemetry(t *testing.T) { altLease := lease altLease.LeaseID = "lease-node-b-via-node-r2" altLease.RelayNodeID = "node-r2" - altLease.RelayEndpoint = "http://node-r2:19001" + altLease.RelayEndpoint = "quic://node-r2:19443" altLease.Priority = 20 cache := mesh.NewPeerCache(mesh.PeerCacheConfig{ Local: mesh.PeerIdentity{ClusterID: identity.ClusterID, NodeID: identity.NodeID}, @@ -1703,11 +2042,11 @@ func TestHeartbeatPayloadReportsStaleRelayWithdrawalTelemetry(t *testing.T) { tracker := mesh.NewPeerConnectionTracker(cache.Snapshot(), now) peer := mesh.PeerCacheEntry{ NodeID: "node-b", - Endpoint: "http://node-r:19001", + Endpoint: "quic://node-r:19443", Warm: true, RendezvousLeaseID: "lease-node-b-via-node-r", RelayNodeID: "node-r", - RelayEndpoint: "http://node-r:19001", + RelayEndpoint: "quic://node-r:19443", RelayControl: true, } tracker.RecordRelayReady(peer, 10, now.Add(time.Second)) @@ -1749,8 +2088,8 @@ func TestRefreshRendezvousLeasesIfNeededReloadsControlPlaneConfig(t *testing.T) LeaseID: "lease-node-b-via-node-r-old", PeerNodeID: "node-b", RelayNodeID: "node-r-old", - RelayEndpoint: "http://node-r-old:19001", - Transport: "relay_control", + RelayEndpoint: "quic://node-r-old:19443", + Transport: "relay_quic", ConnectivityMode: "relay_required", RouteIDs: []string{"route-ab"}, Priority: 10, @@ -1824,14 +2163,14 @@ func TestRefreshRendezvousLeasesIfNeededReloadsControlPlaneConfig(t *testing.T) "config_version": "new-config", "peer_directory_version": "new-config", "policy_version": "new-config", - "peer_endpoints": map[string]string{"node-r-new": "http://node-r-new:19001"}, + "peer_endpoints": map[string]string{"node-r-new": "quic://node-r-new:19443"}, "peer_endpoint_candidates": map[string]any{ "node-b": []map[string]any{ { "endpoint_id": "node-b-outbound-only", "node_id": "node-b", - "transport": "outbound_reverse", - "address": "http://node-b:19002", + "transport": "reverse_quic", + "address": "quic://node-b:19002", "address_family": "ipv4", "reachability": "outbound_only", "connectivity_mode": "outbound_only", @@ -1849,8 +2188,8 @@ func TestRefreshRendezvousLeasesIfNeededReloadsControlPlaneConfig(t *testing.T) "lease_id": "lease-node-b-via-node-r-new", "peer_node_id": "node-b", "relay_node_id": "node-r-new", - "relay_endpoint": "http://node-r-new:19001", - "transport": "relay_control", + "relay_endpoint": "quic://node-r-new:19443", + "transport": "relay_quic", "connectivity_mode": "relay_required", "route_ids": []string{"route-ab"}, "allowed_channels": []string{mesh.SyntheticChannelFabricControl}, @@ -1882,7 +2221,7 @@ func TestRefreshRendezvousLeasesIfNeededReloadsControlPlaneConfig(t *testing.T) "next_hop_id": "node-r-new", "local_role": "entry", "selected_relay_id": "node-r-new", - "selected_relay_endpoint": "http://node-r-new:19001", + "selected_relay_endpoint": "quic://node-r-new:19443", "stale_relay_node_id": "node-r-old", "rendezvous_lease_id": "lease-node-b-via-node-r-new", "rendezvous_lease_reason": "stale_relay_replacement", @@ -2015,7 +2354,7 @@ func TestRouteHealthFeedbackRefreshAppliesReplacementConfig(t *testing.T) { } oldCache := mesh.NewPeerCache(mesh.PeerCacheConfig{ Local: local, - PeerEndpoints: map[string]string{"node-r-old": "http://node-r-old:19001"}, + PeerEndpoints: map[string]string{"node-r-old": "quic://node-r-old:19443"}, Routes: []mesh.SyntheticRoute{oldRoute}, WarmPeerLimit: 1, Now: now, @@ -2044,14 +2383,14 @@ func TestRouteHealthFeedbackRefreshAppliesReplacementConfig(t *testing.T) { "config_version": "new-config", "peer_directory_version": "new-config", "policy_version": "new-config", - "peer_endpoints": map[string]string{"node-r-new": "http://node-r-new:19001"}, + "peer_endpoints": map[string]string{"node-r-new": "quic://node-r-new:19443"}, "rendezvous_leases": []map[string]any{ { "lease_id": "lease-node-b-via-node-r-new", "peer_node_id": "node-b", "relay_node_id": "node-r-new", - "relay_endpoint": "http://node-r-new:19001", - "transport": "relay_control", + "relay_endpoint": "quic://node-r-new:19443", + "transport": "relay_quic", "connectivity_mode": "relay_required", "route_ids": []string{"route-ab"}, "allowed_channels": []string{mesh.SyntheticChannelFabricControl}, @@ -2083,7 +2422,7 @@ func TestRouteHealthFeedbackRefreshAppliesReplacementConfig(t *testing.T) { "next_hop_id": "node-r-new", "local_role": "entry", "selected_relay_id": "node-r-new", - "selected_relay_endpoint": "http://node-r-new:19001", + "selected_relay_endpoint": "quic://node-r-new:19443", "stale_relay_node_id": "node-r-old", "rendezvous_peer_node_id": "node-b", "rendezvous_lease_id": "lease-node-b-via-node-r-new", @@ -2174,7 +2513,7 @@ func TestRouteHealthFeedbackRefreshBackoffSuppressesRepeatedTrigger(t *testing.T local := mesh.PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"} cache := mesh.NewPeerCache(mesh.PeerCacheConfig{ Local: local, - PeerEndpoints: map[string]string{"node-b": "http://node-b:19002"}, + PeerEndpoints: map[string]string{"node-b": "quic://node-b:19443"}, WarmPeerLimit: 1, Now: now, }) @@ -2265,7 +2604,7 @@ func TestMeshRouteGenerationTrackerReportsReplacementWithdrawOnFirstApply(t *tes ControlPlaneOnly: true, ProductionForwarding: false, RendezvousLeaseReason: "stale_relay_replacement", - SelectedRelayEndpoint: "http://node-r-new:19124", + SelectedRelayEndpoint: "quic://node-r-new:19443", }, }, } @@ -2383,3 +2722,118 @@ func TestProductionObservationSinkMetricsEqual(t *testing.T) { t.Fatal("different metrics were equal") } } + +func TestWebIngressForwardHandlerFromConfigVerifiesSignedEnvelope(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + keyID := "web-key-1" + handler := webIngressForwardHandlerFromConfig(config.Config{ + WebIngressTrustedKeysJSON: webingress.TrustedKeysJSONForPublicKey(keyID, publicKey), + }, state.Identity{ClusterID: "cluster-1", NodeID: "node-1"}, nil) + if handler == nil { + t.Fatal("handler is nil") + } + envelope := webingress.FabricServiceChannelEnvelope{ + SchemaVersion: webingress.FabricServiceChannelEnvelopeSchema, + RequestSchema: "rap.web_ingress.fabric_request.v1", + Method: http.MethodGet, + Path: "/platform-admin/root", + ServiceType: "admin-ingress", + Scope: "platform", + ServiceClass: "platform_admin", + ObservedAt: time.Now().UTC().Format(time.RFC3339Nano), + EnvelopedAt: time.Now().UTC().Format(time.RFC3339Nano), + } + canonical, err := json.Marshal(envelope) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + payload, err := json.Marshal(webingress.SignedFabricServiceChannelEnvelope{ + SchemaVersion: webingress.SignedFabricServiceChannelEnvelopeSchema, + Envelope: envelope, + Signature: webingress.FabricEnvelopeSignature{ + KeyID: keyID, + Alg: "ed25519", + Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)), + }, + }) + if err != nil { + t.Fatalf("marshal signed envelope: %v", err) + } + responsePayload, err := handler(context.Background(), payload) + if err != nil { + t.Fatalf("handler: %v", err) + } + var response struct { + SchemaVersion string `json:"schema_version"` + StatusCode int `json:"status_code"` + BodyBase64 string `json:"body_b64"` + } + if err := json.Unmarshal(responsePayload, &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.SchemaVersion != webingress.FabricRuntimeResponseSchema || response.StatusCode != http.StatusBadGateway || response.BodyBase64 == "" { + t.Fatalf("response = %+v", response) + } +} + +func TestWebIngressForwardHandlerFromConfigDisabledWithoutTrustedKeys(t *testing.T) { + if handler := webIngressForwardHandlerFromConfig(config.Config{}, state.Identity{}, 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 { + t.Fatal("handler should be nil with invalid trusted keys") + } +} + +func TestWebIngressRuntimeServiceClassesFromConfig(t *testing.T) { + defaultClasses := webIngressRuntimeServiceClassesFromConfig(config.Config{}) + if !containsString(defaultClasses, "platform_admin") || !containsString(defaultClasses, "user_portal") { + t.Fatalf("default classes = %v", defaultClasses) + } + + limited := webIngressRuntimeServiceClassesFromConfig(config.Config{ + WebIngressRuntimeServiceClasses: " cluster_admin, unknown, cluster_admin ", + }) + if len(limited) != 1 || limited[0] != "cluster_admin" { + t.Fatalf("limited classes = %v", limited) + } +} + +func TestWebIngressRuntimeReceiverReport(t *testing.T) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + report := webIngressRuntimeReceiverReport(config.Config{ + WebIngressTrustedKeysJSON: webingress.TrustedKeysJSONForPublicKey("web-key-1", publicKey), + WebIngressRuntimeServiceClasses: "cluster_admin", + }, &syntheticMeshState{QUICFabricListenAddr: "127.0.0.1:19443"}, time.Date(2026, 5, 17, 1, 2, 3, 0, time.UTC)) + + if report["schema_version"] != "rap.web_ingress.runtime_receiver_report.v1" || + report["enabled"] != true || + report["handler_installed"] != true || + report["status"] != "degraded" || + report["reason"] != "quic_fabric_not_ready" || + report["trusted_key_count"] != 1 || + report["quic_stream_id"] != mesh.WebIngressForwardQUICStreamID || + report["quic_fabric_enabled"] != true { + t.Fatalf("report = %+v", report) + } + classes, ok := report["service_classes"].([]string) + if !ok || len(classes) != 1 || classes[0] != "cluster_admin" { + t.Fatalf("service_classes = %#v", report["service_classes"]) + } +} + +func TestWebIngressRuntimeReceiverReportWithoutTrustedKeys(t *testing.T) { + report := webIngressRuntimeReceiverReport(config.Config{}, nil, time.Date(2026, 5, 17, 1, 2, 3, 0, time.UTC)) + if report["enabled"] != false || + report["handler_installed"] != false || + report["status"] != "blocked" || + report["reason"] != "trusted_keys_required" { + t.Fatalf("report = %+v", report) + } +} diff --git a/agents/rap-node-agent/go.mod b/agents/rap-node-agent/go.mod index ec8920e..6b30a38 100644 --- a/agents/rap-node-agent/go.mod +++ b/agents/rap-node-agent/go.mod @@ -2,15 +2,21 @@ module github.com/example/remote-access-platform/agents/rap-node-agent go 1.25.5 -require golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb +require ( + github.com/gorilla/websocket v1.5.3 + github.com/quic-go/quic-go v0.59.1 + golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb +) require ( - github.com/gorilla/websocket v1.5.3 // indirect - github.com/quic-go/quic-go v0.59.1 // indirect - golang.org/x/crypto v0.50.0 // indirect - golang.org/x/net v0.53.0 // indirect - golang.org/x/sys v0.43.0 // indirect + golang.org/x/crypto v0.51.0 // indirect + golang.org/x/mobile v0.0.0-20260514233045-7de0a8fa7f4d // indirect + golang.org/x/mod v0.36.0 // indirect + golang.org/x/net v0.54.0 // indirect + golang.org/x/sync v0.20.0 // indirect + golang.org/x/sys v0.44.0 // indirect golang.org/x/time v0.15.0 // indirect + golang.org/x/tools v0.45.0 // indirect golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 // indirect gvisor.dev/gvisor v0.0.0-20260505022556-2306ef3db943 // indirect ) diff --git a/agents/rap-node-agent/go.sum b/agents/rap-node-agent/go.sum index 2342f65..1b23599 100644 --- a/agents/rap-node-agent/go.sum +++ b/agents/rap-node-agent/go.sum @@ -1,20 +1,38 @@ +github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c= +github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38= github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg= github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE= +github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM= +github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4= github.com/quic-go/quic-go v0.59.1 h1:0Gmua0HW1Tv7ANR7hUYwRyD0MG5OJfgvYSZasGZzBic= github.com/quic-go/quic-go v0.59.1/go.mod h1:upnsH4Ju1YkqpLXC305eW3yDZ4NfnNbmQRCMWS58IKU= -golang.org/x/crypto v0.50.0 h1:zO47/JPrL6vsNkINmLoo/PH1gcxpls50DNogFvB5ZGI= -golang.org/x/crypto v0.50.0/go.mod h1:3muZ7vA7PBCE6xgPX7nkzzjiUq87kRItoJQM1Yo8S+Q= +github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U= +github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U= +go.uber.org/mock v0.5.2 h1:LbtPTcP8A5k9WPXj54PPPbjcI4Y6lhyOZXn+VS7wNko= +go.uber.org/mock v0.5.2/go.mod h1:wLlUxC2vVTPTaE3UD51E0BGOAElKrILxhVSDYQLld5o= +golang.org/x/crypto v0.51.0 h1:IBPXwPfKxY7cWQZ38ZCIRPI50YLeevDLlLnyC5wRGTI= +golang.org/x/crypto v0.51.0/go.mod h1:8AdwkbraGNABw2kOX6YFPs3WM22XqI4EXEd8g+x7Oc8= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa h1:FRnLl4eNAQl8hwxVVC17teOw8kdjVDVAiFMtgUdTSRQ= golang.org/x/exp v0.0.0-20231110203233-9a3e6036ecaa/go.mod h1:zk2irFbV9DP96SEBUUAy67IdHUaZuSnrz1n472HUCLE= -golang.org/x/net v0.53.0 h1:d+qAbo5L0orcWAr0a9JweQpjXF19LMXJE8Ey7hwOdUA= -golang.org/x/net v0.53.0/go.mod h1:JvMuJH7rrdiCfbeHoo3fCQU24Lf5JJwT9W3sJFulfgs= -golang.org/x/sys v0.43.0 h1:Rlag2XtaFTxp19wS8MXlJwTvoh8ArU6ezoyFsMyCTNI= -golang.org/x/sys v0.43.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= +golang.org/x/mobile v0.0.0-20260514233045-7de0a8fa7f4d h1:XNPSUMmnREiyj6HdYfJjTJVQIC5c1b3+qV7mbxUjzwk= +golang.org/x/mobile v0.0.0-20260514233045-7de0a8fa7f4d/go.mod h1:ltIbhcRzKgwHa4ZxKJeiv0nyzcXUUYCqMyO0Y+vPmXw= +golang.org/x/mod v0.36.0 h1:JJjpVx6myfUsUdAzZuOSTTmRE0PfZeNWzzvKrP7amb4= +golang.org/x/mod v0.36.0/go.mod h1:moc6ELqsWcOw5Ef3xVprK5ul/MvtVvkIXLziUOICjUQ= +golang.org/x/net v0.54.0 h1:2zJIZAxAHV/OHCDTCOHAYehQzLfSXuf/5SoL/Dv6w/w= +golang.org/x/net v0.54.0/go.mod h1:Sj4oj8jK6XmHpBZU/zWHw3BV3abl4Kvi+Ut7cQcY+cQ= +golang.org/x/sync v0.20.0 h1:e0PTpb7pjO8GAtTs2dQ6jYa5BWYlMuX047Dco/pItO4= +golang.org/x/sync v0.20.0/go.mod h1:9xrNwdLfx4jkKbNva9FpL6vEN7evnE43NNNJQ2LF3+0= +golang.org/x/sys v0.44.0 h1:ildZl3J4uzeKP07r2F++Op7E9B29JRUy+a27EibtBTQ= +golang.org/x/sys v0.44.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw= golang.org/x/time v0.15.0 h1:bbrp8t3bGUeFOx08pvsMYRTCVSMk89u4tKbNOZbp88U= golang.org/x/time v0.15.0/go.mod h1:Y4YMaQmXwGQZoFaVFk4YpCt4FLQMYKZe9oeV/f4MSno= +golang.org/x/tools v0.45.0 h1:18qN3FAooORvApf5XjCXgsuayZOEtXf6JK18I3+ONa8= +golang.org/x/tools v0.45.0/go.mod h1:LuUGqqaXcXMEFEruIVJVm5mgDD8vww/z/SR1gQ4uE/0= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2 h1:B82qJJgjvYKsXS9jeunTOisW56dUokqW/FOteYJJ/yg= golang.zx2c4.com/wintun v0.0.0-20230126152724-0fa3db229ce2/go.mod h1:deeaetjYA+DHMHg+sMSMI58GrEteJUUzzw7en6TJQcI= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb h1:whnFRlWMcXI9d+ZbWg+4sHnLp52d5yiIPUxMBSt4X9A= golang.zx2c4.com/wireguard v0.0.0-20250521234502-f333402bd9cb/go.mod h1:rpwXGsirqLqN2L0JDJQlwOboGHmptD5ZD6T2VmcqhTw= +gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA= +gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM= gvisor.dev/gvisor v0.0.0-20260505022556-2306ef3db943 h1:YUPk0vGbex2+Jk7XXIgLIPG6oEAD9ml0x7wd6i/bmA4= gvisor.dev/gvisor v0.0.0-20260505022556-2306ef3db943/go.mod h1:xQ2PWgHmWJA/Ph4i1q1jBm39BKhc3W0DXqWoDSyuBOY= diff --git a/agents/rap-node-agent/internal/agent/payload.go b/agents/rap-node-agent/internal/agent/payload.go index 119db58..e6417ec 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.280-fabricsession" +const Version = "0.2.309-latencyaware" func EnrollmentPayload(clusterID, joinToken string, identity state.Identity) client.EnrollRequest { return client.EnrollRequest{ @@ -38,9 +38,12 @@ func EnrollmentPayload(clusterID, joinToken string, identity state.Identity) cli "vpn_local_gateway_shortcut": false, "vpn_farm_owned_dataplane": true, "fabric_data_session_v1": true, - "fabric_session_websocket_smoke": true, + "fabric_session_quic_smoke": true, "vpn_backend_relay_fallback": false, "fabric_service_channel_required": true, + "web_ingress_workload_contract": "rap.web_ingress.workload_contract.v1", + "web_ingress_real_listener_gate": "RAP_WEB_INGRESS_RUNTIME_ENABLED", + "web_ingress_runtime_enabled": false, "external_backend_entry_proxy": true, }, ReportedFacts: map[string]any{ @@ -67,9 +70,12 @@ func HeartbeatPayload() client.HeartbeatRequest { "vpn_local_gateway_shortcut": false, "vpn_farm_owned_dataplane": true, "fabric_data_session_v1": true, - "fabric_session_websocket_smoke": true, + "fabric_session_quic_smoke": true, "vpn_backend_relay_fallback": false, "fabric_service_channel_required": true, + "web_ingress_workload_contract": "rap.web_ingress.workload_contract.v1", + "web_ingress_real_listener_gate": "RAP_WEB_INGRESS_RUNTIME_ENABLED", + "web_ingress_runtime_enabled": false, "external_backend_entry_proxy": true, }, ServiceStates: map[string]any{ diff --git a/agents/rap-node-agent/internal/authority/authority.go b/agents/rap-node-agent/internal/authority/authority.go index 7c1cb52..9b23097 100644 --- a/agents/rap-node-agent/internal/authority/authority.go +++ b/agents/rap-node-agent/internal/authority/authority.go @@ -14,6 +14,8 @@ import ( const ( AuthoritySchemaVersion = "rap.cluster_authority.v1" SignatureSchemaVersion = "rap.cluster_authority.signature.v1" + QuorumSchemaVersion = "rap.cluster_authority.quorum.v1" + QuorumEnvelopeVersion = "rap.cluster_authority.quorum_envelope.v1" AlgorithmEd25519 = "ed25519" ) @@ -30,6 +32,34 @@ type Signature struct { Signature string `json:"signature"` } +type QuorumMember struct { + NodeID string `json:"node_id,omitempty"` + Role string `json:"role,omitempty"` + PublicKey string `json:"public_key"` + PublicKeyFingerprint string `json:"public_key_fingerprint"` + Scopes []string `json:"scopes,omitempty"` +} + +type QuorumDescriptor struct { + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + Epoch string `json:"epoch"` + Threshold int `json:"threshold"` + Members []QuorumMember `json:"members"` +} + +type QuorumEnvelope struct { + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + Epoch string `json:"epoch"` + Threshold int `json:"threshold"` + PayloadSHA256 string `json:"payload_sha256"` + QuorumSHA256 string `json:"quorum_sha256"` + Signatures []Signature `json:"signatures"` + AllowedScopes []string `json:"allowed_scopes,omitempty"` + DecisionReason string `json:"decision_reason,omitempty"` +} + func VerifyRaw(publicKeyB64 string, payload json.RawMessage, signature Signature) error { if signature.SchemaVersion != SignatureSchemaVersion { return fmt.Errorf("%w: schema_version must be %s", ErrInvalidSignature, SignatureSchemaVersion) @@ -58,6 +88,86 @@ func VerifyRaw(publicKeyB64 string, payload json.RawMessage, signature Signature return nil } +func VerifyQuorumRaw(descriptor QuorumDescriptor, payload json.RawMessage, envelope QuorumEnvelope, requiredScope string) error { + if descriptor.SchemaVersion != QuorumSchemaVersion { + return fmt.Errorf("%w: quorum schema_version must be %s", ErrInvalidSignature, QuorumSchemaVersion) + } + if envelope.SchemaVersion != QuorumEnvelopeVersion { + return fmt.Errorf("%w: quorum envelope schema_version must be %s", ErrInvalidSignature, QuorumEnvelopeVersion) + } + if strings.TrimSpace(descriptor.ClusterID) == "" || descriptor.ClusterID != envelope.ClusterID { + return fmt.Errorf("%w: quorum cluster mismatch", ErrInvalidSignature) + } + if strings.TrimSpace(descriptor.Epoch) == "" || descriptor.Epoch != envelope.Epoch { + return fmt.Errorf("%w: quorum epoch mismatch", ErrInvalidSignature) + } + threshold := descriptor.Threshold + if envelope.Threshold > threshold { + threshold = envelope.Threshold + } + if threshold <= 0 || threshold > len(descriptor.Members) { + return fmt.Errorf("%w: invalid quorum threshold", ErrInvalidSignature) + } + payloadHash, err := HashRaw(payload) + if err != nil { + return err + } + if envelope.PayloadSHA256 != payloadHash { + return fmt.Errorf("%w: quorum payload hash mismatch", ErrInvalidSignature) + } + descriptorHash, err := HashRaw(mustMarshalQuorumDescriptor(descriptor)) + if err != nil { + return err + } + if envelope.QuorumSHA256 != descriptorHash { + return fmt.Errorf("%w: quorum descriptor hash mismatch", ErrInvalidSignature) + } + members := map[string]QuorumMember{} + for _, member := range descriptor.Members { + fingerprint := strings.TrimSpace(member.PublicKeyFingerprint) + if fingerprint == "" { + publicKey, err := decodePublicKey(member.PublicKey) + if err != nil { + return err + } + fingerprint = Fingerprint(publicKey) + } + if _, exists := members[fingerprint]; exists { + return fmt.Errorf("%w: duplicate quorum member", ErrInvalidSignature) + } + member.PublicKeyFingerprint = fingerprint + members[fingerprint] = member + } + seen := map[string]bool{} + valid := 0 + for _, signature := range envelope.Signatures { + fingerprint := strings.TrimSpace(signature.KeyFingerprint) + if seen[fingerprint] { + continue + } + member, ok := members[fingerprint] + if !ok { + return fmt.Errorf("%w: quorum signer is not a member", ErrInvalidSignature) + } + if requiredScope != "" && !memberAllowsScope(member, requiredScope) { + return fmt.Errorf("%w: quorum signer scope mismatch", ErrInvalidSignature) + } + if err := VerifyRaw(member.PublicKey, payload, signature); err != nil { + return err + } + seen[fingerprint] = true + valid++ + } + if valid < threshold { + return fmt.Errorf("%w: quorum threshold not met", ErrInvalidSignature) + } + return nil +} + +func QuorumDescriptorHash(descriptor QuorumDescriptor) (string, error) { + return HashRaw(mustMarshalQuorumDescriptor(descriptor)) +} + func Fingerprint(publicKey ed25519.PublicKey) string { sum := sha256.Sum256(publicKey) return "rap-ca-ed25519-" + hex.EncodeToString(sum[:16]) @@ -72,6 +182,28 @@ func HashRaw(raw json.RawMessage) (string, error) { return hex.EncodeToString(sum[:]), nil } +func mustMarshalQuorumDescriptor(descriptor QuorumDescriptor) json.RawMessage { + raw, err := json.Marshal(descriptor) + if err != nil { + return nil + } + return raw +} + +func memberAllowsScope(member QuorumMember, requiredScope string) bool { + requiredScope = strings.TrimSpace(requiredScope) + if requiredScope == "" { + return true + } + for _, scope := range member.Scopes { + scope = strings.TrimSpace(scope) + if scope == "*" || scope == requiredScope { + return true + } + } + return false +} + func CanonicalJSON(raw json.RawMessage) ([]byte, error) { if len(raw) == 0 { return nil, fmt.Errorf("%w: empty payload", ErrInvalidPayload) diff --git a/agents/rap-node-agent/internal/authority/authority_test.go b/agents/rap-node-agent/internal/authority/authority_test.go index 2c64fb9..a639af6 100644 --- a/agents/rap-node-agent/internal/authority/authority_test.go +++ b/agents/rap-node-agent/internal/authority/authority_test.go @@ -5,6 +5,7 @@ import ( "encoding/base64" "encoding/json" "errors" + "fmt" "testing" ) @@ -50,3 +51,114 @@ func TestVerifyRawRejectsTamperedPayload(t *testing.T) { t.Fatalf("err = %v, want ErrInvalidSignature", err) } } + +func TestVerifyQuorumRawAcceptsThreshold(t *testing.T) { + payload := json.RawMessage(`{"schema_version":"rap.node_update_plan_authority.v1","cluster_id":"cluster-1","action":"update"}`) + descriptor, privateKeys := testQuorumDescriptor(t, 3, 2) + payloadHash, err := HashRaw(payload) + if err != nil { + t.Fatalf("payload hash: %v", err) + } + quorumHash, err := QuorumDescriptorHash(descriptor) + if err != nil { + t.Fatalf("quorum hash: %v", err) + } + envelope := QuorumEnvelope{ + SchemaVersion: QuorumEnvelopeVersion, + ClusterID: "cluster-1", + Epoch: "epoch-1", + Threshold: 2, + PayloadSHA256: payloadHash, + QuorumSHA256: quorumHash, + Signatures: []Signature{ + signTestPayload(t, payload, privateKeys[0]), + signTestPayload(t, payload, privateKeys[1]), + }, + } + if err := VerifyQuorumRaw(descriptor, payload, envelope, "update-authority"); err != nil { + t.Fatalf("VerifyQuorumRaw: %v", err) + } +} + +func TestVerifyQuorumRawRejectsBelowThreshold(t *testing.T) { + payload := json.RawMessage(`{"schema_version":"rap.node_update_plan_authority.v1","cluster_id":"cluster-1","action":"update"}`) + descriptor, privateKeys := testQuorumDescriptor(t, 3, 2) + payloadHash, _ := HashRaw(payload) + quorumHash, _ := QuorumDescriptorHash(descriptor) + envelope := QuorumEnvelope{ + SchemaVersion: QuorumEnvelopeVersion, + ClusterID: "cluster-1", + Epoch: "epoch-1", + Threshold: 2, + PayloadSHA256: payloadHash, + QuorumSHA256: quorumHash, + Signatures: []Signature{signTestPayload(t, payload, privateKeys[0])}, + } + if err := VerifyQuorumRaw(descriptor, payload, envelope, "update-authority"); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("err = %v, want ErrInvalidSignature", err) + } +} + +func TestVerifyQuorumRawRejectsTamperedDescriptor(t *testing.T) { + payload := json.RawMessage(`{"schema_version":"rap.node_update_plan_authority.v1","cluster_id":"cluster-1","action":"update"}`) + descriptor, privateKeys := testQuorumDescriptor(t, 3, 2) + payloadHash, _ := HashRaw(payload) + quorumHash, _ := QuorumDescriptorHash(descriptor) + descriptor.Threshold = 1 + envelope := QuorumEnvelope{ + SchemaVersion: QuorumEnvelopeVersion, + ClusterID: "cluster-1", + Epoch: "epoch-1", + Threshold: 2, + PayloadSHA256: payloadHash, + QuorumSHA256: quorumHash, + Signatures: []Signature{ + signTestPayload(t, payload, privateKeys[0]), + signTestPayload(t, payload, privateKeys[1]), + }, + } + if err := VerifyQuorumRaw(descriptor, payload, envelope, "update-authority"); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("err = %v, want ErrInvalidSignature", err) + } +} + +func testQuorumDescriptor(t *testing.T, members int, threshold int) (QuorumDescriptor, []ed25519.PrivateKey) { + t.Helper() + descriptor := QuorumDescriptor{ + SchemaVersion: QuorumSchemaVersion, + ClusterID: "cluster-1", + Epoch: "epoch-1", + Threshold: threshold, + } + privateKeys := make([]ed25519.PrivateKey, 0, members) + for i := 0; i < members; i++ { + publicKey, privateKey, err := ed25519.GenerateKey(nil) + if err != nil { + t.Fatalf("GenerateKey: %v", err) + } + descriptor.Members = append(descriptor.Members, QuorumMember{ + NodeID: fmt.Sprintf("authority-%d", i+1), + Role: "update-authority", + PublicKey: base64.StdEncoding.EncodeToString(publicKey), + PublicKeyFingerprint: Fingerprint(publicKey), + Scopes: []string{"update-authority"}, + }) + privateKeys = append(privateKeys, privateKey) + } + return descriptor, privateKeys +} + +func signTestPayload(t *testing.T, payload json.RawMessage, privateKey ed25519.PrivateKey) Signature { + t.Helper() + canonical, err := CanonicalJSON(payload) + if err != nil { + t.Fatalf("CanonicalJSON: %v", err) + } + publicKey := privateKey.Public().(ed25519.PublicKey) + return Signature{ + SchemaVersion: SignatureSchemaVersion, + Algorithm: AlgorithmEd25519, + KeyFingerprint: Fingerprint(publicKey), + Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)), + } +} diff --git a/agents/rap-node-agent/internal/client/client.go b/agents/rap-node-agent/internal/client/client.go index 706d32f..4977de6 100644 --- a/agents/rap-node-agent/internal/client/client.go +++ b/agents/rap-node-agent/internal/client/client.go @@ -9,6 +9,7 @@ import ( "io" "net/http" "net/url" + "strings" "time" ) @@ -17,6 +18,17 @@ type Client struct { httpClient *http.Client } +type RawControlRequest struct { + Method string `json:"method"` + Path string `json:"path"` + Body json.RawMessage `json:"body,omitempty"` +} + +type RawControlResponse struct { + StatusCode int `json:"status_code"` + Body json.RawMessage `json:"body,omitempty"` +} + type EnrollRequest struct { ClusterID string `json:"cluster_id"` JoinToken string `json:"join_token"` @@ -46,14 +58,15 @@ type EnrollmentBootstrapResponse struct { } type NodeBootstrap struct { - NodeID string `json:"node_id"` - ClusterID string `json:"cluster_id"` - IdentityStatus string `json:"identity_status"` - Certificate map[string]any `json:"certificate"` - HeartbeatEndpoint string `json:"heartbeat_endpoint"` - ClusterAuthority *ClusterAuthorityDescriptor `json:"cluster_authority,omitempty"` - AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"` - AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"` + NodeID string `json:"node_id"` + ClusterID string `json:"cluster_id"` + IdentityStatus string `json:"identity_status"` + Certificate map[string]any `json:"certificate"` + HeartbeatEndpoint string `json:"heartbeat_endpoint"` + ClusterAuthority *ClusterAuthorityDescriptor `json:"cluster_authority,omitempty"` + ClusterAuthorityQuorum json.RawMessage `json:"cluster_authority_quorum,omitempty"` + AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"` + AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"` } type HeartbeatRequest struct { @@ -123,6 +136,7 @@ type NodeUpdatePlan struct { Artifact *ReleaseArtifact `json:"artifact,omitempty"` AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"` AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"` + AuthorityQuorum *QuorumEnvelope `json:"authority_quorum,omitempty"` ProductionForwarding bool `json:"production_forwarding"` } @@ -293,6 +307,26 @@ type SyntheticMeshConfig struct { ProductionForwarding bool `json:"production_forwarding"` } +type AdminRuntimeProjectionRequest struct { + SchemaVersion string `json:"schema_version"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query,omitempty"` + Host string `json:"host,omitempty"` + Scope string `json:"scope"` + ServiceClass string `json:"service_class"` + ObservedAt string `json:"observed_at"` +} + +type AdminRuntimeProjectionResponse struct { + SchemaVersion string `json:"schema_version"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + StatusCode int `json:"status_code"` + Headers map[string]string `json:"headers,omitempty"` + Body json.RawMessage `json:"body,omitempty"` +} + func (c *SyntheticMeshConfig) UnmarshalJSON(data []byte) error { type syntheticMeshConfigAlias SyntheticMeshConfig var decoded syntheticMeshConfigAlias @@ -448,6 +482,18 @@ type ClusterSignature struct { SignedAt time.Time `json:"signed_at"` } +type QuorumEnvelope struct { + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + Epoch string `json:"epoch"` + Threshold int `json:"threshold"` + PayloadSHA256 string `json:"payload_sha256"` + QuorumSHA256 string `json:"quorum_sha256"` + Signatures []ClusterSignature `json:"signatures"` + AllowedScopes []string `json:"allowed_scopes,omitempty"` + DecisionReason string `json:"decision_reason,omitempty"` +} + type PeerDirectoryEntry struct { NodeID string `json:"node_id"` RouteIDs []string `json:"route_ids,omitempty"` @@ -744,6 +790,50 @@ func (c *Client) SyntheticMeshConfig(ctx context.Context, clusterID, nodeID stri return response.Config, nil } +func (c *Client) AdminRuntimeProjection(ctx context.Context, clusterID, nodeID string, request AdminRuntimeProjectionRequest) (AdminRuntimeProjectionResponse, error) { + var response AdminRuntimeProjectionResponse + path := fmt.Sprintf("/clusters/%s/nodes/%s/admin-runtime/projection", clusterID, nodeID) + if err := c.postJSON(ctx, path, request, &response); err != nil { + return AdminRuntimeProjectionResponse{}, err + } + return response, nil +} + +func (c *Client) RawControl(ctx context.Context, request RawControlRequest) (RawControlResponse, error) { + method := strings.ToUpper(strings.TrimSpace(request.Method)) + if method == "" { + method = http.MethodGet + } + path := strings.TrimSpace(request.Path) + if !strings.HasPrefix(path, "/") { + return RawControlResponse{}, fmt.Errorf("control path must be relative") + } + var body io.Reader + if len(request.Body) > 0 && string(request.Body) != "null" { + body = bytes.NewReader(request.Body) + } + httpReq, err := http.NewRequestWithContext(ctx, method, c.baseURL+path, body) + if err != nil { + return RawControlResponse{}, err + } + if body != nil { + httpReq.Header.Set("Content-Type", "application/json") + } + httpResp, err := c.httpClient.Do(httpReq) + if err != nil { + return RawControlResponse{}, err + } + defer httpResp.Body.Close() + payload, err := io.ReadAll(io.LimitReader(httpResp.Body, 2*1024*1024)) + 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 +} + func (c *Client) getJSON(ctx context.Context, path string, response any) error { httpReq, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+path, nil) if err != nil { diff --git a/agents/rap-node-agent/internal/config/config.go b/agents/rap-node-agent/internal/config/config.go index 7462a68..778b90b 100644 --- a/agents/rap-node-agent/internal/config/config.go +++ b/agents/rap-node-agent/internal/config/config.go @@ -21,6 +21,11 @@ type Config struct { NodeName string StateDir string WorkloadSupervisionEnabled bool + WebIngressRuntimeEnabled bool + WebIngressSigningPrivateKey string + WebIngressSigningKeyID string + WebIngressTrustedKeysJSON string + WebIngressRuntimeServiceClasses string HeartbeatInterval time.Duration EnrollmentPollInterval time.Duration EnrollmentPollTimeout time.Duration @@ -43,6 +48,12 @@ type Config struct { MeshAdvertiseTransport string MeshConnectivityMode string MeshNATType string + MeshLocalSegmentID string + MeshNATGroupID string + MeshSTUNReflexiveEndpoint string + MeshSTUNServer string + MeshRelayNodeID string + MeshRelayEndpoint string MeshRegion string MeshSyntheticConfigPath string MeshPeerEndpointsJSON string @@ -68,9 +79,14 @@ func Load(args []string, env map[string]string) (Config, error) { fs.StringVar(&cfg.NodeName, "node-name", getEnv(env, "RAP_NODE_NAME", hostnameOrDefault()), "Node display name.") fs.StringVar(&cfg.StateDir, "state-dir", getEnv(env, "RAP_NODE_STATE_DIR", defaultStateDir), "Local node-agent state directory.") fs.BoolVar(&cfg.WorkloadSupervisionEnabled, "workload-supervision-enabled", getEnvBool(env, "RAP_WORKLOAD_SUPERVISION_ENABLED", false), "Enable desired workload polling and status reporting. Disabled by default while service runtime is not implemented.") + fs.BoolVar(&cfg.WebIngressRuntimeEnabled, "web-ingress-runtime-enabled", getEnvBool(env, "RAP_WEB_INGRESS_RUNTIME_ENABLED", false), "Enable the future real 80/443 web ingress listener runtime. Disabled by default; contract probe remains safe without it.") + fs.StringVar(&cfg.WebIngressSigningPrivateKey, "web-ingress-signing-private-key", getEnv(env, "RAP_WEB_INGRESS_SIGNING_PRIVATE_KEY", ""), "Base64 Ed25519 private key used to sign web ingress fabric envelopes. Empty keeps signing disabled.") + fs.StringVar(&cfg.WebIngressSigningKeyID, "web-ingress-signing-key-id", getEnv(env, "RAP_WEB_INGRESS_SIGNING_KEY_ID", ""), "Optional key id for web ingress envelope signatures.") + fs.StringVar(&cfg.WebIngressTrustedKeysJSON, "web-ingress-trusted-keys-json", getEnv(env, "RAP_WEB_INGRESS_TRUSTED_KEYS_JSON", ""), "JSON map or array of trusted Ed25519 public keys for web ingress runtime receiver.") + 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 WebSocket endpoint. 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.") @@ -84,9 +100,15 @@ func Load(args []string, env map[string]string) (Config, error) { 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.MeshAdvertiseTransport, "mesh-advertise-transport", getEnv(env, "RAP_MESH_ADVERTISE_TRANSPORT", "direct_tcp_tls"), "Transport label for the advertised mesh endpoint.") + 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.") + fs.StringVar(&cfg.MeshLocalSegmentID, "mesh-local-segment-id", getEnv(env, "RAP_MESH_LOCAL_SEGMENT_ID", ""), "Optional local LAN/site segment ID advertised with QUIC endpoint candidates.") + fs.StringVar(&cfg.MeshNATGroupID, "mesh-nat-group-id", getEnv(env, "RAP_MESH_NAT_GROUP_ID", ""), "Optional NAT group ID advertised with QUIC endpoint candidates.") + fs.StringVar(&cfg.MeshSTUNReflexiveEndpoint, "mesh-stun-reflexive-endpoint", getEnv(env, "RAP_MESH_STUN_REFLEXIVE_ENDPOINT", ""), "Optional STUN-discovered reflexive QUIC endpoint, for example quic://203.0.113.10:19443.") + fs.StringVar(&cfg.MeshSTUNServer, "mesh-stun-server", getEnv(env, "RAP_MESH_STUN_SERVER", ""), "Optional STUN server name used to discover the reflexive endpoint.") + fs.StringVar(&cfg.MeshRelayNodeID, "mesh-relay-node-id", getEnv(env, "RAP_MESH_RELAY_NODE_ID", ""), "Optional relay node ID for relay-required QUIC fallback candidates.") + fs.StringVar(&cfg.MeshRelayEndpoint, "mesh-relay-endpoint", getEnv(env, "RAP_MESH_RELAY_ENDPOINT", ""), "Optional relay QUIC endpoint for relay-required fallback candidates.") fs.StringVar(&cfg.MeshRegion, "mesh-region", getEnv(env, "RAP_MESH_REGION", ""), "Optional region/site hint for the advertised mesh endpoint.") fs.StringVar(&cfg.MeshSyntheticConfigPath, "mesh-synthetic-config", getEnv(env, "RAP_MESH_SYNTHETIC_CONFIG", ""), "Path to scoped synthetic mesh config snapshot. Preferred over debug JSON env.") fs.StringVar(&cfg.MeshPeerEndpointsJSON, "mesh-peer-endpoints-json", getEnv(env, "RAP_MESH_PEER_ENDPOINTS_JSON", ""), "JSON object mapping peer node_id to synthetic mesh endpoint URL.") @@ -129,12 +151,27 @@ 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.MeshAdvertiseTransport = strings.TrimSpace(cfg.MeshAdvertiseTransport) + if cfg.MeshAdvertiseTransport == "" { + cfg.MeshAdvertiseTransport = "quic" + } + cfg.MeshAdvertiseTransport = normalizeLegacyAdvertiseTransport(cfg.MeshAdvertiseTransport) + cfg.MeshAdvertiseEndpoint = normalizeLegacyEndpointSchemeToQUIC(cfg.MeshAdvertiseEndpoint) cfg.MeshConnectivityMode = strings.TrimSpace(cfg.MeshConnectivityMode) cfg.MeshNATType = strings.TrimSpace(cfg.MeshNATType) + cfg.MeshLocalSegmentID = strings.TrimSpace(cfg.MeshLocalSegmentID) + cfg.MeshNATGroupID = strings.TrimSpace(cfg.MeshNATGroupID) + cfg.MeshSTUNReflexiveEndpoint = normalizeLegacyEndpointSchemeToQUIC(strings.TrimRight(strings.TrimSpace(cfg.MeshSTUNReflexiveEndpoint), "/")) + cfg.MeshSTUNServer = strings.TrimSpace(cfg.MeshSTUNServer) + cfg.MeshRelayNodeID = strings.TrimSpace(cfg.MeshRelayNodeID) + cfg.MeshRelayEndpoint = normalizeLegacyEndpointSchemeToQUIC(strings.TrimRight(strings.TrimSpace(cfg.MeshRelayEndpoint), "/")) cfg.MeshRegion = strings.TrimSpace(cfg.MeshRegion) cfg.MeshSyntheticConfigPath = strings.TrimSpace(cfg.MeshSyntheticConfigPath) cfg.MeshPeerEndpointsJSON = strings.TrimSpace(cfg.MeshPeerEndpointsJSON) cfg.MeshSyntheticRoutesJSON = strings.TrimSpace(cfg.MeshSyntheticRoutesJSON) + cfg.WebIngressSigningPrivateKey = strings.TrimSpace(cfg.WebIngressSigningPrivateKey) + cfg.WebIngressSigningKeyID = strings.TrimSpace(cfg.WebIngressSigningKeyID) + cfg.WebIngressTrustedKeysJSON = strings.TrimSpace(cfg.WebIngressTrustedKeysJSON) + cfg.WebIngressRuntimeServiceClasses = strings.TrimSpace(cfg.WebIngressRuntimeServiceClasses) cfg.RemoteWorkspaceRealAdapterCommand = strings.TrimSpace(cfg.RemoteWorkspaceRealAdapterCommand) cfg.RemoteWorkspaceRealAdapterArgsJSON = strings.TrimSpace(cfg.RemoteWorkspaceRealAdapterArgsJSON) cfg.RemoteWorkspaceRealAdapterWorkDir = strings.TrimSpace(cfg.RemoteWorkspaceRealAdapterWorkDir) @@ -176,9 +213,62 @@ func Load(args []string, env map[string]string) (Config, error) { if cfg.MeshListenAutoPortStart > cfg.MeshListenAutoPortEnd { return Config{}, errors.New("mesh listen auto port start must be less than or equal to end") } + if !isQUICAdvertiseTransport(cfg.MeshAdvertiseTransport) { + return Config{}, errors.New("mesh advertise transport must be a QUIC transport label") + } + if hasLegacyEndpointScheme(cfg.MeshAdvertiseEndpoint) { + return Config{}, errors.New("mesh advertise endpoint must be a QUIC endpoint") + } + if cfg.MeshSTUNReflexiveEndpoint != "" && hasLegacyEndpointScheme(cfg.MeshSTUNReflexiveEndpoint) { + return Config{}, errors.New("mesh STUN reflexive endpoint must be a QUIC endpoint") + } + if cfg.MeshRelayEndpoint != "" && hasLegacyEndpointScheme(cfg.MeshRelayEndpoint) { + return Config{}, errors.New("mesh relay endpoint must be a QUIC endpoint") + } return cfg, nil } +func isQUICAdvertiseTransport(label string) bool { + switch strings.ToLower(strings.TrimSpace(label)) { + case "quic", "direct_quic", "udp_quic", "quic_udp", "lan_quic", "reverse_quic", "relay_quic", "ice_quic": + return true + default: + return false + } +} + +func normalizeLegacyAdvertiseTransport(label string) string { + switch strings.ToLower(strings.TrimSpace(label)) { + case "direct_http", "direct_https", "direct_tcp_tls", "http", "https", "ws", "wss", "websocket": + return "direct_quic" + case "outbound_reverse", "reverse", "reverse_outbound": + return "reverse_quic" + case "relay", "relay_control": + return "relay_quic" + default: + return strings.TrimSpace(label) + } +} + +func normalizeLegacyEndpointSchemeToQUIC(endpoint string) string { + endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/") + lower := strings.ToLower(endpoint) + for _, prefix := range []string{"http://", "https://", "ws://", "wss://"} { + if strings.HasPrefix(lower, prefix) { + return "quic://" + endpoint[len(prefix):] + } + } + return endpoint +} + +func hasLegacyEndpointScheme(endpoint string) bool { + endpoint = strings.ToLower(strings.TrimSpace(endpoint)) + return strings.HasPrefix(endpoint, "http://") || + strings.HasPrefix(endpoint, "https://") || + strings.HasPrefix(endpoint, "ws://") || + strings.HasPrefix(endpoint, "wss://") +} + 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 b74ac33..f352682 100644 --- a/agents/rap-node-agent/internal/config/config_test.go +++ b/agents/rap-node-agent/internal/config/config_test.go @@ -15,6 +15,11 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) { "RAP_NODE_NAME": "node-a", "RAP_NODE_STATE_DIR": "/tmp/rap-node", "RAP_WORKLOAD_SUPERVISION_ENABLED": "true", + "RAP_WEB_INGRESS_RUNTIME_ENABLED": "true", + "RAP_WEB_INGRESS_SIGNING_PRIVATE_KEY": " private-key-b64 ", + "RAP_WEB_INGRESS_SIGNING_KEY_ID": " web-key-1 ", + "RAP_WEB_INGRESS_TRUSTED_KEYS_JSON": ` {"web-key-1":"public-key-b64"} `, + "RAP_WEB_INGRESS_RUNTIME_SERVICE_CLASSES": " platform_admin, cluster_admin ", "RAP_HEARTBEAT_INTERVAL_SECONDS": "7", "RAP_ENROLLMENT_POLL_INTERVAL_SECONDS": "3", "RAP_ENROLLMENT_POLL_TIMEOUT_SECONDS": "30", @@ -32,11 +37,17 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) { "RAP_MESH_LISTEN_PORT_MODE": "auto", "RAP_MESH_LISTEN_AUTO_PORT_START": "19010", "RAP_MESH_LISTEN_AUTO_PORT_END": "19020", - "RAP_MESH_ADVERTISE_ENDPOINT": "https://node-a.example.test:443/", + "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_MESH_ADVERTISE_TRANSPORT": "wss", + "RAP_MESH_ADVERTISE_TRANSPORT": "direct_quic", "RAP_MESH_CONNECTIVITY_MODE": "outbound_only", "RAP_MESH_NAT_TYPE": "symmetric", + "RAP_MESH_LOCAL_SEGMENT_ID": "site-a", + "RAP_MESH_NAT_GROUP_ID": "nat-a", + "RAP_MESH_STUN_REFLEXIVE_ENDPOINT": "quic://203.0.113.20:19443/", + "RAP_MESH_STUN_SERVER": "stun.example.test:3478", + "RAP_MESH_RELAY_NODE_ID": "node-r", + "RAP_MESH_RELAY_ENDPOINT": "quic://node-r.example.test:19443/", "RAP_MESH_REGION": "eu", "RAP_MESH_SYNTHETIC_CONFIG": "/tmp/rap-node/mesh-synthetic.json", "RAP_MESH_PEER_ENDPOINTS_JSON": `{"node-b":"http://127.0.0.1:19002"}`, @@ -67,6 +78,15 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) { if !cfg.WorkloadSupervisionEnabled { t.Fatal("WorkloadSupervisionEnabled = false, want true") } + if !cfg.WebIngressRuntimeEnabled { + t.Fatal("WebIngressRuntimeEnabled = false, want true") + } + if cfg.WebIngressSigningPrivateKey != "private-key-b64" || + cfg.WebIngressSigningKeyID != "web-key-1" || + cfg.WebIngressTrustedKeysJSON != `{"web-key-1":"public-key-b64"}` || + cfg.WebIngressRuntimeServiceClasses != "platform_admin, cluster_admin" { + t.Fatalf("unexpected web ingress key config: %+v", cfg) + } if !cfg.MeshSyntheticRuntimeEnabled { t.Fatal("MeshSyntheticRuntimeEnabled = false, want true") } @@ -100,11 +120,17 @@ func TestLoadConfigFromEnvAndArgs(t *testing.T) { if cfg.MeshListenPortMode != "auto" || cfg.MeshListenAutoPortStart != 19010 || cfg.MeshListenAutoPortEnd != 19020 { t.Fatalf("unexpected mesh listen port config: %+v", cfg) } - if cfg.MeshAdvertiseEndpoint != "https://node-a.example.test:443" || + if cfg.MeshAdvertiseEndpoint != "quic://node-a.example.test:19443" || cfg.MeshAdvertiseEndpointsJSON == "" || - cfg.MeshAdvertiseTransport != "wss" || + cfg.MeshAdvertiseTransport != "direct_quic" || cfg.MeshConnectivityMode != "outbound_only" || cfg.MeshNATType != "symmetric" || + cfg.MeshLocalSegmentID != "site-a" || + cfg.MeshNATGroupID != "nat-a" || + cfg.MeshSTUNReflexiveEndpoint != "quic://203.0.113.20:19443" || + cfg.MeshSTUNServer != "stun.example.test:3478" || + cfg.MeshRelayNodeID != "node-r" || + cfg.MeshRelayEndpoint != "quic://node-r.example.test:19443" || cfg.MeshRegion != "eu" { t.Fatalf("unexpected mesh advertise config: %+v", cfg) } @@ -139,6 +165,9 @@ func TestLoadConfigDefaultsEnrollmentPollingToNoTimeout(t *testing.T) { cfg.RemoteWorkspaceRealAdapterWorkDir != "" { t.Fatalf("real adapter config should default disabled and empty: %+v", cfg) } + if cfg.WebIngressRuntimeEnabled { + t.Fatalf("web ingress runtime should default disabled: %+v", cfg) + } } func TestLoadConfigRejectsNegativeProductionObservationSinkCapacity(t *testing.T) { @@ -162,3 +191,33 @@ func TestLoadConfigRejectsTooLargeProductionObservationSinkCapacity(t *testing.T t.Fatal("Load returned nil error for too-large sink capacity") } } + +func TestLoadConfigNormalizesLegacyMeshAdvertiseTransport(t *testing.T) { + cfg, err := Load(nil, map[string]string{ + "RAP_BACKEND_URL": "http://backend/api/v1", + "RAP_NODE_NAME": "node-a", + "RAP_MESH_ADVERTISE_ENDPOINT": "quic://node-a.example.test:19443", + "RAP_MESH_ADVERTISE_TRANSPORT": "wss", + }) + if err != nil { + t.Fatalf("Load returned error for legacy mesh advertise transport migration: %v", err) + } + if cfg.MeshAdvertiseTransport != "direct_quic" { + t.Fatalf("transport = %q, want direct_quic", cfg.MeshAdvertiseTransport) + } +} + +func TestLoadConfigNormalizesLegacyMeshAdvertiseEndpointScheme(t *testing.T) { + cfg, err := Load(nil, map[string]string{ + "RAP_BACKEND_URL": "http://backend/api/v1", + "RAP_NODE_NAME": "node-a", + "RAP_MESH_ADVERTISE_ENDPOINT": "https://node-a.example.test:443", + "RAP_MESH_ADVERTISE_TRANSPORT": "direct_quic", + }) + if err != nil { + t.Fatalf("Load returned error for legacy mesh advertise endpoint migration: %v", err) + } + if cfg.MeshAdvertiseEndpoint != "quic://node-a.example.test:443" { + t.Fatalf("endpoint = %q, want quic scheme", cfg.MeshAdvertiseEndpoint) + } +} diff --git a/agents/rap-node-agent/internal/fabricproto/session_frames.go b/agents/rap-node-agent/internal/fabricproto/session_frames.go index 646f555..7d8e223 100644 --- a/agents/rap-node-agent/internal/fabricproto/session_frames.go +++ b/agents/rap-node-agent/internal/fabricproto/session_frames.go @@ -1,6 +1,9 @@ package fabricproto -import "errors" +import ( + "crypto/sha256" + "errors" +) var ( ErrUnsupportedSessionFrame = errors.New("unsupported fabric session frame") @@ -62,6 +65,7 @@ func (s *Session) HandleFrame(frame Frame) (SessionEvent, []Frame, error) { TrafficClass: frame.TrafficClass, StreamID: frame.StreamID, Sequence: frame.Sequence, + Payload: DataAckPayload(frame.Payload), }}, nil case FrameAck: if err := s.Ack(frame.StreamID, frame.Sequence); err != nil { @@ -103,6 +107,11 @@ func (s *Session) HandleFrame(frame Frame) (SessionEvent, []Frame, error) { } } +func DataAckPayload(payload []byte) []byte { + sum := sha256.Sum256(payload) + return sum[:] +} + func (s *Session) handleDataFrame(frame Frame) (SessionEvent, error) { s.mu.Lock() defer s.mu.Unlock() diff --git a/agents/rap-node-agent/internal/fabricproto/session_frames_test.go b/agents/rap-node-agent/internal/fabricproto/session_frames_test.go index 506afb3..5b193ed 100644 --- a/agents/rap-node-agent/internal/fabricproto/session_frames_test.go +++ b/agents/rap-node-agent/internal/fabricproto/session_frames_test.go @@ -1,6 +1,7 @@ package fabricproto import ( + "bytes" "errors" "testing" ) @@ -36,6 +37,9 @@ func TestHandleFrameOpensStreamAndReceivesData(t *testing.T) { if len(responses) != 1 || responses[0].Type != FrameAck || responses[0].StreamID != 7 || responses[0].Sequence != 11 { t.Fatalf("responses = %+v, want ack for stream 7 seq 11", responses) } + if !bytes.Equal(responses[0].Payload, DataAckPayload([]byte("rdp-input"))) { + t.Fatalf("ack checksum = %x, want sha256 payload checksum", responses[0].Payload) + } snapshot := session.Snapshot() if snapshot.FramesReceived != 1 || snapshot.Streams[7].Received != 1 { t.Fatalf("received metrics = %+v stream=%+v", snapshot, snapshot.Streams[7]) diff --git a/agents/rap-node-agent/internal/hostagent/config.go b/agents/rap-node-agent/internal/hostagent/config.go index 035f0ab..aa50cb1 100644 --- a/agents/rap-node-agent/internal/hostagent/config.go +++ b/agents/rap-node-agent/internal/hostagent/config.go @@ -136,6 +136,12 @@ func (cfg RuntimeConfig) ValidateInstall() error { if cfg.MeshListenAutoPortStart > 0 && cfg.MeshListenAutoPortEnd > 0 && cfg.MeshListenAutoPortStart > cfg.MeshListenAutoPortEnd { return errors.New("mesh listen auto port start must be less than or equal to end") } + if cfg.MeshAdvertiseTransport != "" && !isQUICAdvertiseTransport(cfg.MeshAdvertiseTransport) { + return errors.New("mesh advertise transport must be a QUIC transport label") + } + if hasLegacyEndpointScheme(cfg.MeshAdvertiseEndpoint) { + return errors.New("mesh advertise endpoint must be a QUIC endpoint") + } if cfg.ProductionObservationSinkCap < 0 { return errors.New("production observation sink capacity must not be negative") } @@ -153,3 +159,20 @@ func firstNonEmpty(value, fallback string) string { } return strings.TrimSpace(value) } + +func isQUICAdvertiseTransport(label string) bool { + switch strings.ToLower(strings.TrimSpace(label)) { + case "quic", "direct_quic", "udp_quic", "quic_udp", "lan_quic", "reverse_quic", "relay_quic", "ice_quic": + return true + default: + return false + } +} + +func hasLegacyEndpointScheme(endpoint string) bool { + endpoint = strings.ToLower(strings.TrimSpace(endpoint)) + return strings.HasPrefix(endpoint, "http://") || + strings.HasPrefix(endpoint, "https://") || + strings.HasPrefix(endpoint, "ws://") || + strings.HasPrefix(endpoint, "wss://") +} diff --git a/agents/rap-node-agent/internal/hostagent/docker_test.go b/agents/rap-node-agent/internal/hostagent/docker_test.go index a32b545..c111582 100644 --- a/agents/rap-node-agent/internal/hostagent/docker_test.go +++ b/agents/rap-node-agent/internal/hostagent/docker_test.go @@ -73,7 +73,8 @@ func TestDockerRunArgsBuildNodeRuntimePlacement(t *testing.T) { VPNFabricQUICMaxStreamsPerConn: 24, VPNFabricQUICIdleTTLSeconds: 120, MeshListenAddr: ":19131", - MeshAdvertiseEndpoint: "http://10.0.0.11:19131/", + MeshAdvertiseEndpoint: "quic://10.0.0.11:19443/", + MeshAdvertiseTransport: "direct_quic", MeshConnectivityMode: "private_lan", }) @@ -94,7 +95,8 @@ func TestDockerRunArgsBuildNodeRuntimePlacement(t *testing.T) { "RAP_VPN_FABRIC_QUIC_MAX_STREAMS_PER_CONN=24", "RAP_VPN_FABRIC_QUIC_IDLE_TTL_SECONDS=120", "RAP_MESH_LISTEN_ADDR=:19131", - "RAP_MESH_ADVERTISE_ENDPOINT=http://10.0.0.11:19131", + "RAP_MESH_ADVERTISE_ENDPOINT=quic://10.0.0.11:19443", + "RAP_MESH_ADVERTISE_TRANSPORT=direct_quic", "RAP_MESH_CONNECTIVITY_MODE=private_lan", "rap-node-agent:test", } { @@ -384,3 +386,35 @@ func TestValidateRequiresJoinTokenUnlessReplacingExistingState(t *testing.T) { t.Fatalf("replace update should allow missing join token: %v", err) } } + +func TestValidateRejectsLegacyMeshAdvertiseTransport(t *testing.T) { + err := RuntimeConfig{ + BackendURL: "http://control/api/v1", + ClusterID: "cluster-1", + JoinToken: "join-secret", + NodeName: "node-a", + MeshAdvertiseEndpoint: "quic://10.0.0.11:19443", + MeshAdvertiseTransport: "wss", + MeshQUICFabricEnabled: true, + MeshQUICFabricListenAddr: ":19443", + }.ValidateInstall() + if err == nil || !strings.Contains(err.Error(), "QUIC transport") { + t.Fatalf("expected QUIC transport validation error, got %v", err) + } +} + +func TestValidateRejectsLegacyMeshAdvertiseEndpointScheme(t *testing.T) { + err := RuntimeConfig{ + BackendURL: "http://control/api/v1", + ClusterID: "cluster-1", + JoinToken: "join-secret", + NodeName: "node-a", + MeshAdvertiseEndpoint: "http://10.0.0.11:19131", + MeshAdvertiseTransport: "direct_quic", + MeshQUICFabricEnabled: true, + MeshQUICFabricListenAddr: ":19443", + }.ValidateInstall() + if err == nil || !strings.Contains(err.Error(), "QUIC endpoint") { + t.Fatalf("expected QUIC endpoint validation error, got %v", err) + } +} diff --git a/agents/rap-node-agent/internal/hostagent/update.go b/agents/rap-node-agent/internal/hostagent/update.go index 3dcfc10..d49248e 100644 --- a/agents/rap-node-agent/internal/hostagent/update.go +++ b/agents/rap-node-agent/internal/hostagent/update.go @@ -16,6 +16,7 @@ import ( "strings" "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/state" ) @@ -104,22 +105,37 @@ type NodeUpdatePlanResponse struct { } type NodeUpdatePlan struct { - SchemaVersion string `json:"schema_version"` - ClusterID string `json:"cluster_id"` - NodeID string `json:"node_id"` - Product string `json:"product"` - CurrentVersion string `json:"current_version,omitempty"` - Action string `json:"action"` - Reason string `json:"reason"` - TargetVersion string `json:"target_version,omitempty"` - Channel string `json:"channel,omitempty"` - Strategy string `json:"strategy,omitempty"` - RollbackAllowed bool `json:"rollback_allowed"` - HealthWindowSec int `json:"health_window_seconds,omitempty"` - Artifact *ReleaseArtifact `json:"artifact,omitempty"` - AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"` - AuthoritySignature json.RawMessage `json:"authority_signature,omitempty"` - ProductionForwarding bool `json:"production_forwarding"` + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + NodeID string `json:"node_id"` + Product string `json:"product"` + CurrentVersion string `json:"current_version,omitempty"` + Action string `json:"action"` + Reason string `json:"reason"` + TargetVersion string `json:"target_version,omitempty"` + Channel string `json:"channel,omitempty"` + Strategy string `json:"strategy,omitempty"` + RollbackAllowed bool `json:"rollback_allowed"` + HealthWindowSec int `json:"health_window_seconds,omitempty"` + Artifact *ReleaseArtifact `json:"artifact,omitempty"` + AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"` + AuthoritySignature json.RawMessage `json:"authority_signature,omitempty"` + AuthorityQuorum *clusterauth.QuorumEnvelope `json:"authority_quorum,omitempty"` + ProductionForwarding bool `json:"production_forwarding"` +} + +type nodeUpdatePlanAuthorityPayload struct { + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + NodeID string `json:"node_id"` + Product string `json:"product"` + CurrentVersion string `json:"current_version,omitempty"` + Action string `json:"action"` + TargetVersion string `json:"target_version,omitempty"` + ArtifactSHA256 string `json:"artifact_sha256,omitempty"` + ArtifactURL string `json:"artifact_url,omitempty"` + ControlPlaneOnly bool `json:"control_plane_only"` + ProductionForwarding bool `json:"production_forwarding"` } type ReleaseArtifact struct { @@ -516,9 +532,87 @@ func FetchNodeUpdatePlan(ctx context.Context, req UpdateRequest) (NodeUpdatePlan if err := json.NewDecoder(resp.Body).Decode(&out); err != nil { return NodeUpdatePlan{}, err } + if err := verifyNodeUpdatePlanAuthority(req, out.Plan); err != nil { + return NodeUpdatePlan{}, err + } return out.Plan, nil } +func verifyNodeUpdatePlanAuthority(req UpdateRequest, plan NodeUpdatePlan) error { + identity, ok := pinnedUpdatePlanAuthority(req) + if !ok { + return nil + } + if len(identity.ClusterAuthorityQuorum) > 0 { + if plan.AuthorityQuorum == nil { + return errors.New("update plan quorum authority is required by pinned cluster quorum") + } + var descriptor clusterauth.QuorumDescriptor + if err := json.Unmarshal(identity.ClusterAuthorityQuorum, &descriptor); err != nil { + return fmt.Errorf("invalid pinned cluster authority quorum: %w", err) + } + if len(plan.AuthorityPayload) == 0 { + return errors.New("update plan authority payload is required by pinned cluster quorum") + } + if err := clusterauth.VerifyQuorumRaw(descriptor, plan.AuthorityPayload, *plan.AuthorityQuorum, "update-authority"); err != nil { + return fmt.Errorf("update plan quorum authority rejected: %w", err) + } + return verifyNodeUpdatePlanAuthorityPayload(plan) + } + if len(plan.AuthorityPayload) == 0 || len(plan.AuthoritySignature) == 0 { + return errors.New("update plan authority signature is required by pinned cluster authority") + } + var signature clusterauth.Signature + if err := json.Unmarshal(plan.AuthoritySignature, &signature); err != nil { + return fmt.Errorf("invalid update plan authority signature: %w", err) + } + if identity.ClusterAuthorityFingerprint != "" && signature.KeyFingerprint != identity.ClusterAuthorityFingerprint { + return errors.New("update plan authority fingerprint mismatch") + } + if err := clusterauth.VerifyRaw(identity.ClusterAuthorityPublicKey, plan.AuthorityPayload, signature); err != nil { + return fmt.Errorf("update plan authority signature rejected: %w", err) + } + return verifyNodeUpdatePlanAuthorityPayload(plan) +} + +func verifyNodeUpdatePlanAuthorityPayload(plan NodeUpdatePlan) error { + var payload nodeUpdatePlanAuthorityPayload + if err := json.Unmarshal(plan.AuthorityPayload, &payload); err != nil { + return fmt.Errorf("invalid update plan authority payload: %w", err) + } + if payload.SchemaVersion != "rap.node_update_plan_authority.v1" || + payload.ClusterID != plan.ClusterID || + payload.NodeID != plan.NodeID || + payload.Product != plan.Product || + payload.CurrentVersion != plan.CurrentVersion || + payload.Action != plan.Action || + payload.TargetVersion != plan.TargetVersion || + payload.ProductionForwarding != plan.ProductionForwarding { + return errors.New("update plan authority payload mismatch") + } + if plan.Artifact != nil { + if payload.ArtifactSHA256 != plan.Artifact.SHA256 || payload.ArtifactURL != plan.Artifact.URL { + return errors.New("update plan artifact authority payload mismatch") + } + } + return nil +} + +func pinnedUpdatePlanAuthority(req UpdateRequest) (state.Identity, bool) { + stateDir := strings.TrimSpace(req.StateDir) + if stateDir == "" { + return state.Identity{}, false + } + identity, err := state.Load(filepath.Join(stateDir, state.FileName)) + if err != nil { + return state.Identity{}, false + } + if strings.TrimSpace(identity.ClusterAuthorityPublicKey) == "" { + return state.Identity{}, false + } + return identity, true +} + func resolveUpdateRequest(req UpdateRequest) (UpdateRequest, error) { req = req.Normalize() if err := req.Validate(); err != nil { diff --git a/agents/rap-node-agent/internal/hostagent/update_test.go b/agents/rap-node-agent/internal/hostagent/update_test.go index 61d8df9..3c396f7 100644 --- a/agents/rap-node-agent/internal/hostagent/update_test.go +++ b/agents/rap-node-agent/internal/hostagent/update_test.go @@ -2,6 +2,9 @@ package hostagent import ( "context" + "crypto/ed25519" + cryptorand "crypto/rand" + "encoding/base64" "encoding/json" "fmt" "net/http" @@ -12,6 +15,7 @@ import ( "testing" "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/state" ) @@ -21,6 +25,101 @@ type updateRunner struct { inspectJSON string } +func writePinnedAuthorityIdentity(t *testing.T) (string, ed25519.PublicKey, ed25519.PrivateKey) { + t.Helper() + publicKey, privateKey, err := ed25519.GenerateKey(cryptorand.Reader) + if err != nil { + t.Fatalf("generate authority key: %v", err) + } + dir := t.TempDir() + identity := state.Identity{ + NodeID: "node-1", + ClusterID: "cluster-1", + NodeName: "node-a", + IdentityStatus: "active", + ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), + ClusterAuthorityFingerprint: clusterauth.Fingerprint(publicKey), + } + if err := state.Save(filepath.Join(dir, state.FileName), identity); err != nil { + t.Fatalf("save identity: %v", err) + } + return dir, publicKey, privateKey +} + +func writePinnedQuorumIdentity(t *testing.T) (string, clusterauth.QuorumDescriptor, []ed25519.PrivateKey) { + t.Helper() + descriptor := clusterauth.QuorumDescriptor{ + SchemaVersion: clusterauth.QuorumSchemaVersion, + ClusterID: "cluster-1", + Epoch: "epoch-1", + Threshold: 2, + } + privateKeys := make([]ed25519.PrivateKey, 0, 3) + for i := 0; i < 3; i++ { + publicKey, privateKey, err := ed25519.GenerateKey(cryptorand.Reader) + if err != nil { + t.Fatalf("generate authority key: %v", err) + } + descriptor.Members = append(descriptor.Members, clusterauth.QuorumMember{ + NodeID: fmt.Sprintf("authority-%d", i+1), + Role: "update-authority", + PublicKey: base64.StdEncoding.EncodeToString(publicKey), + PublicKeyFingerprint: clusterauth.Fingerprint(publicKey), + Scopes: []string{"update-authority"}, + }) + privateKeys = append(privateKeys, privateKey) + } + rawQuorum, err := json.Marshal(descriptor) + if err != nil { + t.Fatalf("marshal quorum: %v", err) + } + dir := t.TempDir() + identity := state.Identity{ + NodeID: "node-1", + ClusterID: "cluster-1", + NodeName: "node-a", + IdentityStatus: "active", + ClusterAuthorityQuorum: rawQuorum, + } + if err := state.Save(filepath.Join(dir, state.FileName), identity); err != nil { + t.Fatalf("save identity: %v", err) + } + return dir, descriptor, privateKeys +} + +func signedAuthorityPayload(t *testing.T, publicKey ed25519.PublicKey, privateKey ed25519.PrivateKey, payload any) (json.RawMessage, clusterauth.Signature) { + t.Helper() + raw, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + canonical, err := clusterauth.CanonicalJSON(raw) + if err != nil { + t.Fatalf("canonical payload: %v", err) + } + return raw, clusterauth.Signature{ + SchemaVersion: clusterauth.SignatureSchemaVersion, + Algorithm: clusterauth.AlgorithmEd25519, + KeyFingerprint: clusterauth.Fingerprint(publicKey), + Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)), + } +} + +func signHostAgentPayload(t *testing.T, payload json.RawMessage, privateKey ed25519.PrivateKey) clusterauth.Signature { + t.Helper() + canonical, err := clusterauth.CanonicalJSON(payload) + if err != nil { + t.Fatalf("canonical payload: %v", err) + } + publicKey := privateKey.Public().(ed25519.PublicKey) + return clusterauth.Signature{ + SchemaVersion: clusterauth.SignatureSchemaVersion, + Algorithm: clusterauth.AlgorithmEd25519, + KeyFingerprint: clusterauth.Fingerprint(publicKey), + Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)), + } +} + func TestArtifactURLsForBackendResolvesControlPlaneRelativeDownloads(t *testing.T) { urls := artifactURLsForBackend(ReleaseArtifact{ URL: "/downloads/rap-node-agent-0.2.92.tar", @@ -41,6 +140,161 @@ func TestArtifactURLsForBackendResolvesControlPlaneRelativeDownloads(t *testing. } } +func TestFetchNodeUpdatePlanRejectsUnsignedPlanWithPinnedAuthority(t *testing.T) { + stateDir, _, _ := writePinnedAuthorityIdentity(t) + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{ + "node_update_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, + }, + }) + })) + defer server.Close() + + _, err := FetchNodeUpdatePlan(context.Background(), UpdateRequest{ + BackendURL: server.URL, + ClusterID: "cluster-1", + NodeID: "node-1", + StateDir: stateDir, + CurrentVersion: "0.1.0", + OS: "linux", + Arch: "amd64", + InstallType: "docker", + }) + if err == nil || !strings.Contains(err.Error(), "authority signature is required") { + t.Fatalf("expected pinned authority rejection, got %v", err) + } +} + +func TestFetchNodeUpdatePlanAcceptsSignedPlanWithPinnedAuthority(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 + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"node_update_plan": plan}) + })) + defer server.Close() + + got, err := FetchNodeUpdatePlan(context.Background(), UpdateRequest{ + BackendURL: server.URL, + ClusterID: "cluster-1", + NodeID: "node-1", + StateDir: stateDir, + CurrentVersion: "0.1.0", + OS: "linux", + Arch: "amd64", + InstallType: "docker", + }) + if err != nil { + t.Fatalf("fetch signed plan: %v", err) + } + if got.Action != "none" || got.Reason != "already_current" { + t.Fatalf("unexpected plan: %+v", got) + } +} + +func TestFetchNodeUpdatePlanAcceptsQuorumSignedPlan(t *testing.T) { + stateDir, descriptor, privateKeys := writePinnedQuorumIdentity(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, err := json.Marshal(payload) + if err != nil { + t.Fatalf("marshal payload: %v", err) + } + payloadHash, err := clusterauth.HashRaw(rawPayload) + if err != nil { + t.Fatalf("payload hash: %v", err) + } + quorumHash, err := clusterauth.QuorumDescriptorHash(descriptor) + if err != nil { + t.Fatalf("quorum hash: %v", err) + } + plan["authority_payload"] = json.RawMessage(rawPayload) + plan["authority_quorum"] = clusterauth.QuorumEnvelope{ + SchemaVersion: clusterauth.QuorumEnvelopeVersion, + ClusterID: "cluster-1", + Epoch: "epoch-1", + Threshold: 2, + PayloadSHA256: payloadHash, + QuorumSHA256: quorumHash, + Signatures: []clusterauth.Signature{ + signHostAgentPayload(t, rawPayload, privateKeys[0]), + signHostAgentPayload(t, rawPayload, privateKeys[1]), + }, + } + server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + _ = json.NewEncoder(w).Encode(map[string]any{"node_update_plan": plan}) + })) + defer server.Close() + + got, err := FetchNodeUpdatePlan(context.Background(), UpdateRequest{ + BackendURL: server.URL, + ClusterID: "cluster-1", + NodeID: "node-1", + StateDir: stateDir, + CurrentVersion: "0.1.0", + OS: "linux", + Arch: "amd64", + InstallType: "docker", + }) + if err != nil { + t.Fatalf("fetch quorum plan: %v", err) + } + if got.Action != "none" { + t.Fatalf("unexpected plan: %+v", got) + } +} + func (r *updateRunner) Run(_ context.Context, name string, args ...string) (string, error) { r.calls = append(r.calls, append([]string{name}, args...)) if len(args) >= 2 && args[0] == "inspect" && args[1] == "--format" { diff --git a/agents/rap-node-agent/internal/mesh/client_test.go b/agents/rap-node-agent/internal/mesh/client_test.go index fdff9ba..9bc92e9 100644 --- a/agents/rap-node-agent/internal/mesh/client_test.go +++ b/agents/rap-node-agent/internal/mesh/client_test.go @@ -11,8 +11,9 @@ import ( func TestClientFabricSessionFrameRoundTrip(t *testing.T) { server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, + FabricSessionEnabled: true, + FabricSessionWebSocketEnabled: true, }.Handler()) defer server.Close() @@ -37,8 +38,9 @@ func TestClientFabricSessionFrameRoundTrip(t *testing.T) { func TestClientFabricSessionPersistentRoundTrips(t *testing.T) { server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, + FabricSessionEnabled: true, + FabricSessionWebSocketEnabled: true, }.Handler()) defer server.Close() @@ -80,8 +82,9 @@ func TestClientFabricSessionPersistentRoundTrips(t *testing.T) { func TestClientFabricSessionPersistentDataAcks(t *testing.T) { server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, + FabricSessionEnabled: true, + FabricSessionWebSocketEnabled: true, }.Handler()) defer server.Close() @@ -135,8 +138,9 @@ func TestClientFabricSessionPersistentDataAcks(t *testing.T) { func TestClientFabricSessionPumpMovesIndependentFrames(t *testing.T) { server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, + FabricSessionEnabled: true, + FabricSessionWebSocketEnabled: true, }.Handler()) defer server.Close() @@ -202,8 +206,9 @@ func TestClientFabricSessionPumpMovesIndependentFrames(t *testing.T) { func TestClientFabricSessionReportsRejectedStatus(t *testing.T) { server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, + FabricSessionEnabled: true, + FabricSessionWebSocketEnabled: true, }.Handler()) defer server.Close() diff --git a/agents/rap-node-agent/internal/mesh/contracts.go b/agents/rap-node-agent/internal/mesh/contracts.go index 3b1dbf6..0b632db 100644 --- a/agents/rap-node-agent/internal/mesh/contracts.go +++ b/agents/rap-node-agent/internal/mesh/contracts.go @@ -72,6 +72,10 @@ const ( MaxProductionEnvelopePayloadBytes = 4096 MaxProductionVPNPacketPayloadBytes = 256 * 1024 MaxProductionEnvelopeFutureSkew = time.Minute + ProductionForwardQUICStreamID = 1 + WebIngressForwardQUICStreamID = 2 + FabricControlForwardQUICStreamID = 3 + SyntheticForwardQUICStreamID = 1001 ) type PeerIdentity struct { diff --git a/agents/rap-node-agent/internal/mesh/endpoint_candidate_scoring.go b/agents/rap-node-agent/internal/mesh/endpoint_candidate_scoring.go index 9dce6b5..31f8aec 100644 --- a/agents/rap-node-agent/internal/mesh/endpoint_candidate_scoring.go +++ b/agents/rap-node-agent/internal/mesh/endpoint_candidate_scoring.go @@ -47,6 +47,9 @@ func RankPeerEndpointCandidates(candidates []PeerEndpointCandidate, opts Endpoin } out := make([]ScoredPeerEndpointCandidate, 0, len(candidates)) for _, candidate := range candidates { + if endpointHasUnspecifiedHost(candidate.Address) { + continue + } out = append(out, scorePeerEndpointCandidate(candidate, opts)) } sort.SliceStable(out, func(i, j int) bool { @@ -68,25 +71,25 @@ func scorePeerEndpointCandidate(candidate PeerEndpointCandidate, opts EndpointCa score := 100 reasons := []string{"base"} - switch candidate.Transport { + switch strings.ToLower(strings.TrimSpace(candidate.Transport)) { case "quic", "direct_quic", "udp_quic", "quic_udp": score += 45 reasons = append(reasons, "transport:quic") - case "direct_tcp_tls", "direct_http", "direct_https": - score += 35 - reasons = append(reasons, "transport:direct") - case "wss": - score += 25 - reasons = append(reasons, "transport:wss") - case "outbound_reverse": - score += 10 - reasons = append(reasons, "transport:outbound_reverse") - case "relay": + case "lan_quic": + score += 42 + reasons = append(reasons, "transport:lan_quic") + case "ice_quic": + score += 38 + reasons = append(reasons, "transport:ice_quic") + case "reverse_quic": + score += 15 + reasons = append(reasons, "transport:reverse_quic") + case "relay_quic": score += 5 - reasons = append(reasons, "transport:relay") + reasons = append(reasons, "transport:relay_quic") default: score -= 100 - reasons = append(reasons, "transport:unknown") + reasons = append(reasons, "transport:non_quic_rejected") } switch candidate.Reachability { @@ -173,7 +176,8 @@ func scorePeerEndpointCandidate(candidate PeerEndpointCandidate, opts EndpointCa score += 8 reasons = append(reasons, "channel:control-direct") } - if candidate.Transport == "relay" { + transport := strings.ToLower(strings.TrimSpace(candidate.Transport)) + if transport == "relay" || transport == "relay_quic" { score -= 8 reasons = append(reasons, "channel:control-relay-penalty") } @@ -234,14 +238,20 @@ func scoreEndpointCandidateObservation(observation EndpointCandidateHealthObserv } switch { case observation.LastLatencyMs > 0 && observation.LastLatencyMs <= 50: - score += 18 + score += 24 reasons = append(reasons, "latency:low") case observation.LastLatencyMs > 0 && observation.LastLatencyMs <= 150: score += 8 reasons = append(reasons, "latency:moderate") - case observation.LastLatencyMs > 0: - score -= 10 + case observation.LastLatencyMs > 0 && observation.LastLatencyMs <= 300: + score -= 12 reasons = append(reasons, "latency:high") + case observation.LastLatencyMs > 0 && observation.LastLatencyMs <= 750: + score -= 32 + reasons = append(reasons, "latency:very_high") + case observation.LastLatencyMs > 0: + score -= 60 + reasons = append(reasons, "latency:extreme") } if observation.ReliabilityScore > 0 { switch { diff --git a/agents/rap-node-agent/internal/mesh/endpoint_candidate_scoring_test.go b/agents/rap-node-agent/internal/mesh/endpoint_candidate_scoring_test.go index c60b1fa..9acf941 100644 --- a/agents/rap-node-agent/internal/mesh/endpoint_candidate_scoring_test.go +++ b/agents/rap-node-agent/internal/mesh/endpoint_candidate_scoring_test.go @@ -13,7 +13,7 @@ func TestRankPeerEndpointCandidatesPrefersDirectFreshPublicPath(t *testing.T) { { EndpointID: "node-b-relay", NodeID: "node-b", - Transport: "relay", + Transport: "relay_quic", Address: "relay.example.test/node-b", Reachability: "relay", NATType: "symmetric", @@ -25,8 +25,8 @@ func TestRankPeerEndpointCandidatesPrefersDirectFreshPublicPath(t *testing.T) { { EndpointID: "node-b-public", NodeID: "node-b", - Transport: "direct_tcp_tls", - Address: "203.0.113.20:443", + Transport: "direct_quic", + Address: "quic://203.0.113.20:19443", Reachability: "public", NATType: "none", ConnectivityMode: "direct", @@ -38,8 +38,8 @@ func TestRankPeerEndpointCandidatesPrefersDirectFreshPublicPath(t *testing.T) { { EndpointID: "node-b-private-stale", NodeID: "node-b", - Transport: "wss", - Address: "10.0.0.5:443", + Transport: "lan_quic", + Address: "quic://10.0.0.5:19443", Reachability: "private", NATType: "restricted", ConnectivityMode: "direct", @@ -74,8 +74,8 @@ func TestRankPeerEndpointCandidatesUsesDeterministicTieBreak(t *testing.T) { { EndpointID: "endpoint-b", NodeID: "node-b", - Transport: "direct_tcp_tls", - Address: "203.0.113.21:443", + Transport: "direct_quic", + Address: "quic://203.0.113.21:19443", Reachability: "public", NATType: "none", ConnectivityMode: "direct", @@ -84,8 +84,8 @@ func TestRankPeerEndpointCandidatesUsesDeterministicTieBreak(t *testing.T) { { EndpointID: "endpoint-a", NodeID: "node-b", - Transport: "direct_tcp_tls", - Address: "203.0.113.20:443", + Transport: "direct_quic", + Address: "quic://203.0.113.20:19443", Reachability: "public", NATType: "none", ConnectivityMode: "direct", @@ -103,10 +103,10 @@ func TestRankPeerEndpointCandidatesPrefersQUICFastPath(t *testing.T) { now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) candidates := []PeerEndpointCandidate{ { - EndpointID: "node-b-wss", + EndpointID: "node-b-relay", NodeID: "node-b", - Transport: "wss", - Address: "wss://node-b.example.test", + Transport: "relay_quic", + Address: "quic://relay.example.test:19443", Reachability: "public", NATType: "none", ConnectivityMode: "direct", @@ -138,14 +138,44 @@ func TestRankPeerEndpointCandidatesPrefersQUICFastPath(t *testing.T) { } } +func TestRankPeerEndpointCandidatesDropsUnspecifiedQUICEndpoint(t *testing.T) { + candidates := []PeerEndpointCandidate{ + { + EndpointID: "node-b-unspecified", + NodeID: "node-b", + Transport: "direct_quic", + Address: "quic://[::]:19131", + Reachability: "public", + NATType: "none", + ConnectivityMode: "direct", + Priority: 1, + }, + { + EndpointID: "node-b-public", + NodeID: "node-b", + Transport: "direct_quic", + Address: "quic://203.0.113.20:19131", + Reachability: "public", + NATType: "none", + ConnectivityMode: "direct", + Priority: 10, + }, + } + + ranked := RankPeerEndpointCandidates(candidates, EndpointCandidateScoreOptions{}) + if len(ranked) != 1 || ranked[0].Candidate.EndpointID != "node-b-public" { + t.Fatalf("unspecified endpoint was not dropped: %+v", ranked) + } +} + func TestRankPeerEndpointCandidatesPrefersCorporatePrivateEndpoint(t *testing.T) { now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) candidates := []PeerEndpointCandidate{ { EndpointID: "node-b-public", NodeID: "node-b", - Transport: "direct_tcp_tls", - Address: "203.0.113.20:443", + Transport: "direct_quic", + Address: "quic://203.0.113.20:19443", Reachability: "public", NATType: "none", ConnectivityMode: "direct", @@ -155,8 +185,8 @@ func TestRankPeerEndpointCandidatesPrefersCorporatePrivateEndpoint(t *testing.T) { EndpointID: "node-b-corp-lan", NodeID: "node-b", - Transport: "direct_tcp_tls", - Address: "10.24.10.20:19001", + Transport: "lan_quic", + Address: "quic://10.24.10.20:19443", Reachability: "private", NATType: "none", ConnectivityMode: "direct", @@ -184,7 +214,7 @@ func TestRankPeerEndpointCandidatesDoesNotDropRelayRequiredFallback(t *testing.T { EndpointID: "node-b-outbound", NodeID: "node-b", - Transport: "outbound_reverse", + Transport: "reverse_quic", Address: "node-b.reverse.local", Reachability: "outbound_only", NATType: "symmetric", @@ -194,7 +224,7 @@ func TestRankPeerEndpointCandidatesDoesNotDropRelayRequiredFallback(t *testing.T { EndpointID: "node-b-relay", NodeID: "node-b", - Transport: "relay", + Transport: "relay_quic", Address: "relay.example.test/node-b", Reachability: "relay", NATType: "blocked", @@ -222,18 +252,18 @@ func TestRankPeerEndpointCandidatesUsesHealthObservationOverlay(t *testing.T) { { EndpointID: "node-b-direct", NodeID: "node-b", - Transport: "direct_tcp_tls", - Address: "203.0.113.20:443", + Transport: "direct_quic", + Address: "quic://203.0.113.20:19443", Reachability: "public", NATType: "none", ConnectivityMode: "direct", Priority: 10, }, { - EndpointID: "node-b-wss", + EndpointID: "node-b-ice", NodeID: "node-b", - Transport: "wss", - Address: "node-b.example.test", + Transport: "ice_quic", + Address: "quic://node-b.example.test:19443", Reachability: "public", NATType: "restricted", ConnectivityMode: "direct", @@ -253,8 +283,8 @@ func TestRankPeerEndpointCandidatesUsesHealthObservationOverlay(t *testing.T) { ReliabilityScore: 50, ObservedAt: now.Add(-time.Minute), }, - "node-b-wss": { - EndpointID: "node-b-wss", + "node-b-ice": { + EndpointID: "node-b-ice", LastLatencyMs: 35, SuccessCount: 8, ReliabilityScore: 95, @@ -262,8 +292,8 @@ func TestRankPeerEndpointCandidatesUsesHealthObservationOverlay(t *testing.T) { }, }, }) - if ranked[0].Candidate.EndpointID != "node-b-wss" { - t.Fatalf("top endpoint = %q, want node-b-wss: %+v", ranked[0].Candidate.EndpointID, ranked) + if ranked[0].Candidate.EndpointID != "node-b-ice" { + t.Fatalf("top endpoint = %q, want node-b-ice: %+v", ranked[0].Candidate.EndpointID, ranked) } if !containsReason(ranked[0].Reasons, "latency:low") || !containsReason(ranked[0].Reasons, "reliability:high") { t.Fatalf("top reasons missing health hints: %+v", ranked[0].Reasons) @@ -279,8 +309,8 @@ func TestRankPeerEndpointCandidatesTreatsStaleObservationAsPenalty(t *testing.T) { EndpointID: "node-b-direct", NodeID: "node-b", - Transport: "direct_tcp_tls", - Address: "203.0.113.20:443", + Transport: "direct_quic", + Address: "quic://203.0.113.20:19443", Reachability: "public", NATType: "none", ConnectivityMode: "direct", @@ -321,10 +351,10 @@ func TestRankPeerEndpointCandidatesDoesNotRewardZeroLatencyFailure(t *testing.T) LastVerifiedAt: &now, }, { - EndpointID: "node-b-wss", + EndpointID: "node-b-ice", NodeID: "node-b", - Transport: "wss", - Address: "https://node-b.example.test:443", + Transport: "ice_quic", + Address: "quic://node-b.example.test:19444", Reachability: "public", ConnectivityMode: "direct", Priority: 10, @@ -345,14 +375,81 @@ func TestRankPeerEndpointCandidatesDoesNotRewardZeroLatencyFailure(t *testing.T) }, MaxObservationAge: time.Minute, }) - if ranked[0].Candidate.EndpointID != "node-b-wss" { - t.Fatalf("top endpoint = %q, want wss after repeated quic failures: %+v", ranked[0].Candidate.EndpointID, ranked) + if ranked[0].Candidate.EndpointID != "node-b-ice" { + t.Fatalf("top endpoint = %q, want ice_quic after repeated direct QUIC failures: %+v", ranked[0].Candidate.EndpointID, ranked) } if containsReason(ranked[1].Reasons, "latency:moderate") { t.Fatalf("zero latency failure was rewarded as moderate latency: %+v", ranked[1].Reasons) } } +func TestRankPeerEndpointCandidatesPenalizesSevereLatencyGradient(t *testing.T) { + now := time.Date(2026, 5, 17, 6, 0, 0, 0, time.UTC) + candidates := []PeerEndpointCandidate{ + { + EndpointID: "node-b-lan", + NodeID: "node-b", + Transport: "direct_quic", + Address: "quic://10.0.0.2:19443", + Reachability: "private", + ConnectivityMode: "direct", + LastVerifiedAt: &now, + }, + { + EndpointID: "node-b-wan", + NodeID: "node-b", + Transport: "direct_quic", + Address: "quic://203.0.113.20:19443", + Reachability: "public", + ConnectivityMode: "direct", + LastVerifiedAt: &now, + }, + { + EndpointID: "node-b-bad-relay", + NodeID: "node-b", + Transport: "relay_quic", + Address: "quic://relay.example.test:19443", + Reachability: "relay", + ConnectivityMode: "relay_required", + LastVerifiedAt: &now, + }, + } + ranked := RankPeerEndpointCandidates(candidates, EndpointCandidateScoreOptions{ + Now: now, + MaxVerificationAge: time.Minute, + MaxObservationAge: time.Minute, + Observations: map[string]EndpointCandidateHealthObservation{ + "node-b-lan": { + EndpointID: "node-b-lan", + LastLatencyMs: 4, + ReliabilityScore: 95, + ObservedAt: now, + }, + "node-b-wan": { + EndpointID: "node-b-wan", + LastLatencyMs: 420, + ReliabilityScore: 95, + ObservedAt: now, + }, + "node-b-bad-relay": { + EndpointID: "node-b-bad-relay", + LastLatencyMs: 900, + ReliabilityScore: 95, + ObservedAt: now, + }, + }, + }) + if ranked[0].Candidate.EndpointID != "node-b-lan" || ranked[1].Candidate.EndpointID != "node-b-wan" || ranked[2].Candidate.EndpointID != "node-b-bad-relay" { + t.Fatalf("ranked endpoints = %+v, want lan, wan, bad relay", ranked) + } + if !containsReason(ranked[1].Reasons, "latency:very_high") { + t.Fatalf("wan reasons = %+v, want latency:very_high", ranked[1].Reasons) + } + if !containsReason(ranked[2].Reasons, "latency:extreme") { + t.Fatalf("relay reasons = %+v, want latency:extreme", ranked[2].Reasons) + } +} + func TestRankPeerEndpointCandidatesTreatsCapacityAsSoftPressure(t *testing.T) { now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) ranked := RankPeerEndpointCandidates([]PeerEndpointCandidate{ diff --git a/agents/rap-node-agent/internal/mesh/fabric_channel_router.go b/agents/rap-node-agent/internal/mesh/fabric_channel_router.go new file mode 100644 index 0000000..d362f60 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_channel_router.go @@ -0,0 +1,217 @@ +package mesh + +import ( + "errors" + "strings" + "time" +) + +type FabricChannelRouteEventType string + +const ( + FabricChannelRouteEventNone FabricChannelRouteEventType = "" + FabricChannelRouteEventOpened FabricChannelRouteEventType = "opened" + FabricChannelRouteEventReroute FabricChannelRouteEventType = "reroute" +) + +var ErrFabricRouteRerouteSuppressed = errors.New("fabric route reroute suppressed") + +type FabricChannelRouterConfig struct { + SchedulerConfig FabricRouteSchedulerConfig + MaxAckLatencyMs int64 + MaxRoutePressure int + MinRerouteInterval time.Duration + ProjectedChannelCost int +} + +type FabricChannelRouter struct { + Config FabricChannelRouterConfig + Scheduler FabricRouteScheduler +} + +type FabricChannelObservation struct { + ChannelID string + RouteID string + AckLatencyMs int64 + Failed bool + BytesSent uint64 + BytesRecv uint64 + FramesSent uint64 + FramesRecv uint64 + Reason string + ObservedAt time.Time +} + +type FabricChannelRouteEvent struct { + Type FabricChannelRouteEventType + Reason string + PreviousRoute FabricRoute + NextRoute FabricRoute + Choice FabricRouteChoice + Observation FabricChannelObservation + Channel FabricChannel + OccurredAt time.Time +} + +func NewFabricChannelRouter(cfg FabricChannelRouterConfig) FabricChannelRouter { + cfg = normalizeFabricChannelRouterConfig(cfg) + return FabricChannelRouter{ + Config: cfg, + Scheduler: NewFabricRouteScheduler(cfg.SchedulerConfig), + } +} + +func (r FabricChannelRouter) OpenChannel(spec FabricChannelSpec, routeSet FabricRouteSet, now time.Time) (FabricChannel, FabricChannelRouteEvent, error) { + if now.IsZero() { + now = time.Now().UTC() + } + choice, err := r.Scheduler.ChooseRoute(spec, routeSet, now) + if err != nil { + return FabricChannel{}, FabricChannelRouteEvent{}, err + } + channel := FabricChannel{ + Spec: spec, + State: FabricChannelOpen, + RouteID: choice.Route.RouteID, + TargetNode: choice.Route.DestinationNodeID, + OpenedAt: now, + } + event := FabricChannelRouteEvent{ + Type: FabricChannelRouteEventOpened, + Reason: choice.Reason, + NextRoute: choice.Route, + Choice: choice, + Channel: channel, + OccurredAt: now, + } + return channel, event, nil +} + +func (r FabricChannelRouter) ObserveChannel(channel FabricChannel, routeSet FabricRouteSet, observation FabricChannelObservation, now time.Time) (FabricChannel, FabricChannelRouteEvent, error) { + if now.IsZero() { + now = time.Now().UTC() + } + if observation.ObservedAt.IsZero() { + observation.ObservedAt = now + } + channel.BytesSent += observation.BytesSent + channel.BytesRecv += observation.BytesRecv + channel.FramesSent += observation.FramesSent + channel.FramesRecv += observation.FramesRecv + if channel.State == "" { + channel.State = FabricChannelOpen + } + if !r.shouldReroute(channel, observation, routeSet, now) { + return channel, FabricChannelRouteEvent{Type: FabricChannelRouteEventNone, Observation: observation, Channel: channel, OccurredAt: now}, nil + } + previous, _ := findFabricRoute(routeSet, channel.RouteID) + choice, err := r.chooseAlternativeRoute(channel.Spec, routeSet, channel.RouteID, now) + if err != nil { + return channel, FabricChannelRouteEvent{}, err + } + channel.RouteID = choice.Route.RouteID + channel.TargetNode = choice.Route.DestinationNodeID + channel.LastReroute = now + channel.RerouteCount++ + reason := observation.Reason + if strings.TrimSpace(reason) == "" { + reason = rerouteReason(r.Config, observation, previous) + } + event := FabricChannelRouteEvent{ + Type: FabricChannelRouteEventReroute, + Reason: reason, + PreviousRoute: previous, + NextRoute: choice.Route, + Choice: choice, + Observation: observation, + Channel: channel, + OccurredAt: now, + } + return channel, event, nil +} + +func (r FabricChannelRouter) shouldReroute(channel FabricChannel, observation FabricChannelObservation, routeSet FabricRouteSet, now time.Time) bool { + cfg := normalizeFabricChannelRouterConfig(r.Config) + if cfg.MinRerouteInterval > 0 && !channel.LastReroute.IsZero() && now.Sub(channel.LastReroute) < cfg.MinRerouteInterval { + return false + } + if observation.Failed { + return true + } + if cfg.MaxAckLatencyMs > 0 && observation.AckLatencyMs > cfg.MaxAckLatencyMs { + return true + } + if cfg.MaxRoutePressure > 0 { + if route, ok := findFabricRoute(routeSet, channel.RouteID); ok && fabricRoutePressurePercent(route, cfg.ProjectedChannelCost) > cfg.MaxRoutePressure { + return true + } + } + return false +} + +func (r FabricChannelRouter) chooseAlternativeRoute(spec FabricChannelSpec, routeSet FabricRouteSet, currentRouteID string, now time.Time) (FabricRouteChoice, error) { + routes := flattenFabricRouteSet(routeSet) + alternatives := make([]FabricRoute, 0, len(routes)) + for _, route := range routes { + if route.RouteID == currentRouteID { + continue + } + alternatives = append(alternatives, route) + } + if len(alternatives) == 0 { + return FabricRouteChoice{}, ErrFabricRouteNotFound + } + return r.Scheduler.ChooseRoute(spec, routeSetFromRoutes(routeSet, alternatives), now) +} + +func normalizeFabricChannelRouterConfig(cfg FabricChannelRouterConfig) FabricChannelRouterConfig { + if cfg.ProjectedChannelCost <= 0 { + cfg.ProjectedChannelCost = 1 + } + if cfg.SchedulerConfig.ProjectedChannelCost <= 0 { + cfg.SchedulerConfig.ProjectedChannelCost = cfg.ProjectedChannelCost + } + if cfg.MaxRoutePressure <= 0 { + cfg.MaxRoutePressure = 90 + } + return cfg +} + +func rerouteReason(cfg FabricChannelRouterConfig, observation FabricChannelObservation, route FabricRoute) string { + cfg = normalizeFabricChannelRouterConfig(cfg) + switch { + case observation.Failed: + return "route_failure" + case cfg.MaxAckLatencyMs > 0 && observation.AckLatencyMs > cfg.MaxAckLatencyMs: + return "ack_latency_threshold" + case cfg.MaxRoutePressure > 0 && fabricRoutePressurePercent(route, cfg.ProjectedChannelCost) > cfg.MaxRoutePressure: + return "route_capacity_pressure" + default: + return "route_degraded" + } +} + +func findFabricRoute(routeSet FabricRouteSet, routeID string) (FabricRoute, bool) { + routeID = strings.TrimSpace(routeID) + if routeID == "" { + return FabricRoute{}, false + } + for _, route := range flattenFabricRouteSet(routeSet) { + if route.RouteID == routeID { + return route, true + } + } + return FabricRoute{}, false +} + +func routeSetFromRoutes(template FabricRouteSet, routes []FabricRoute) FabricRouteSet { + out := FabricRouteSet{TargetKind: template.TargetKind, TargetID: template.TargetID} + if len(routes) == 0 { + return out + } + out.Primary = routes[0] + if len(routes) > 1 { + out.WarmStandby = append(out.WarmStandby, routes[1:]...) + } + return out +} diff --git a/agents/rap-node-agent/internal/mesh/fabric_channel_router_test.go b/agents/rap-node-agent/internal/mesh/fabric_channel_router_test.go new file mode 100644 index 0000000..63692c4 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_channel_router_test.go @@ -0,0 +1,151 @@ +package mesh + +import ( + "testing" + "time" +) + +func TestFabricChannelRouterOpensOnBestRoute(t *testing.T) { + router := NewFabricChannelRouter(FabricChannelRouterConfig{}) + now := time.Now() + channel, event, err := router.OpenChannel(testFabricChannelSpec(FabricChannelTargetNode, "node-b"), FabricRouteSet{ + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + Primary: testFabricRoute("route-slow", "node-b", 80, 100, 0, true), + WarmStandby: []FabricRoute{ + testFabricRoute("route-fast", "node-b", 15, 100, 0, true), + }, + }, now) + if err != nil { + t.Fatalf("open channel: %v", err) + } + if channel.RouteID != "route-fast" || channel.State != FabricChannelOpen { + t.Fatalf("channel = %+v, want route-fast open", channel) + } + if event.Type != FabricChannelRouteEventOpened || event.NextRoute.RouteID != "route-fast" { + t.Fatalf("event = %+v", event) + } +} + +func TestFabricChannelRouterReroutesOnSlowAck(t *testing.T) { + router := NewFabricChannelRouter(FabricChannelRouterConfig{MaxAckLatencyMs: 30}) + now := time.Now() + routeSet := FabricRouteSet{ + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + Primary: testFabricRoute("route-primary", "node-b", 10, 100, 0, true), + WarmStandby: []FabricRoute{ + testFabricRoute("route-standby", "node-b", 20, 100, 0, true), + }, + } + channel := FabricChannel{ + Spec: testFabricChannelSpec(FabricChannelTargetNode, "node-b"), + State: FabricChannelOpen, + RouteID: "route-primary", + OpenedAt: now.Add(-time.Minute), + } + updated, event, err := router.ObserveChannel(channel, routeSet, FabricChannelObservation{ + ChannelID: channel.Spec.ChannelID, + RouteID: channel.RouteID, + AckLatencyMs: 120, + BytesSent: 4096, + FramesSent: 4, + }, now) + if err != nil { + t.Fatalf("observe channel: %v", err) + } + if event.Type != FabricChannelRouteEventReroute || event.Reason != "ack_latency_threshold" { + t.Fatalf("event = %+v", event) + } + if updated.RouteID != "route-standby" || updated.RerouteCount != 1 || updated.BytesSent != 4096 || updated.FramesSent != 4 { + t.Fatalf("updated = %+v", updated) + } +} + +func TestFabricChannelRouterReroutesPoolTargetOnFailure(t *testing.T) { + router := NewFabricChannelRouter(FabricChannelRouterConfig{}) + now := time.Now() + routeSet := FabricRouteSet{ + TargetKind: FabricChannelTargetPool, + TargetID: "pool-egress", + Primary: testFabricPoolRoute("route-node-b", "node-b", 10, true), + WarmStandby: []FabricRoute{ + testFabricPoolRoute("route-node-c", "node-c", 20, true), + }, + } + channel := FabricChannel{ + Spec: testFabricChannelSpec(FabricChannelTargetPool, "pool-egress"), + State: FabricChannelOpen, + RouteID: "route-node-b", + TargetNode: "node-b", + OpenedAt: now.Add(-time.Minute), + } + updated, event, err := router.ObserveChannel(channel, routeSet, FabricChannelObservation{ + ChannelID: channel.Spec.ChannelID, + RouteID: channel.RouteID, + Failed: true, + Reason: "target_failed", + }, now) + if err != nil { + t.Fatalf("observe channel: %v", err) + } + if event.Type != FabricChannelRouteEventReroute || event.PreviousRoute.RouteID != "route-node-b" || event.NextRoute.RouteID != "route-node-c" { + t.Fatalf("event = %+v", event) + } + if updated.TargetNode != "node-c" || updated.RouteID != "route-node-c" { + t.Fatalf("updated = %+v", updated) + } +} + +func TestFabricChannelRouterSuppressesRerouteInsideHysteresis(t *testing.T) { + router := NewFabricChannelRouter(FabricChannelRouterConfig{MaxAckLatencyMs: 30, MinRerouteInterval: time.Minute}) + now := time.Now() + channel := FabricChannel{ + Spec: testFabricChannelSpec(FabricChannelTargetNode, "node-b"), + State: FabricChannelOpen, + RouteID: "route-primary", + LastReroute: now.Add(-10 * time.Second), + } + updated, event, err := router.ObserveChannel(channel, FabricRouteSet{ + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + Primary: testFabricRoute("route-primary", "node-b", 10, 100, 0, true), + WarmStandby: []FabricRoute{testFabricRoute("route-standby", "node-b", 20, 100, 0, true)}, + }, FabricChannelObservation{AckLatencyMs: 120}, now) + if err != nil { + t.Fatalf("observe channel: %v", err) + } + if event.Type != FabricChannelRouteEventNone || updated.RouteID != "route-primary" { + t.Fatalf("event=%+v updated=%+v", event, updated) + } +} + +func testFabricChannelSpec(kind FabricChannelTargetKind, targetID string) FabricChannelSpec { + return FabricChannelSpec{ + ChannelID: "channel-1", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + TargetKind: kind, + TargetID: targetID, + } +} + +func testFabricRoute(routeID string, destination string, latency int, capacity int, active int, healthy bool) FabricRoute { + return FabricRoute{ + RouteID: routeID, + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: destination, + Hops: []FabricRouteHop{{NodeID: "node-a"}, {NodeID: destination}}, + BaseLatencyMs: latency, + Capacity: capacity, + ActiveChannels: active, + Healthy: healthy, + } +} + +func testFabricPoolRoute(routeID string, destination string, latency int, healthy bool) FabricRoute { + route := testFabricRoute(routeID, destination, latency, 100, 0, healthy) + route.PoolID = "pool-egress" + return route +} diff --git a/agents/rap-node-agent/internal/mesh/fabric_channel_runtime.go b/agents/rap-node-agent/internal/mesh/fabric_channel_runtime.go new file mode 100644 index 0000000..0df9953 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_channel_runtime.go @@ -0,0 +1,487 @@ +package mesh + +import ( + "context" + "fmt" + "strings" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" +) + +type FabricChannelRuntimeConfig struct { + RouterConfig FabricChannelRouterConfig + StreamID uint64 + TrafficClass fabricproto.TrafficClass + Timeout time.Duration + MaxPayload int + RouteHealthTTL time.Duration +} + +type FabricChannelRuntime struct { + Transport FabricTransport + Router FabricChannelRouter + Pressure *FabricRoutePressureTracker + Health *FabricRouteHealthTracker + Config FabricChannelRuntimeConfig +} + +type FabricChannelRuntimeResult struct { + Channel FabricChannel + BytesSent uint64 + BytesRecv uint64 + FramesSent uint64 + FramesRecv uint64 + AcksReceived uint64 + RouteEvents []FabricChannelRouteEvent + RouteAttempts []string + MigrationEvents int + RoutePressure FabricRoutePressureSnapshot + RouteHealth FabricRouteHealthSnapshot +} + +type FabricChannelRequestResponseResult struct { + FabricChannelRuntimeResult + ResponsePayload []byte +} + +func NewFabricChannelRuntime(transport FabricTransport, cfg FabricChannelRuntimeConfig) *FabricChannelRuntime { + if cfg.StreamID == 0 { + cfg.StreamID = 2 + } + if cfg.TrafficClass == 0 { + cfg.TrafficClass = fabricproto.TrafficClassBulk + } + if cfg.Timeout <= 0 { + cfg.Timeout = 30 * time.Second + } + if cfg.MaxPayload <= 0 { + cfg.MaxPayload = fabricproto.DefaultMaxPayload + } + return &FabricChannelRuntime{ + Transport: transport, + Router: NewFabricChannelRouter(cfg.RouterConfig), + Pressure: NewFabricRoutePressureTracker(), + Health: NewFabricRouteHealthTracker(cfg.RouteHealthTTL), + Config: cfg, + } +} + +func (r *FabricChannelRuntime) SendReliable(ctx context.Context, spec FabricChannelSpec, routeSet FabricRouteSet, payloads [][]byte) (FabricChannelRuntimeResult, error) { + if r == nil || r.Transport == nil { + return FabricChannelRuntimeResult{}, ErrForwardRuntimeUnavailable + } + now := time.Now().UTC() + routeSet = r.routeSetForScheduling(routeSet) + channel, event, err := r.Router.OpenChannel(spec, routeSet, now) + if err != nil { + return FabricChannelRuntimeResult{}, err + } + result := FabricChannelRuntimeResult{Channel: channel, RouteEvents: []FabricChannelRouteEvent{event}} + sequence := uint64(0) + index := 0 + for index < len(payloads) { + routeSet = r.routeSetForScheduling(routeSet) + route, ok := findFabricRoute(routeSet, channel.RouteID) + if !ok { + return result, ErrFabricRouteNotFound + } + result.RouteAttempts = append(result.RouteAttempts, route.RouteID) + target, err := FabricTransportTargetForRoute(route) + if err != nil { + return result, err + } + releaseRoute := r.acquireRoute(route.RouteID) + session, err := r.Transport.Connect(ctx, target) + if err != nil { + releaseRoute() + r.markRouteFailure(route.RouteID, err) + updated, event, rerouteErr := r.Router.ObserveChannel(channel, routeSet, FabricChannelObservation{ + ChannelID: spec.ChannelID, + RouteID: route.RouteID, + Failed: true, + Reason: "connect_failed", + ObservedAt: time.Now().UTC(), + }, time.Now().UTC()) + channel = updated + result.Channel = channel + if event.Type == FabricChannelRouteEventReroute { + result.RouteEvents = append(result.RouteEvents, event) + result.MigrationEvents++ + continue + } + if rerouteErr != nil { + return result, rerouteErr + } + return result, err + } + migrated, sendErr := r.sendOnSession(ctx, session, &channel, routeSet, route, payloads, &index, &sequence, &result) + _ = session.Close() + releaseRoute() + result.Channel = channel + if sendErr != nil { + return result, sendErr + } + if !migrated { + break + } + } + result.Channel = channel + result.RoutePressure = r.snapshotRoutePressure() + result.RouteHealth = r.snapshotRouteHealth() + return result, nil +} + +func (r *FabricChannelRuntime) SendRequestResponse(ctx context.Context, spec FabricChannelSpec, routeSet FabricRouteSet, payload []byte) (FabricChannelRequestResponseResult, error) { + if r == nil || r.Transport == nil { + return FabricChannelRequestResponseResult{}, ErrForwardRuntimeUnavailable + } + if len(payload) > r.Config.MaxPayload { + return FabricChannelRequestResponseResult{}, fmt.Errorf("%w: %d > %d", fabricproto.ErrInvalidPayloadLen, len(payload), r.Config.MaxPayload) + } + now := time.Now().UTC() + routeSet = r.routeSetForScheduling(routeSet) + channel, event, err := r.Router.OpenChannel(spec, routeSet, now) + if err != nil { + return FabricChannelRequestResponseResult{}, err + } + result := FabricChannelRequestResponseResult{ + FabricChannelRuntimeResult: FabricChannelRuntimeResult{Channel: channel, RouteEvents: []FabricChannelRouteEvent{event}}, + } + sequence := uint64(1) + for { + routeSet = r.routeSetForScheduling(routeSet) + route, ok := findFabricRoute(routeSet, channel.RouteID) + if !ok { + return result, ErrFabricRouteNotFound + } + result.RouteAttempts = append(result.RouteAttempts, route.RouteID) + target, err := FabricTransportTargetForRoute(route) + if err != nil { + return result, err + } + releaseRoute := r.acquireRoute(route.RouteID) + session, err := r.Transport.Connect(ctx, target) + if err != nil { + releaseRoute() + r.markRouteFailure(route.RouteID, err) + updated, routeEvent, rerouteErr := r.Router.ObserveChannel(channel, routeSet, FabricChannelObservation{ + ChannelID: spec.ChannelID, + RouteID: route.RouteID, + Failed: true, + Reason: "connect_failed", + ObservedAt: time.Now().UTC(), + }, time.Now().UTC()) + channel = updated + result.Channel = channel + if routeEvent.Type == FabricChannelRouteEventReroute { + result.RouteEvents = append(result.RouteEvents, routeEvent) + result.MigrationEvents++ + continue + } + if rerouteErr != nil { + return result, rerouteErr + } + return result, err + } + response, ackMs, sendErr := r.sendRequestResponseOnSession(ctx, session, route.RouteID, spec.ChannelID, payload, sequence) + _ = session.Close() + releaseRoute() + result.Channel = channel + if sendErr == nil { + r.markRouteSuccess(route.RouteID) + result.BytesSent += uint64(len(payload)) + result.FramesSent++ + result.BytesRecv += uint64(len(response)) + result.FramesRecv++ + result.AcksReceived++ + updated, routeEvent, observeErr := r.Router.ObserveChannel(channel, routeSet, FabricChannelObservation{ + ChannelID: spec.ChannelID, + RouteID: route.RouteID, + AckLatencyMs: ackMs, + BytesSent: uint64(len(payload)), + FramesSent: 1, + BytesRecv: uint64(len(response)), + FramesRecv: 1, + ObservedAt: time.Now().UTC(), + }, time.Now().UTC()) + channel = updated + result.Channel = channel + if observeErr != nil { + return result, observeErr + } + if routeEvent.Type == FabricChannelRouteEventReroute { + result.RouteEvents = append(result.RouteEvents, routeEvent) + result.MigrationEvents++ + } + result.ResponsePayload = response + result.RoutePressure = r.snapshotRoutePressure() + result.RouteHealth = r.snapshotRouteHealth() + return result, nil + } + r.markRouteFailure(route.RouteID, sendErr) + updated, routeEvent, rerouteErr := r.Router.ObserveChannel(channel, routeSet, FabricChannelObservation{ + ChannelID: spec.ChannelID, + RouteID: route.RouteID, + Failed: true, + Reason: "response_failed", + ObservedAt: time.Now().UTC(), + }, time.Now().UTC()) + channel = updated + result.Channel = channel + if routeEvent.Type == FabricChannelRouteEventReroute { + result.RouteEvents = append(result.RouteEvents, routeEvent) + result.MigrationEvents++ + continue + } + if rerouteErr != nil { + return result, rerouteErr + } + return result, sendErr + } +} + +func (r *FabricChannelRuntime) routeSetForScheduling(routeSet FabricRouteSet) FabricRouteSet { + if r != nil && r.Health != nil { + routeSet = r.Health.Apply(routeSet, time.Now().UTC()) + } + return r.routeSetWithActiveChannels(routeSet) +} + +func (r *FabricChannelRuntime) routeSetWithActiveChannels(routeSet FabricRouteSet) FabricRouteSet { + if r == nil || r.Pressure == nil { + return routeSet + } + return r.Pressure.Apply(routeSet) +} + +func (r *FabricChannelRuntime) acquireRoute(routeID string) func() { + if r == nil || r.Pressure == nil { + return func() {} + } + return r.Pressure.Acquire(routeID) +} + +func (r *FabricChannelRuntime) snapshotRoutePressure() FabricRoutePressureSnapshot { + if r == nil || r.Pressure == nil { + return FabricRoutePressureSnapshot{} + } + return r.Pressure.SnapshotPressure() +} + +func (r *FabricChannelRuntime) snapshotRouteHealth() FabricRouteHealthSnapshot { + if r == nil || r.Health == nil { + return FabricRouteHealthSnapshot{} + } + return r.Health.Snapshot(time.Now().UTC()) +} + +func (r *FabricChannelRuntime) markRouteFailure(routeID string, err error) { + if r == nil || r.Health == nil || err == nil { + return + } + r.Health.MarkFailure(routeID, err.Error(), time.Now().UTC()) +} + +func (r *FabricChannelRuntime) markRouteSuccess(routeID string) { + if r == nil || r.Health == nil { + return + } + r.Health.MarkSuccess(routeID) +} + +func (r *FabricChannelRuntime) sendOnSession(ctx context.Context, session FabricTransportSession, channel *FabricChannel, routeSet FabricRouteSet, route FabricRoute, payloads [][]byte, index *int, sequence *uint64, result *FabricChannelRuntimeResult) (bool, error) { + cfg := r.Config + if err := session.Send(ctx, fabricproto.Frame{ + Type: fabricproto.FrameOpenStream, + TrafficClass: cfg.TrafficClass, + StreamID: cfg.StreamID, + }); err != nil { + r.markRouteFailure(route.RouteID, err) + return false, err + } + for *index < len(payloads) { + payload := payloads[*index] + if len(payload) > cfg.MaxPayload { + return false, fmt.Errorf("%w: %d > %d", fabricproto.ErrInvalidPayloadLen, len(payload), cfg.MaxPayload) + } + (*sequence)++ + if err := session.Send(ctx, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: cfg.TrafficClass, + StreamID: cfg.StreamID, + Sequence: *sequence, + Payload: payload, + }); err != nil { + r.markRouteFailure(route.RouteID, err) + return false, err + } + ackOK, ackMs := waitForFabricRuntimeAck(ctx, session, cfg.StreamID, *sequence, cfg.Timeout) + if !ackOK { + r.markRouteFailure(route.RouteID, fmt.Errorf("ack_failed")) + updated, event, err := r.Router.ObserveChannel(*channel, routeSet, FabricChannelObservation{ + ChannelID: channel.Spec.ChannelID, + RouteID: route.RouteID, + Failed: true, + Reason: "ack_failed", + ObservedAt: time.Now().UTC(), + }, time.Now().UTC()) + *channel = updated + if event.Type == FabricChannelRouteEventReroute { + result.RouteEvents = append(result.RouteEvents, event) + result.MigrationEvents++ + return true, nil + } + return false, err + } + r.markRouteSuccess(route.RouteID) + *index++ + result.BytesSent += uint64(len(payload)) + result.FramesSent++ + result.AcksReceived++ + updated, event, err := r.Router.ObserveChannel(*channel, routeSet, FabricChannelObservation{ + ChannelID: channel.Spec.ChannelID, + RouteID: route.RouteID, + AckLatencyMs: ackMs, + BytesSent: uint64(len(payload)), + FramesSent: 1, + ObservedAt: time.Now().UTC(), + }, time.Now().UTC()) + *channel = updated + if err != nil { + return false, err + } + if event.Type == FabricChannelRouteEventReroute { + result.RouteEvents = append(result.RouteEvents, event) + result.MigrationEvents++ + return true, nil + } + } + _ = session.Send(context.Background(), fabricproto.Frame{ + Type: fabricproto.FrameCloseStream, + TrafficClass: cfg.TrafficClass, + StreamID: cfg.StreamID, + }) + return false, nil +} + +func (r *FabricChannelRuntime) sendRequestResponseOnSession(ctx context.Context, session FabricTransportSession, routeID string, channelID string, payload []byte, sequence uint64) ([]byte, int64, error) { + cfg := r.Config + if err := session.Send(ctx, fabricproto.Frame{ + Type: fabricproto.FrameOpenStream, + TrafficClass: cfg.TrafficClass, + StreamID: cfg.StreamID, + }); err != nil { + r.markRouteFailure(routeID, err) + return nil, 0, err + } + started := time.Now() + if err := session.Send(ctx, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: cfg.TrafficClass, + StreamID: cfg.StreamID, + Sequence: sequence, + Payload: payload, + }); err != nil { + r.markRouteFailure(routeID, err) + return nil, 0, err + } + waitCtx := ctx + if cfg.Timeout > 0 { + var cancel context.CancelFunc + waitCtx, cancel = context.WithTimeout(ctx, cfg.Timeout) + defer cancel() + } + for { + select { + case <-waitCtx.Done(): + return nil, 0, waitCtx.Err() + case err, ok := <-session.Errors(): + if !ok { + return nil, 0, ErrForwardPeerUnavailable + } + if err != nil { + return nil, 0, err + } + case frame, ok := <-session.Frames(): + if !ok { + return nil, 0, ErrForwardPeerUnavailable + } + if frame.Type != fabricproto.FrameData || frame.StreamID != cfg.StreamID || frame.Sequence != sequence { + continue + } + _ = session.Send(context.Background(), fabricproto.Frame{ + Type: fabricproto.FrameCloseStream, + TrafficClass: cfg.TrafficClass, + StreamID: cfg.StreamID, + }) + return append([]byte(nil), frame.Payload...), time.Since(started).Milliseconds(), nil + } + } +} + +func FabricTransportTargetForRoute(route FabricRoute) (FabricTransportTarget, error) { + if strings.TrimSpace(route.RouteID) == "" { + return FabricTransportTarget{}, ErrFabricRouteNotFound + } + if route.RelayCount > 0 { + for _, hop := range route.Hops { + if hop.Mode != FabricRouteRelay { + continue + } + if target, ok := fabricTransportTargetForHop(hop); ok { + return target, nil + } + } + } + for i := len(route.Hops) - 1; i >= 0; i-- { + if target, ok := fabricTransportTargetForHop(route.Hops[i]); ok { + return target, nil + } + } + return FabricTransportTarget{}, fmt.Errorf("%w: route %s has no transport endpoint", ErrFabricRouteNotFound, route.RouteID) +} + +func fabricTransportTargetForHop(hop FabricRouteHop) (FabricTransportTarget, bool) { + endpoint := strings.TrimSpace(hop.Address) + if endpoint == "" { + return FabricTransportTarget{}, false + } + transport := string(hop.Mode) + if transport == "" { + transport = "quic" + } + return FabricTransportTarget{ + EndpointID: hop.EndpointID, + PeerID: strings.TrimSpace(hop.NodeID), + Endpoint: endpoint, + Transport: transport, + PeerCertSHA256: strings.TrimSpace(hop.PeerCertSHA256), + }, true +} + +func waitForFabricRuntimeAck(ctx context.Context, session FabricTransportSession, streamID uint64, sequence uint64, timeout time.Duration) (bool, int64) { + started := time.Now() + if timeout > 0 { + var cancel context.CancelFunc + ctx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + for { + select { + case <-ctx.Done(): + return false, 0 + case err, ok := <-session.Errors(): + if !ok || err != nil { + return false, 0 + } + case frame, ok := <-session.Frames(): + if !ok { + return false, 0 + } + if frame.Type == fabricproto.FrameAck && frame.StreamID == streamID && frame.Sequence == sequence { + return true, time.Since(started).Milliseconds() + } + } + } +} diff --git a/agents/rap-node-agent/internal/mesh/fabric_channel_runtime_test.go b/agents/rap-node-agent/internal/mesh/fabric_channel_runtime_test.go new file mode 100644 index 0000000..62357f2 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_channel_runtime_test.go @@ -0,0 +1,495 @@ +package mesh + +import ( + "context" + "strings" + "sync" + "testing" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" +) + +func TestFabricChannelRuntimeMigratesSlowAckToStandbyRoute(t *testing.T) { + transport := newFakeFabricRuntimeTransport(map[string]time.Duration{ + "quic://slow.example.test:19443": 60 * time.Millisecond, + "quic://fast.example.test:19443": 0, + }) + runtime := NewFabricChannelRuntime(transport, FabricChannelRuntimeConfig{ + RouterConfig: FabricChannelRouterConfig{MaxAckLatencyMs: 30}, + StreamID: 9, + }) + routeSet := FabricRouteSet{ + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + Primary: testRuntimeRoute("route-slow", "node-b", "quic://slow.example.test:19443", 10), + WarmStandby: []FabricRoute{ + testRuntimeRoute("route-fast", "node-b", "quic://fast.example.test:19443", 20), + }, + } + result, err := runtime.SendReliable(context.Background(), testFabricChannelSpec(FabricChannelTargetNode, "node-b"), routeSet, [][]byte{ + []byte("one"), + []byte("two"), + []byte("three"), + }) + if err != nil { + t.Fatalf("send reliable: %v", err) + } + if result.MigrationEvents != 1 { + t.Fatalf("migration events = %d, want 1: %+v", result.MigrationEvents, result.RouteEvents) + } + if result.Channel.RouteID != "route-fast" || result.Channel.RerouteCount != 1 { + t.Fatalf("channel = %+v", result.Channel) + } + if result.BytesSent != uint64(len("one")+len("two")+len("three")) || result.AcksReceived != 3 { + t.Fatalf("result = %+v", result) + } + if got := transport.connectCount("quic://slow.example.test:19443"); got != 1 { + t.Fatalf("slow connect count = %d, want 1", got) + } + if got := transport.connectCount("quic://fast.example.test:19443"); got != 1 { + t.Fatalf("fast connect count = %d, want 1", got) + } + if result.RoutePressure.AcquiredTotal != 2 || result.RoutePressure.ReleasedTotal != 2 || result.RoutePressure.MaxActiveTotal == 0 { + t.Fatalf("route pressure = %+v", result.RoutePressure) + } +} + +func TestFabricChannelRuntimeReroutesOnConnectFailure(t *testing.T) { + transport := newFakeFabricRuntimeTransport(map[string]time.Duration{ + "quic://fast.example.test:19443": 0, + }) + transport.failConnect["quic://dead.example.test:19443"] = true + runtime := NewFabricChannelRuntime(transport, FabricChannelRuntimeConfig{ + RouterConfig: FabricChannelRouterConfig{MaxAckLatencyMs: 30}, + StreamID: 9, + }) + routeSet := FabricRouteSet{ + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + Primary: testRuntimeRoute("route-dead", "node-b", "quic://dead.example.test:19443", 10), + WarmStandby: []FabricRoute{ + testRuntimeRoute("route-fast", "node-b", "quic://fast.example.test:19443", 20), + }, + } + result, err := runtime.SendReliable(context.Background(), testFabricChannelSpec(FabricChannelTargetNode, "node-b"), routeSet, [][]byte{[]byte("payload")}) + if err != nil { + t.Fatalf("send reliable: %v", err) + } + if result.MigrationEvents != 1 || result.Channel.RouteID != "route-fast" || result.BytesSent != uint64(len("payload")) { + t.Fatalf("result = %+v", result) + } +} + +func TestFabricChannelRuntimeQuarantinesFailedRouteAcrossChannels(t *testing.T) { + transport := newFakeFabricRuntimeTransport(map[string]time.Duration{ + "quic://fast.example.test:19443": 0, + }) + transport.failConnect["quic://dead.example.test:19443"] = true + runtime := NewFabricChannelRuntime(transport, FabricChannelRuntimeConfig{ + RouterConfig: FabricChannelRouterConfig{MaxAckLatencyMs: 30}, + StreamID: 9, + RouteHealthTTL: time.Minute, + }) + routeSet := FabricRouteSet{ + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + Primary: testRuntimeRoute("route-dead", "node-b", "quic://dead.example.test:19443", 10), + WarmStandby: []FabricRoute{ + testRuntimeRoute("route-fast", "node-b", "quic://fast.example.test:19443", 20), + }, + } + + first, err := runtime.SendReliable(context.Background(), testFabricChannelSpec(FabricChannelTargetNode, "node-b"), routeSet, [][]byte{[]byte("first")}) + if err != nil { + t.Fatalf("first send reliable: %v", err) + } + if first.Channel.RouteID != "route-fast" || first.RouteHealth.Quarantined["route-dead"].Failures != 1 { + t.Fatalf("first result = %+v", first) + } + second, err := runtime.SendReliable(context.Background(), testFabricChannelSpec(FabricChannelTargetNode, "node-b"), routeSet, [][]byte{[]byte("second")}) + if err != nil { + t.Fatalf("second send reliable: %v", err) + } + if second.Channel.RouteID != "route-fast" { + t.Fatalf("second route = %s, want route-fast", second.Channel.RouteID) + } + if got := transport.connectCount("quic://dead.example.test:19443"); got != 1 { + t.Fatalf("dead connect count = %d, want one attempt before quarantine", got) + } + if got := transport.connectCount("quic://fast.example.test:19443"); got != 2 { + t.Fatalf("fast connect count = %d, want both channels on healthy route", got) + } +} + +func TestFabricChannelRuntimeReroutesOnAckTimeout(t *testing.T) { + transport := newFakeFabricRuntimeTransport(map[string]time.Duration{ + "quic://slow.example.test:19443": 100 * time.Millisecond, + "quic://fast.example.test:19443": 0, + }) + runtime := NewFabricChannelRuntime(transport, FabricChannelRuntimeConfig{ + RouterConfig: FabricChannelRouterConfig{MaxAckLatencyMs: 30}, + StreamID: 9, + Timeout: 10 * time.Millisecond, + }) + routeSet := FabricRouteSet{ + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + Primary: testRuntimeRoute("route-slow", "node-b", "quic://slow.example.test:19443", 10), + WarmStandby: []FabricRoute{ + testRuntimeRoute("route-fast", "node-b", "quic://fast.example.test:19443", 20), + }, + } + result, err := runtime.SendReliable(context.Background(), testFabricChannelSpec(FabricChannelTargetNode, "node-b"), routeSet, [][]byte{[]byte("payload")}) + if err != nil { + t.Fatalf("send reliable: %v", err) + } + if result.MigrationEvents != 1 || result.Channel.RouteID != "route-fast" || result.BytesSent != uint64(len("payload")) { + t.Fatalf("result = %+v", result) + } +} + +func TestFabricChannelRuntimeSpreadsConcurrentChannelsBySharedPressure(t *testing.T) { + transport := newFakeFabricRuntimeTransport(map[string]time.Duration{ + "quic://route-a.example.test:19443": 80 * time.Millisecond, + "quic://route-b.example.test:19443": 0, + }) + runtime := NewFabricChannelRuntime(transport, FabricChannelRuntimeConfig{StreamID: 9}) + routeSet := FabricRouteSet{ + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + Primary: testRuntimeRoute("route-a", "node-b", "quic://route-a.example.test:19443", 10), + WarmStandby: []FabricRoute{ + testRuntimeRoute("route-b", "node-b", "quic://route-b.example.test:19443", 11), + }, + } + + firstDone := make(chan error, 1) + go func() { + _, err := runtime.SendReliable(context.Background(), testFabricChannelSpec(FabricChannelTargetNode, "node-b"), routeSet, [][]byte{[]byte("one")}) + firstDone <- err + }() + transport.waitForConnect(t, "quic://route-a.example.test:19443", 1) + result, err := runtime.SendReliable(context.Background(), testFabricChannelSpec(FabricChannelTargetNode, "node-b"), routeSet, [][]byte{[]byte("two")}) + if err != nil { + t.Fatalf("second send reliable: %v", err) + } + if result.Channel.RouteID != "route-b" { + t.Fatalf("second route = %s, want route-b", result.Channel.RouteID) + } + if got := transport.connectCount("quic://route-b.example.test:19443"); got != 1 { + t.Fatalf("route-b connect count = %d, want 1", got) + } + if err := <-firstDone; err != nil { + t.Fatalf("first send reliable: %v", err) + } +} + +func TestFabricChannelRuntimeRequestResponseReturnsPayload(t *testing.T) { + transport := newFakeFabricRequestResponseTransport(map[string][]byte{ + "quic://runtime.example.test:19443": []byte(`{"status":"ok"}`), + }) + runtime := NewFabricChannelRuntime(transport, FabricChannelRuntimeConfig{ + RouterConfig: FabricChannelRouterConfig{MaxAckLatencyMs: 30}, + StreamID: 9, + }) + routeSet := FabricRouteSet{ + TargetKind: FabricChannelTargetPool, + TargetID: "pool-admin-runtime", + Primary: testRuntimePoolRoute("route-runtime", "pool-admin-runtime", "node-runtime", "quic://runtime.example.test:19443", 10), + } + + result, err := runtime.SendRequestResponse(context.Background(), FabricChannelSpec{ + ChannelID: "channel-web-1", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + TargetKind: FabricChannelTargetPool, + TargetID: "pool-admin-runtime", + TrafficClass: "control", + CreatedAt: time.Now().UTC(), + }, routeSet, []byte(`{"request":true}`)) + if err != nil { + t.Fatalf("request response: %v", err) + } + if string(result.ResponsePayload) != `{"status":"ok"}` { + t.Fatalf("response payload = %s", string(result.ResponsePayload)) + } + if result.Channel.RouteID != "route-runtime" || + result.BytesSent != uint64(len(`{"request":true}`)) || + result.BytesRecv != uint64(len(`{"status":"ok"}`)) || + result.FramesSent != 1 || + result.FramesRecv != 1 || + result.AcksReceived != 1 { + t.Fatalf("result = %+v", result) + } +} + +func TestFabricChannelRuntimeRequestResponseReroutesOnResponseFailure(t *testing.T) { + transport := newFakeFabricRequestResponseTransport(map[string][]byte{ + "quic://fast.example.test:19443": []byte(`{"status":"ok"}`), + }) + transport.failResponse["quic://slow.example.test:19443"] = true + runtime := NewFabricChannelRuntime(transport, FabricChannelRuntimeConfig{ + RouterConfig: FabricChannelRouterConfig{MaxAckLatencyMs: 30}, + StreamID: 9, + Timeout: 10 * time.Millisecond, + }) + routeSet := FabricRouteSet{ + TargetKind: FabricChannelTargetNode, + TargetID: "node-runtime", + Primary: testRuntimeRoute("route-slow", "node-runtime", "quic://slow.example.test:19443", 10), + WarmStandby: []FabricRoute{ + testRuntimeRoute("route-fast", "node-runtime", "quic://fast.example.test:19443", 20), + }, + } + + result, err := runtime.SendRequestResponse(context.Background(), testFabricChannelSpec(FabricChannelTargetNode, "node-runtime"), routeSet, []byte(`{"request":true}`)) + if err != nil { + t.Fatalf("request response: %v", err) + } + if result.MigrationEvents != 1 || result.Channel.RouteID != "route-fast" || string(result.ResponsePayload) != `{"status":"ok"}` { + t.Fatalf("result = %+v", result) + } +} + +func TestFabricTransportTargetForRouteUsesLastAddressedHop(t *testing.T) { + target, err := FabricTransportTargetForRoute(FabricRoute{ + RouteID: "route-1", + Hops: []FabricRouteHop{ + {NodeID: "node-a"}, + {NodeID: "node-r", Mode: FabricRouteRelay, EndpointID: "relay-1", Address: "quic://relay.example.test:19443"}, + {NodeID: "node-b", Mode: FabricRouteDirect, EndpointID: "node-b-quic", Address: "quic://node-b.example.test:19443"}, + }, + }) + if err != nil { + t.Fatalf("target for route: %v", err) + } + if target.PeerID != "node-b" || target.EndpointID != "node-b-quic" || target.Endpoint != "quic://node-b.example.test:19443" || target.Transport != string(FabricRouteDirect) { + t.Fatalf("target = %+v", target) + } +} + +type fakeFabricRequestResponseTransport struct { + mu sync.Mutex + responses map[string][]byte + failResponse map[string]bool + connects map[string]int +} + +func newFakeFabricRequestResponseTransport(responses map[string][]byte) *fakeFabricRequestResponseTransport { + return &fakeFabricRequestResponseTransport{ + responses: responses, + failResponse: map[string]bool{}, + connects: map[string]int{}, + } +} + +func (t *fakeFabricRequestResponseTransport) Connect(_ context.Context, target FabricTransportTarget) (FabricTransportSession, error) { + endpoint := target.Endpoint + t.mu.Lock() + t.connects[endpoint]++ + response := append([]byte(nil), t.responses[endpoint]...) + failResponse := t.failResponse[endpoint] + t.mu.Unlock() + return &fakeFabricRequestResponseSession{ + response: response, + failResponse: failResponse, + frames: make(chan fabricproto.Frame, 16), + errors: make(chan error, 1), + done: make(chan struct{}), + }, nil +} + +func (t *fakeFabricRequestResponseTransport) Close() error { + return nil +} + +type fakeFabricRequestResponseSession struct { + response []byte + failResponse bool + frames chan fabricproto.Frame + errors chan error + done chan struct{} + once sync.Once +} + +func (s *fakeFabricRequestResponseSession) Send(_ context.Context, frame fabricproto.Frame) error { + if frame.Type != fabricproto.FrameData || s.failResponse { + return nil + } + response := append([]byte(nil), s.response...) + go func() { + select { + case <-s.done: + case s.frames <- fabricproto.Frame{Type: fabricproto.FrameData, TrafficClass: frame.TrafficClass, StreamID: frame.StreamID, Sequence: frame.Sequence, Payload: response}: + } + }() + return nil +} + +func (s *fakeFabricRequestResponseSession) Frames() <-chan fabricproto.Frame { + return s.frames +} + +func (s *fakeFabricRequestResponseSession) Errors() <-chan error { + return s.errors +} + +func (s *fakeFabricRequestResponseSession) Close() error { + s.once.Do(func() { + close(s.done) + }) + return nil +} + +func (s *fakeFabricRequestResponseSession) Closed() bool { + select { + case <-s.done: + return true + default: + return false + } +} + +func TestFabricTransportTargetForRouteUsesRelayHopForRelayRoute(t *testing.T) { + target, err := FabricTransportTargetForRoute(FabricRoute{ + RouteID: "route-relay", + RelayCount: 1, + Hops: []FabricRouteHop{ + {NodeID: "node-a"}, + {NodeID: "node-r", Mode: FabricRouteRelay, EndpointID: "relay-1", Address: "quic://relay.example.test:19443", PeerCertSHA256: "relay-cert"}, + {NodeID: "node-b", Mode: FabricRouteRelay, EndpointID: "node-b-private", Address: "quic://10.0.0.2:19443", PeerCertSHA256: "node-b-cert"}, + }, + }) + if err != nil { + t.Fatalf("target for relay route: %v", err) + } + if target.PeerID != "node-r" || target.EndpointID != "relay-1" || target.Endpoint != "quic://relay.example.test:19443" || target.PeerCertSHA256 != "relay-cert" { + t.Fatalf("target = %+v", target) + } +} + +type fakeFabricRuntimeTransport struct { + mu sync.Mutex + delays map[string]time.Duration + failConnect map[string]bool + connects map[string]int +} + +func newFakeFabricRuntimeTransport(delays map[string]time.Duration) *fakeFabricRuntimeTransport { + return &fakeFabricRuntimeTransport{ + delays: delays, + failConnect: map[string]bool{}, + connects: map[string]int{}, + } +} + +func (t *fakeFabricRuntimeTransport) Connect(_ context.Context, target FabricTransportTarget) (FabricTransportSession, error) { + endpoint := target.Endpoint + t.mu.Lock() + t.connects[endpoint]++ + fail := t.failConnect[endpoint] + delay := t.delays[endpoint] + t.mu.Unlock() + if fail { + return nil, ErrForwardPeerUnavailable + } + return &fakeFabricRuntimeSession{ + endpoint: endpoint, + delay: delay, + frames: make(chan fabricproto.Frame, 64), + errors: make(chan error, 1), + done: make(chan struct{}), + }, nil +} + +func (t *fakeFabricRuntimeTransport) Close() error { + return nil +} + +func (t *fakeFabricRuntimeTransport) connectCount(endpoint string) int { + t.mu.Lock() + defer t.mu.Unlock() + return t.connects[endpoint] +} + +func (t *fakeFabricRuntimeTransport) waitForConnect(tb testing.TB, endpoint string, count int) { + tb.Helper() + deadline := time.Now().Add(time.Second) + for { + t.mu.Lock() + got := t.connects[endpoint] + t.mu.Unlock() + if got >= count { + return + } + if time.Now().After(deadline) { + tb.Fatalf("timed out waiting for %s connect count %d, got %d", endpoint, count, got) + } + time.Sleep(time.Millisecond) + } +} + +type fakeFabricRuntimeSession struct { + endpoint string + delay time.Duration + frames chan fabricproto.Frame + errors chan error + done chan struct{} + once sync.Once +} + +func (s *fakeFabricRuntimeSession) Send(_ context.Context, frame fabricproto.Frame) error { + if frame.Type != fabricproto.FrameData { + return nil + } + delay := s.delay + go func() { + if delay > 0 { + time.Sleep(delay) + } + select { + case <-s.done: + case s.frames <- fabricproto.Frame{Type: fabricproto.FrameAck, TrafficClass: frame.TrafficClass, StreamID: frame.StreamID, Sequence: frame.Sequence}: + } + }() + return nil +} + +func (s *fakeFabricRuntimeSession) Frames() <-chan fabricproto.Frame { + return s.frames +} + +func (s *fakeFabricRuntimeSession) Errors() <-chan error { + return s.errors +} + +func (s *fakeFabricRuntimeSession) Close() error { + s.once.Do(func() { + close(s.done) + }) + return nil +} + +func (s *fakeFabricRuntimeSession) Closed() bool { + select { + case <-s.done: + return true + default: + return false + } +} + +func testRuntimeRoute(routeID string, destination string, endpoint string, latency int) FabricRoute { + route := testFabricRoute(routeID, destination, latency, 100, 0, true) + route.Hops[len(route.Hops)-1].Address = endpoint + route.Hops[len(route.Hops)-1].EndpointID = strings.TrimPrefix(routeID, "route-") + route.Hops[len(route.Hops)-1].Mode = FabricRouteDirect + return route +} + +func testRuntimePoolRoute(routeID string, poolID string, destination string, endpoint string, latency int) FabricRoute { + route := testRuntimeRoute(routeID, destination, endpoint, latency) + route.PoolID = poolID + return route +} diff --git a/agents/rap-node-agent/internal/mesh/fabric_core.go b/agents/rap-node-agent/internal/mesh/fabric_core.go new file mode 100644 index 0000000..4b61976 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_core.go @@ -0,0 +1,390 @@ +package mesh + +import ( + "errors" + "sort" + "strings" + "time" +) + +type FabricChannelTargetKind string + +const ( + FabricChannelTargetNode FabricChannelTargetKind = "node" + FabricChannelTargetPool FabricChannelTargetKind = "pool" +) + +type FabricChannelLifecycleState string + +const ( + FabricChannelOpening FabricChannelLifecycleState = "opening" + FabricChannelOpen FabricChannelLifecycleState = "open" + FabricChannelDraining FabricChannelLifecycleState = "draining" + FabricChannelClosed FabricChannelLifecycleState = "closed" +) + +type FabricRouteMode string + +const ( + FabricRouteDirect FabricRouteMode = "direct_quic" + FabricRouteLAN FabricRouteMode = "lan_quic" + FabricRouteReverse FabricRouteMode = "reverse_quic" + FabricRouteRelay FabricRouteMode = "relay_quic" + FabricRouteICE FabricRouteMode = "ice_quic" +) + +var ( + ErrFabricChannelInvalid = errors.New("fabric channel request is invalid") + ErrFabricRouteNotFound = errors.New("fabric route not found") +) + +type FabricChannelSpec struct { + ChannelID string + ClusterID string + SourceNodeID string + TargetKind FabricChannelTargetKind + TargetID string + TrafficClass string + MinBandwidth int64 + StickyKey string + CreatedAt time.Time + ForbiddenHops []string +} + +type FabricServiceChannelTarget struct { + Kind FabricChannelTargetKind + PoolIDs []string + NodeIDs []string + SelectedNodeID string + ServiceRole string + SelectionPolicy string + SingleMemberPool bool +} + +type FabricServiceChannelRequest struct { + SchemaVersion string + ChannelID string + ClusterID string + OrganizationID string + UserID string + ResourceID string + SourceNodeID string + SourceRole string + ServiceClass string + Target FabricServiceChannelTarget + TrafficClass string + CreatedAt time.Time +} + +type FabricChannel struct { + Spec FabricChannelSpec + State FabricChannelLifecycleState + RouteID string + TargetNode string + OpenedAt time.Time + LastReroute time.Time + BytesSent uint64 + BytesRecv uint64 + FramesSent uint64 + FramesRecv uint64 + RerouteCount uint64 +} + +type FabricRouteHop struct { + NodeID string + Mode FabricRouteMode + EndpointID string + Address string + PeerCertSHA256 string +} + +type FabricRoute struct { + RouteID string + ClusterID string + SourceNodeID string + DestinationNodeID string + PoolID string + Hops []FabricRouteHop + BaseLatencyMs int + JitterMs int + LossPermille int + Capacity int + ActiveChannels int + RelayCount int + LastUpdatedAt time.Time + Healthy bool + Degraded bool +} + +type FabricRouteSet struct { + TargetKind FabricChannelTargetKind + TargetID string + Primary FabricRoute + WarmStandby []FabricRoute + ColdFallbacks []FabricRoute +} + +type FabricAdjacency struct { + FromNodeID string + ToNodeID string + Mode FabricRouteMode + RTTMs int + JitterMs int + LossPermille int + Capacity int + ActiveChannels int + ThroughputBps int64 + PressurePercent int + Healthy bool + PassiveOutbound bool + LocalSegmentID string + NATGroupID string + LastObservedAt time.Time + LastFailureReason string +} + +type FabricRouteChoice struct { + Route FabricRoute + Score int + Reason string + PressureBefore int + PressureAfter int +} + +type FabricRouteSchedulerConfig struct { + LatencyWeight int + JitterWeight int + LossWeight int + PressureWeight int + HopPenalty int + RelayPenalty int + DegradedPenalty int + ProjectedChannelCost int + HardMaxRoutePressure int +} + +type FabricRouteScheduler struct { + Config FabricRouteSchedulerConfig +} + +func NewFabricRouteScheduler(cfg FabricRouteSchedulerConfig) FabricRouteScheduler { + return FabricRouteScheduler{Config: normalizeFabricRouteSchedulerConfig(cfg)} +} + +func (s FabricRouteScheduler) ChooseRoute(spec FabricChannelSpec, routeSet FabricRouteSet, now time.Time) (FabricRouteChoice, error) { + if err := ValidateFabricChannelSpec(spec); err != nil { + return FabricRouteChoice{}, err + } + routes := flattenFabricRouteSet(routeSet) + if len(routes) == 0 { + return FabricRouteChoice{}, ErrFabricRouteNotFound + } + forbidden := stringSet(spec.ForbiddenHops) + choices := make([]FabricRouteChoice, 0, len(routes)) + for _, route := range routes { + if !fabricRouteUsable(spec, route, forbidden, now) { + continue + } + choice := s.scoreRoute(route) + if s.Config.HardMaxRoutePressure > 0 && choice.PressureAfter > s.Config.HardMaxRoutePressure { + continue + } + choice.Route = route + choices = append(choices, choice) + } + if len(choices) == 0 { + return FabricRouteChoice{}, ErrFabricRouteNotFound + } + sort.SliceStable(choices, func(i, j int) bool { + if choices[i].Score != choices[j].Score { + return choices[i].Score < choices[j].Score + } + if choices[i].PressureAfter != choices[j].PressureAfter { + return choices[i].PressureAfter < choices[j].PressureAfter + } + if choices[i].Route.BaseLatencyMs != choices[j].Route.BaseLatencyMs { + return choices[i].Route.BaseLatencyMs < choices[j].Route.BaseLatencyMs + } + return choices[i].Route.RouteID < choices[j].Route.RouteID + }) + return choices[0], nil +} + +func ValidateFabricChannelSpec(spec FabricChannelSpec) error { + if strings.TrimSpace(spec.ChannelID) == "" || strings.TrimSpace(spec.ClusterID) == "" || strings.TrimSpace(spec.SourceNodeID) == "" || strings.TrimSpace(spec.TargetID) == "" { + return ErrFabricChannelInvalid + } + switch spec.TargetKind { + case FabricChannelTargetNode, FabricChannelTargetPool: + return nil + default: + return ErrFabricChannelInvalid + } +} + +func FabricChannelSpecFromServiceRequest(req FabricServiceChannelRequest, localNodeID string, now time.Time) (FabricChannelSpec, error) { + if now.IsZero() { + now = time.Now().UTC() + } + sourceNodeID := firstNonEmpty(strings.TrimSpace(req.SourceNodeID), strings.TrimSpace(localNodeID)) + targetKind := req.Target.Kind + if targetKind == "" { + targetKind = FabricChannelTargetPool + } + targetID := firstNonEmpty(firstString(req.Target.PoolIDs), strings.TrimSpace(req.Target.SelectedNodeID), firstString(req.Target.NodeIDs)) + if targetKind == FabricChannelTargetNode { + targetID = firstNonEmpty(strings.TrimSpace(req.Target.SelectedNodeID), firstString(req.Target.NodeIDs), targetID) + } + spec := FabricChannelSpec{ + ChannelID: firstNonEmpty(strings.TrimSpace(req.ChannelID), strings.TrimSpace(req.ResourceID)), + ClusterID: strings.TrimSpace(req.ClusterID), + SourceNodeID: sourceNodeID, + TargetKind: targetKind, + TargetID: targetID, + TrafficClass: firstNonEmpty(strings.TrimSpace(req.TrafficClass), serviceClassDefaultTrafficClass(req.ServiceClass)), + StickyKey: strings.TrimSpace(req.ResourceID), + CreatedAt: now, + } + if err := ValidateFabricChannelSpec(spec); err != nil { + return FabricChannelSpec{}, err + } + return spec, nil +} + +func serviceClassDefaultTrafficClass(serviceClass string) string { + switch strings.TrimSpace(strings.ToLower(serviceClass)) { + case FabricServiceClassVPNPackets: + return FabricServiceChannelBulk + case FabricServiceClassRemoteWorkspace: + return FabricServiceChannelInteractive + default: + return FabricServiceChannelReliable + } +} + +func firstString(values []string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func (s FabricRouteScheduler) scoreRoute(route FabricRoute) FabricRouteChoice { + cfg := normalizeFabricRouteSchedulerConfig(s.Config) + pressureBefore := fabricRoutePressurePercent(route, 0) + pressureAfter := fabricRoutePressurePercent(route, cfg.ProjectedChannelCost) + score := route.BaseLatencyMs*cfg.LatencyWeight + + route.JitterMs*cfg.JitterWeight + + route.LossPermille*cfg.LossWeight + + pressureAfter*cfg.PressureWeight + + len(route.Hops)*cfg.HopPenalty + + route.RelayCount*cfg.RelayPenalty + if route.Degraded { + score += cfg.DegradedPenalty + } + reason := "latency_load_score" + if pressureAfter >= 90 { + reason = "capacity_pressure_avoidance" + } + if route.RelayCount > 0 { + reason = "relay_fallback_available" + } + return FabricRouteChoice{Score: score, Reason: reason, PressureBefore: pressureBefore, PressureAfter: pressureAfter} +} + +func normalizeFabricRouteSchedulerConfig(cfg FabricRouteSchedulerConfig) FabricRouteSchedulerConfig { + if cfg.LatencyWeight <= 0 { + cfg.LatencyWeight = 10 + } + if cfg.JitterWeight <= 0 { + cfg.JitterWeight = 4 + } + if cfg.LossWeight <= 0 { + cfg.LossWeight = 8 + } + if cfg.PressureWeight <= 0 { + cfg.PressureWeight = 12 + } + if cfg.HopPenalty <= 0 { + cfg.HopPenalty = 5 + } + if cfg.RelayPenalty <= 0 { + cfg.RelayPenalty = 25 + } + if cfg.DegradedPenalty <= 0 { + cfg.DegradedPenalty = 500 + } + if cfg.ProjectedChannelCost <= 0 { + cfg.ProjectedChannelCost = 1 + } + if cfg.HardMaxRoutePressure < 0 { + cfg.HardMaxRoutePressure = 0 + } + return cfg +} + +func flattenFabricRouteSet(routeSet FabricRouteSet) []FabricRoute { + routes := make([]FabricRoute, 0, 1+len(routeSet.WarmStandby)+len(routeSet.ColdFallbacks)) + if strings.TrimSpace(routeSet.Primary.RouteID) != "" { + routes = append(routes, routeSet.Primary) + } + routes = append(routes, routeSet.WarmStandby...) + routes = append(routes, routeSet.ColdFallbacks...) + return routes +} + +func fabricRouteUsable(spec FabricChannelSpec, route FabricRoute, forbidden map[string]struct{}, now time.Time) bool { + if strings.TrimSpace(route.RouteID) == "" || !route.Healthy { + return false + } + if route.ClusterID != "" && spec.ClusterID != "" && route.ClusterID != spec.ClusterID { + return false + } + if route.SourceNodeID != "" && route.SourceNodeID != spec.SourceNodeID { + return false + } + switch spec.TargetKind { + case FabricChannelTargetNode: + if route.DestinationNodeID != "" && route.DestinationNodeID != spec.TargetID { + return false + } + case FabricChannelTargetPool: + if route.PoolID != "" && route.PoolID != spec.TargetID { + return false + } + } + for _, hop := range route.Hops { + if _, blocked := forbidden[hop.NodeID]; blocked { + return false + } + } + return true +} + +func fabricRoutePressurePercent(route FabricRoute, projected int) int { + if route.Capacity <= 0 { + return 100 + } + active := route.ActiveChannels + projected + if active <= 0 { + return 0 + } + pressure := (active * 100) / route.Capacity + if pressure > 100 { + return 100 + } + return pressure +} + +func stringSet(values []string) map[string]struct{} { + out := make(map[string]struct{}, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + out[value] = struct{}{} + } + } + return out +} diff --git a/agents/rap-node-agent/internal/mesh/fabric_core_test.go b/agents/rap-node-agent/internal/mesh/fabric_core_test.go new file mode 100644 index 0000000..a3112d4 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_core_test.go @@ -0,0 +1,244 @@ +package mesh + +import ( + "errors" + "testing" + "time" +) + +func TestFabricRouteSchedulerAvoidsSaturatedShortestRoute(t *testing.T) { + scheduler := NewFabricRouteScheduler(FabricRouteSchedulerConfig{}) + spec := FabricChannelSpec{ + ChannelID: "channel-1", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + } + choice, err := scheduler.ChooseRoute(spec, FabricRouteSet{ + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + Primary: FabricRoute{ + RouteID: "short-saturated", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-b", + Hops: []FabricRouteHop{{NodeID: "node-a"}, {NodeID: "node-b"}}, + BaseLatencyMs: 10, + Capacity: 10, + ActiveChannels: 10, + Healthy: true, + }, + WarmStandby: []FabricRoute{{ + RouteID: "slightly-longer-free", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-b", + Hops: []FabricRouteHop{{NodeID: "node-a"}, {NodeID: "node-r"}, {NodeID: "node-b"}}, + BaseLatencyMs: 18, + Capacity: 100, + ActiveChannels: 5, + RelayCount: 1, + Healthy: true, + }}, + }, time.Now()) + if err != nil { + t.Fatalf("choose route: %v", err) + } + if choice.Route.RouteID != "slightly-longer-free" { + t.Fatalf("route = %q, want slightly-longer-free score=%d pressure=%d", choice.Route.RouteID, choice.Score, choice.PressureAfter) + } +} + +func TestFabricChannelSpecFromServiceRequestTargetsPool(t *testing.T) { + spec, err := FabricChannelSpecFromServiceRequest(FabricServiceChannelRequest{ + ChannelID: "vpn-1", + ClusterID: "cluster-1", + ResourceID: "vpn-1", + ServiceClass: FabricServiceClassVPNPackets, + Target: FabricServiceChannelTarget{ + Kind: FabricChannelTargetPool, + PoolIDs: []string{"home-ipv4"}, + ServiceRole: "ipv4-egress", + }, + }, "android-node", time.Now()) + if err != nil { + t.Fatalf("service request spec: %v", err) + } + if spec.SourceNodeID != "android-node" || spec.TargetKind != FabricChannelTargetPool || spec.TargetID != "home-ipv4" || spec.TrafficClass != FabricServiceChannelBulk { + t.Fatalf("unexpected spec: %+v", spec) + } +} + +func TestFabricChannelSpecFromServiceRequestKeepsServiceOutOfEndpointSelection(t *testing.T) { + _, err := FabricChannelSpecFromServiceRequest(FabricServiceChannelRequest{ + ChannelID: "rdp-1", + ClusterID: "cluster-1", + ServiceClass: FabricServiceClassRemoteWorkspace, + Target: FabricServiceChannelTarget{ + Kind: FabricChannelTargetPool, + ServiceRole: "rdp-gateway", + }, + }, "client-node", time.Now()) + if !errors.Is(err, ErrFabricChannelInvalid) { + t.Fatalf("err = %v, want invalid without pool/node target id", err) + } +} + +func TestFabricRouteSchedulerPoolSkipsFailedEndpoint(t *testing.T) { + scheduler := NewFabricRouteScheduler(FabricRouteSchedulerConfig{}) + spec := FabricChannelSpec{ + ChannelID: "channel-pool", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + TargetKind: FabricChannelTargetPool, + TargetID: "pool-egress", + } + choice, err := scheduler.ChooseRoute(spec, FabricRouteSet{ + TargetKind: FabricChannelTargetPool, + TargetID: "pool-egress", + Primary: FabricRoute{ + RouteID: "pool-node-dead", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-b", + PoolID: "pool-egress", + Capacity: 100, + Healthy: false, + }, + WarmStandby: []FabricRoute{{ + RouteID: "pool-node-live", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-c", + PoolID: "pool-egress", + Hops: []FabricRouteHop{{NodeID: "node-a"}, {NodeID: "node-c"}}, + BaseLatencyMs: 25, + Capacity: 100, + Healthy: true, + }}, + }, time.Now()) + if err != nil { + t.Fatalf("choose route: %v", err) + } + if choice.Route.DestinationNodeID != "node-c" { + t.Fatalf("destination = %q, want node-c", choice.Route.DestinationNodeID) + } +} + +func TestFabricRouteSchedulerHonorsForbiddenHops(t *testing.T) { + scheduler := NewFabricRouteScheduler(FabricRouteSchedulerConfig{}) + spec := FabricChannelSpec{ + ChannelID: "channel-1", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + ForbiddenHops: []string{"node-r"}, + } + _, err := scheduler.ChooseRoute(spec, FabricRouteSet{ + Primary: FabricRoute{ + RouteID: "blocked", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-b", + Hops: []FabricRouteHop{{NodeID: "node-a"}, {NodeID: "node-r"}, {NodeID: "node-b"}}, + Capacity: 100, + Healthy: true, + }, + }, time.Now()) + if !errors.Is(err, ErrFabricRouteNotFound) { + t.Fatalf("err = %v, want ErrFabricRouteNotFound", err) + } +} + +func TestFabricRouteSchedulerRejectsRoutesAboveHardPressureLimit(t *testing.T) { + scheduler := NewFabricRouteScheduler(FabricRouteSchedulerConfig{HardMaxRoutePressure: 80}) + spec := FabricChannelSpec{ + ChannelID: "channel-pressure", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + } + choice, err := scheduler.ChooseRoute(spec, FabricRouteSet{ + Primary: FabricRoute{ + RouteID: "too-busy", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-b", + Capacity: 10, + ActiveChannels: 9, + Healthy: true, + }, + WarmStandby: []FabricRoute{{ + RouteID: "admissible", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-b", + Capacity: 10, + ActiveChannels: 5, + Healthy: true, + }}, + }, time.Now()) + if err != nil { + t.Fatalf("choose route: %v", err) + } + if choice.Route.RouteID != "admissible" { + t.Fatalf("route = %q, want admissible", choice.Route.RouteID) + } +} + +func TestFabricRouteSchedulerKeepsHighLatencyRouteAsFallbackUntilFastRouteSaturates(t *testing.T) { + spec := FabricChannelSpec{ + ChannelID: "channel-latency-aware", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + TargetKind: FabricChannelTargetPool, + TargetID: "pool-egress", + } + routeSet := FabricRouteSet{ + TargetKind: FabricChannelTargetPool, + TargetID: "pool-egress", + Primary: FabricRoute{ + RouteID: "lan-fast", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-lan", + PoolID: "pool-egress", + BaseLatencyMs: 4, + Capacity: 100, + ActiveChannels: 85, + Healthy: true, + }, + WarmStandby: []FabricRoute{{ + RouteID: "wan-slow", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-wan", + PoolID: "pool-egress", + BaseLatencyMs: 420, + Capacity: 100, + ActiveChannels: 0, + Healthy: true, + }}, + } + + scheduler := NewFabricRouteScheduler(FabricRouteSchedulerConfig{HardMaxRoutePressure: 90}) + choice, err := scheduler.ChooseRoute(spec, routeSet, time.Now()) + if err != nil { + t.Fatalf("choose route: %v", err) + } + if choice.Route.RouteID != "lan-fast" { + t.Fatalf("route = %q, want fast LAN before hard pressure limit", choice.Route.RouteID) + } + + routeSet.Primary.ActiveChannels = 90 + choice, err = scheduler.ChooseRoute(spec, routeSet, time.Now()) + if err != nil { + t.Fatalf("choose fallback route: %v", err) + } + if choice.Route.RouteID != "wan-slow" { + t.Fatalf("route = %q, want WAN only after LAN reaches hard pressure limit", choice.Route.RouteID) + } +} diff --git a/agents/rap-node-agent/internal/mesh/fabric_overlay_transport.go b/agents/rap-node-agent/internal/mesh/fabric_overlay_transport.go new file mode 100644 index 0000000..40d0859 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_overlay_transport.go @@ -0,0 +1,130 @@ +package mesh + +import ( + "context" + "fmt" + "strings" + "sync/atomic" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" +) + +type FabricOverlayTransportConfig struct { + ClusterID string + LocalNodeID string + RouterConfig FabricChannelRouterConfig + Timeout time.Duration +} + +type FabricOverlayTransport struct { + Runtime *FabricChannelRuntime + RouteSets map[string]FabricRouteSet + Config FabricOverlayTransportConfig + sequence atomic.Uint64 +} + +type FabricOverlayTransportSnapshot struct { + RoutePressure FabricRoutePressureSnapshot `json:"route_pressure"` + RouteHealth FabricRouteHealthSnapshot `json:"route_health,omitempty"` +} + +type FabricOverlaySendRequest struct { + ChannelID string + TargetKind FabricChannelTargetKind + TargetID string + TrafficClass fabricproto.TrafficClass + Payloads [][]byte + StickyKey string +} + +func NewFabricOverlayTransport(transport FabricTransport, routeSets map[string]FabricRouteSet, cfg FabricOverlayTransportConfig) *FabricOverlayTransport { + if cfg.Timeout <= 0 { + cfg.Timeout = 30 * time.Second + } + runtime := NewFabricChannelRuntime(transport, FabricChannelRuntimeConfig{ + RouterConfig: cfg.RouterConfig, + Timeout: cfg.Timeout, + }) + normalized := make(map[string]FabricRouteSet, len(routeSets)) + for targetID, routeSet := range routeSets { + targetID = strings.TrimSpace(targetID) + if targetID != "" { + normalized[targetID] = routeSet + } + } + return &FabricOverlayTransport{ + Runtime: runtime, + RouteSets: normalized, + Config: cfg, + } +} + +func (t *FabricOverlayTransport) Send(ctx context.Context, req FabricOverlaySendRequest) (FabricChannelRuntimeResult, error) { + if t == nil || t.Runtime == nil { + return FabricChannelRuntimeResult{}, ErrForwardRuntimeUnavailable + } + targetID := strings.TrimSpace(req.TargetID) + if targetID == "" { + return FabricChannelRuntimeResult{}, ErrFabricChannelInvalid + } + routeSet, ok := t.RouteSets[targetID] + if !ok { + return FabricChannelRuntimeResult{}, ErrFabricRouteNotFound + } + targetKind := req.TargetKind + if targetKind == "" { + targetKind = routeSet.TargetKind + } + if targetKind == "" { + targetKind = FabricChannelTargetNode + } + trafficClass := req.TrafficClass + if trafficClass == 0 { + trafficClass = fabricproto.TrafficClassReliable + } + t.Runtime.Config.TrafficClass = trafficClass + spec := FabricChannelSpec{ + ChannelID: firstNonEmpty(strings.TrimSpace(req.ChannelID), fmt.Sprintf("fabric-overlay-%d", t.sequence.Add(1))), + ClusterID: strings.TrimSpace(t.Config.ClusterID), + SourceNodeID: strings.TrimSpace(t.Config.LocalNodeID), + TargetKind: targetKind, + TargetID: targetID, + TrafficClass: loadFabricTrafficClassName(trafficClass), + StickyKey: strings.TrimSpace(req.StickyKey), + CreatedAt: time.Now().UTC(), + } + return t.Runtime.SendReliable(ctx, spec, routeSet, req.Payloads) +} + +func (t *FabricOverlayTransport) SnapshotPressure() FabricRoutePressureSnapshot { + if t == nil || t.Runtime == nil || t.Runtime.Pressure == nil { + return FabricRoutePressureSnapshot{} + } + return t.Runtime.Pressure.SnapshotPressure() +} + +func (t *FabricOverlayTransport) Snapshot() FabricOverlayTransportSnapshot { + if t == nil || t.Runtime == nil { + return FabricOverlayTransportSnapshot{} + } + return FabricOverlayTransportSnapshot{ + RoutePressure: t.Runtime.snapshotRoutePressure(), + RouteHealth: t.Runtime.snapshotRouteHealth(), + } +} + +func loadFabricTrafficClassName(trafficClass fabricproto.TrafficClass) string { + switch trafficClass { + case fabricproto.TrafficClassControl: + return "control" + case fabricproto.TrafficClassInteractive: + return "interactive" + case fabricproto.TrafficClassBulk: + return "bulk" + case fabricproto.TrafficClassReliable: + return "reliable" + default: + return fmt.Sprintf("traffic_class_%d", trafficClass) + } +} diff --git a/agents/rap-node-agent/internal/mesh/fabric_overlay_transport_test.go b/agents/rap-node-agent/internal/mesh/fabric_overlay_transport_test.go new file mode 100644 index 0000000..4b3e0ea --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_overlay_transport_test.go @@ -0,0 +1,49 @@ +package mesh + +import ( + "context" + "testing" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" +) + +func TestFabricOverlayTransportSendsThroughRouteSet(t *testing.T) { + transport := newFakeFabricRuntimeTransport(map[string]time.Duration{ + "quic://node-b:19443": 0, + }) + overlay := NewFabricOverlayTransport(transport, map[string]FabricRouteSet{ + "node-b": { + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + Primary: FabricRoute{ + RouteID: "node-b-direct", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-b", + Hops: []FabricRouteHop{{NodeID: "node-b", Mode: FabricRouteDirect, EndpointID: "node-b-direct", Address: "quic://node-b:19443"}}, + Capacity: 100, + Healthy: true, + }, + }, + }, FabricOverlayTransportConfig{ClusterID: "cluster-1", LocalNodeID: "node-a"}) + ctx, cancel := context.WithTimeout(context.Background(), time.Second) + defer cancel() + result, err := overlay.Send(ctx, FabricOverlaySendRequest{ + TargetID: "node-b", + TrafficClass: fabricproto.TrafficClassReliable, + Payloads: [][]byte{[]byte("payload")}, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + if result.BytesSent != uint64(len("payload")) || result.AcksReceived != 1 { + t.Fatalf("result = %+v", result) + } + if pressure := overlay.SnapshotPressure(); pressure.ActiveTotal != 0 || pressure.AcquiredTotal != pressure.ReleasedTotal { + t.Fatalf("pressure leak: %+v", pressure) + } + if snapshot := overlay.Snapshot(); snapshot.RoutePressure.AcquiredTotal != 1 || len(snapshot.RouteHealth.Quarantined) != 0 { + t.Fatalf("snapshot = %+v", snapshot) + } +} diff --git a/agents/rap-node-agent/internal/mesh/fabric_quic_server.go b/agents/rap-node-agent/internal/mesh/fabric_quic_server.go index 36d75bf..2b2b8e1 100644 --- a/agents/rap-node-agent/internal/mesh/fabric_quic_server.go +++ b/agents/rap-node-agent/internal/mesh/fabric_quic_server.go @@ -3,28 +3,50 @@ package mesh import ( "context" "crypto/tls" + "encoding/json" "fmt" "net" + "strings" "sync" + "time" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" "github.com/quic-go/quic-go" ) type QUICFabricServer struct { - listener *quic.Listener - logger FabricSessionEventLogger - done chan struct{} - closeOnce sync.Once + listener *quic.Listener + logger FabricSessionEventLogger + reverseMu sync.RWMutex + reverseTransport *QUICFabricTransport + fabricFrameHandler FabricFrameHandler + productionForwardHandler func(context.Context, ProductionEnvelope) (ProductionForwardResult, error) + webIngressForwardHandler func(context.Context, []byte) ([]byte, error) + fabricControlHandler func(context.Context, []byte) ([]byte, error) + syntheticForwardHandler func(context.Context, SyntheticEnvelope) (SyntheticEnvelope, error) + done chan struct{} + closeOnce sync.Once } type QUICFabricServerConfig struct { - ListenAddr string - TLSConfig *tls.Config - QUICConfig *quic.Config - Logger FabricSessionEventLogger + ListenAddr string + TLSConfig *tls.Config + QUICConfig *quic.Config + Logger FabricSessionEventLogger + ReverseTransport *QUICFabricTransport + FabricFrameHandler FabricFrameHandler + ProductionForwardHandler func(context.Context, ProductionEnvelope) (ProductionForwardResult, error) + WebIngressForwardHandler func(context.Context, []byte) ([]byte, error) + FabricControlHandler func(context.Context, []byte) ([]byte, error) + SyntheticForwardHandler func(context.Context, SyntheticEnvelope) (SyntheticEnvelope, error) } +type FabricFrameSender interface { + SendFrame(context.Context, fabricproto.Frame) error +} + +type FabricFrameHandler func(context.Context, FabricFrameSender, fabricproto.Frame) (bool, error) + func StartQUICFabricServer(ctx context.Context, cfg QUICFabricServerConfig) (*QUICFabricServer, error) { if cfg.ListenAddr == "" { return nil, fmt.Errorf("quic fabric listen addr is required") @@ -42,9 +64,15 @@ func StartQUICFabricServer(ctx context.Context, cfg QUICFabricServerConfig) (*QU return nil, err } server := &QUICFabricServer{ - listener: listener, - logger: cfg.Logger, - done: make(chan struct{}), + listener: listener, + logger: cfg.Logger, + reverseTransport: cfg.ReverseTransport, + fabricFrameHandler: cfg.FabricFrameHandler, + productionForwardHandler: cfg.ProductionForwardHandler, + webIngressForwardHandler: cfg.WebIngressForwardHandler, + fabricControlHandler: cfg.FabricControlHandler, + syntheticForwardHandler: cfg.SyntheticForwardHandler, + done: make(chan struct{}), } go server.acceptLoop(ctx) return server, nil @@ -57,6 +85,15 @@ func (s *QUICFabricServer) Addr() net.Addr { return s.listener.Addr() } +func (s *QUICFabricServer) SetReverseTransport(transport *QUICFabricTransport) { + if s == nil { + return + } + s.reverseMu.Lock() + s.reverseTransport = transport + s.reverseMu.Unlock() +} + func (s *QUICFabricServer) Close() error { if s == nil { return nil @@ -95,6 +132,8 @@ func (s *QUICFabricServer) handleConn(ctx context.Context, conn *quic.Conn) { func (s *QUICFabricServer) handleStream(ctx context.Context, conn *quic.Conn, stream *quic.Stream) { session := fabricproto.NewSession(fabricproto.SessionConfig{}) + sender := quicStreamFrameSender{stream: stream} + defer func() { _ = stream.Close() }() s.logFabricSession(FabricSessionEventLogEntry{ Event: "fabric_session_quic_stream_opened", AcceptedBy: "quic", @@ -116,6 +155,29 @@ func (s *QUICFabricServer) handleStream(ctx context.Context, conn *quic.Conn, st if err != nil { return } + s.registerReverseHelloFrame(conn, frame) + if s.handleProductionForwardFrame(ctx, stream, frame) { + continue + } + if s.handleWebIngressForwardFrame(ctx, stream, frame) { + continue + } + if s.handleFabricControlForwardFrame(ctx, stream, frame) { + continue + } + if s.handleSyntheticForwardFrame(ctx, conn, stream, frame) { + continue + } + if s.fabricFrameHandler != nil { + handled, err := s.fabricFrameHandler(ctx, sender, frame) + if err != nil { + _ = conn.CloseWithError(2, err.Error()) + return + } + if handled { + continue + } + } event, responses, err := session.HandleFrame(frame) if err != nil { _ = conn.CloseWithError(2, err.Error()) @@ -140,6 +202,196 @@ func (s *QUICFabricServer) handleStream(ctx context.Context, conn *quic.Conn, st } } +type quicStreamFrameSender struct { + stream *quic.Stream + mu sync.Mutex +} + +func (s quicStreamFrameSender) SendFrame(ctx context.Context, frame fabricproto.Frame) error { + if s.stream == nil { + return fmt.Errorf("quic fabric stream is closed") + } + s.mu.Lock() + defer s.mu.Unlock() + if deadline, ok := ctx.Deadline(); ok { + _ = s.stream.SetWriteDeadline(deadline) + } else { + _ = s.stream.SetWriteDeadline(time.Now().Add(30 * time.Second)) + } + return fabricproto.WriteFrame(s.stream, frame) +} + +func (s *QUICFabricServer) registerReverseHelloFrame(conn *quic.Conn, frame fabricproto.Frame) { + reverseTransport := s.getReverseTransport() + if s == nil || reverseTransport == nil || conn == nil || frame.Type != fabricproto.FramePing { + return + } + payload := string(frame.Payload) + if !strings.HasPrefix(payload, fabricQUICReverseHelloPrefix) { + return + } + peerID := strings.TrimPrefix(payload, fabricQUICReverseHelloPrefix) + reverseTransport.RegisterReverseConn(peerID, conn) + s.logFabricSession(FabricSessionEventLogEntry{ + Event: "fabric_session_quic_reverse_registered", + AcceptedBy: "quic_reverse_hello", + RemoteAddr: conn.RemoteAddr().String(), + PeerID: peerID, + }) +} + +type quicProductionForwardResponse struct { + Result ProductionForwardResult `json:"result,omitempty"` + Error string `json:"error,omitempty"` +} + +type quicSyntheticForwardResponse struct { + Envelope SyntheticEnvelope `json:"envelope,omitempty"` + Error string `json:"error,omitempty"` +} + +type quicWebIngressForwardResponse struct { + Payload json.RawMessage `json:"payload,omitempty"` + Error string `json:"error,omitempty"` +} + +type quicFabricControlForwardResponse struct { + Payload json.RawMessage `json:"payload,omitempty"` + Error string `json:"error,omitempty"` +} + +func (s *QUICFabricServer) handleProductionForwardFrame(ctx context.Context, stream *quic.Stream, frame fabricproto.Frame) bool { + if frame.Type != fabricproto.FrameData || frame.StreamID != ProductionForwardQUICStreamID { + return false + } + response := quicProductionForwardResponse{} + if s == nil || s.productionForwardHandler == nil { + response.Error = ErrForwardRuntimeUnavailable.Error() + } else { + var envelope ProductionEnvelope + if err := json.Unmarshal(frame.Payload, &envelope); err != nil { + response.Error = "invalid production mesh envelope" + } else if result, err := s.productionForwardHandler(ctx, envelope); err != nil { + response.Error = err.Error() + } else { + response.Result = result + } + } + payload, err := json.Marshal(response) + if err != nil { + return true + } + _ = fabricproto.WriteFrame(stream, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: fabricproto.TrafficClassReliable, + StreamID: ProductionForwardQUICStreamID, + Sequence: frame.Sequence, + Payload: payload, + }) + return true +} + +func (s *QUICFabricServer) handleWebIngressForwardFrame(ctx context.Context, stream *quic.Stream, frame fabricproto.Frame) bool { + if frame.Type != fabricproto.FrameData || frame.StreamID != WebIngressForwardQUICStreamID { + return false + } + response := quicWebIngressForwardResponse{} + if s == nil || s.webIngressForwardHandler == nil { + response.Error = ErrForwardRuntimeUnavailable.Error() + } else if payload, err := s.webIngressForwardHandler(ctx, append([]byte(nil), frame.Payload...)); err != nil { + response.Error = err.Error() + } else { + response.Payload = append(json.RawMessage(nil), payload...) + } + payload, err := json.Marshal(response) + if err != nil { + return true + } + _ = fabricproto.WriteFrame(stream, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: fabricproto.TrafficClassReliable, + StreamID: WebIngressForwardQUICStreamID, + Sequence: frame.Sequence, + Payload: payload, + }) + return true +} + +func (s *QUICFabricServer) handleFabricControlForwardFrame(ctx context.Context, stream *quic.Stream, frame fabricproto.Frame) bool { + if frame.Type != fabricproto.FrameData || frame.StreamID != FabricControlForwardQUICStreamID { + return false + } + response := quicFabricControlForwardResponse{} + if s == nil || s.fabricControlHandler == nil { + response.Error = ErrForwardRuntimeUnavailable.Error() + } else if payload, err := s.fabricControlHandler(ctx, append([]byte(nil), frame.Payload...)); err != nil { + response.Error = err.Error() + } else { + response.Payload = append(json.RawMessage(nil), payload...) + } + payload, err := json.Marshal(response) + if err != nil { + return true + } + _ = fabricproto.WriteFrame(stream, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: fabricproto.TrafficClassReliable, + StreamID: FabricControlForwardQUICStreamID, + Sequence: frame.Sequence, + Payload: payload, + }) + return true +} + +func (s *QUICFabricServer) handleSyntheticForwardFrame(ctx context.Context, conn *quic.Conn, stream *quic.Stream, frame fabricproto.Frame) bool { + if frame.Type != fabricproto.FrameData || frame.StreamID != SyntheticForwardQUICStreamID { + return false + } + response := quicSyntheticForwardResponse{} + if s == nil || s.syntheticForwardHandler == nil { + response.Error = ErrMeshRuntimeDisabled.Error() + } else { + var envelope SyntheticEnvelope + if err := json.Unmarshal(frame.Payload, &envelope); err != nil { + response.Error = "invalid synthetic mesh envelope" + } else if ack, err := s.syntheticForwardHandler(ctx, envelope); err != nil { + response.Error = err.Error() + } else { + s.registerReversePeerConn(envelope.From.NodeID, conn) + response.Envelope = ack + } + } + payload, err := json.Marshal(response) + if err != nil { + return true + } + _ = fabricproto.WriteFrame(stream, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: fabricproto.TrafficClassReliable, + StreamID: SyntheticForwardQUICStreamID, + Sequence: frame.Sequence, + Payload: payload, + }) + return true +} + +func (s *QUICFabricServer) registerReversePeerConn(peerID string, conn *quic.Conn) { + reverseTransport := s.getReverseTransport() + if s == nil || reverseTransport == nil || conn == nil { + return + } + reverseTransport.RegisterReverseConn(peerID, conn) +} + +func (s *QUICFabricServer) getReverseTransport() *QUICFabricTransport { + if s == nil { + return nil + } + s.reverseMu.RLock() + defer s.reverseMu.RUnlock() + return s.reverseTransport +} + func (s *QUICFabricServer) logFabricSession(entry FabricSessionEventLogEntry) { if s != nil && s.logger != nil { s.logger(entry) diff --git a/agents/rap-node-agent/internal/mesh/fabric_quic_transport.go b/agents/rap-node-agent/internal/mesh/fabric_quic_transport.go index 5a797db..bb8d8ee 100644 --- a/agents/rap-node-agent/internal/mesh/fabric_quic_transport.go +++ b/agents/rap-node-agent/internal/mesh/fabric_quic_transport.go @@ -6,7 +6,9 @@ import ( "crypto/tls" "crypto/x509" "encoding/hex" + "encoding/json" "fmt" + "net" "sort" "strings" "sync" @@ -17,6 +19,7 @@ import ( ) const fabricQUICNextProto = "rap-fabric-data-session-v1" +const fabricQUICReverseHelloPrefix = "rap-fabric-reverse-hello-v1:" const defaultQUICFabricConnIdleTTL = 5 * time.Minute const defaultQUICFabricMaxStreamsPerConn = 64 const ErrQUICFabricStreamLimitReached = quicFabricError("quic fabric stream limit reached") @@ -28,17 +31,29 @@ func (e quicFabricError) Error() string { } type QUICFabricTransport struct { - Config *quic.Config - IdleTTL time.Duration - MaxStreamsPerConn int - mu sync.Mutex - conns map[string]*quicFabricConnEntry - stats QUICFabricTransportStats + Config *quic.Config + LocalPeerID string + IdleTTL time.Duration + MaxStreamsPerConn int + DialAddr func(context.Context, string, *tls.Config, *quic.Config) (*quic.Conn, error) + mu sync.Mutex + conns map[string]*quicFabricConnEntry + reverseConns map[string]*quicFabricConnEntry + inboundProductionHandler func(context.Context, ProductionEnvelope) (ProductionForwardResult, error) + inboundWebIngressHandler func(context.Context, []byte) ([]byte, error) + inboundFabricControlHandler func(context.Context, []byte) ([]byte, error) + inboundSyntheticHandler func(context.Context, SyntheticEnvelope) (SyntheticEnvelope, error) + logger FabricSessionEventLogger + stats QUICFabricTransportStats } type QUICFabricTransportStats struct { Opens uint64 `json:"opens"` Reuses uint64 `json:"reuses"` + ReverseHelloSent uint64 `json:"reverse_hello_sent"` + ReverseHelloFailed uint64 `json:"reverse_hello_failed"` + ReverseRegisters uint64 `json:"reverse_registers"` + ReverseReuses uint64 `json:"reverse_reuses"` OpenFailures uint64 `json:"open_failures"` ClosedEvicted uint64 `json:"closed_evicted"` CloseAllCalls uint64 `json:"close_all_calls"` @@ -50,6 +65,7 @@ type QUICFabricTransportStats struct { type QUICFabricTransportSnapshot struct { SchemaVersion string `json:"schema_version"` + LocalPeerID string `json:"local_peer_id,omitempty"` ActiveCount int `json:"active_count"` ActiveStreams int `json:"active_streams"` MaxStreamsPerConn int `json:"max_streams_per_conn"` @@ -63,6 +79,7 @@ type QUICFabricConnSnapshot struct { PeerID string `json:"peer_id,omitempty"` Endpoint string `json:"endpoint,omitempty"` CertSHA256 string `json:"cert_sha256,omitempty"` + Direction string `json:"direction,omitempty"` ActiveStreams int `json:"active_streams"` MaxStreams int `json:"max_streams"` CapacityPressurePercent int `json:"capacity_pressure_percent"` @@ -92,7 +109,41 @@ type quicFabricConnEntry struct { } func NewQUICFabricTransport(config *quic.Config) *QUICFabricTransport { - return &QUICFabricTransport{Config: config, IdleTTL: defaultQUICFabricConnIdleTTL, MaxStreamsPerConn: defaultQUICFabricMaxStreamsPerConn, conns: map[string]*quicFabricConnEntry{}} + return &QUICFabricTransport{Config: config, IdleTTL: defaultQUICFabricConnIdleTTL, MaxStreamsPerConn: defaultQUICFabricMaxStreamsPerConn, conns: map[string]*quicFabricConnEntry{}, reverseConns: map[string]*quicFabricConnEntry{}} +} + +func (t *QUICFabricTransport) SetInboundHandlers(production func(context.Context, ProductionEnvelope) (ProductionForwardResult, error), synthetic func(context.Context, SyntheticEnvelope) (SyntheticEnvelope, error), logger FabricSessionEventLogger) { + t.SetInboundHandlersWithWebIngress(production, nil, synthetic, logger) +} + +func (t *QUICFabricTransport) SetInboundHandlersWithWebIngress(production func(context.Context, ProductionEnvelope) (ProductionForwardResult, error), webIngress func(context.Context, []byte) ([]byte, error), synthetic func(context.Context, SyntheticEnvelope) (SyntheticEnvelope, error), logger FabricSessionEventLogger) { + if t == nil { + return + } + t.mu.Lock() + t.inboundProductionHandler = production + t.inboundWebIngressHandler = webIngress + t.inboundSyntheticHandler = synthetic + t.logger = logger + t.mu.Unlock() +} + +func (t *QUICFabricTransport) SetInboundFabricControlHandler(handler func(context.Context, []byte) ([]byte, error)) { + if t == nil { + return + } + t.mu.Lock() + t.inboundFabricControlHandler = handler + t.mu.Unlock() +} + +func (t *QUICFabricTransport) SetLocalPeerID(peerID string) { + if t == nil { + return + } + t.mu.Lock() + t.LocalPeerID = strings.TrimSpace(peerID) + t.mu.Unlock() } func quicTLSConfigForTarget(target FabricTransportTarget) *tls.Config { @@ -186,9 +237,12 @@ func (t *QUICFabricTransport) connectConn(ctx context.Context, target FabricTran conn, err := quic.DialAddr(ctx, target.Endpoint, tlsConfig, nil) return conn, "", true, err } + if conn, key, ok := t.reverseConnForTarget(target); ok { + return conn, key, false, nil + } key := quicFabricConnKey(target) if key == "" { - conn, err := quic.DialAddr(ctx, target.Endpoint, tlsConfig, t.Config) + conn, err := t.dialAddr(ctx, target.Endpoint, tlsConfig) return conn, "", true, err } t.mu.Lock() @@ -207,7 +261,7 @@ func (t *QUICFabricTransport) connectConn(ctx context.Context, target FabricTran } t.mu.Unlock() - conn, err := quic.DialAddr(ctx, target.Endpoint, tlsConfig, t.Config) + conn, err := t.dialAddr(ctx, target.Endpoint, tlsConfig) if err != nil { t.mu.Lock() t.stats.OpenFailures++ @@ -235,16 +289,339 @@ func (t *QUICFabricTransport) connectConn(ctx context.Context, target FabricTran t.conns[key] = &quicFabricConnEntry{conn: conn, lastUsed: time.Now()} t.stats.Opens++ t.mu.Unlock() + go t.acceptInboundStreams(context.Background(), conn) + go t.sendReverseHello(context.Background(), conn) return conn, key, false, nil } +func (t *QUICFabricTransport) dialAddr(ctx context.Context, endpoint string, tlsConfig *tls.Config) (*quic.Conn, error) { + if t != nil && t.DialAddr != nil { + return t.DialAddr(ctx, endpoint, tlsConfig, t.Config) + } + return quic.DialAddr(ctx, endpoint, tlsConfig, t.Config) +} + +func DialQUICAddrWithPacketConn(ctx context.Context, endpoint string, packetConn net.PacketConn, tlsConfig *tls.Config, config *quic.Config) (*quic.Conn, error) { + if packetConn == nil { + return nil, fmt.Errorf("quic packet connection is required") + } + addr, err := net.ResolveUDPAddr("udp", strings.TrimPrefix(strings.TrimSpace(endpoint), "quic://")) + if err != nil { + _ = packetConn.Close() + return nil, err + } + transport := &quic.Transport{Conn: packetConn} + conn, err := transport.Dial(ctx, addr, tlsConfig, config) + if err != nil { + _ = transport.Close() + return nil, err + } + go func() { + <-conn.Context().Done() + _ = transport.Close() + }() + return conn, nil +} + +func (t *QUICFabricTransport) sendReverseHello(ctx context.Context, conn *quic.Conn) { + if t == nil || conn == nil { + return + } + localPeerID := t.localPeerID() + if localPeerID == "" { + t.mu.Lock() + t.stats.ReverseHelloFailed++ + t.mu.Unlock() + return + } + helloCtx, cancel := context.WithTimeout(ctx, 3*time.Second) + defer cancel() + stream, err := conn.OpenStreamSync(helloCtx) + if err != nil { + t.mu.Lock() + t.stats.ReverseHelloFailed++ + t.mu.Unlock() + return + } + defer func() { _ = stream.Close() }() + if err := fabricproto.WriteFrame(stream, fabricproto.Frame{ + Type: fabricproto.FramePing, + Sequence: 1, + Payload: []byte(fabricQUICReverseHelloPrefix + localPeerID), + }); err != nil { + t.mu.Lock() + t.stats.ReverseHelloFailed++ + t.mu.Unlock() + return + } + t.mu.Lock() + t.stats.ReverseHelloSent++ + t.mu.Unlock() + _, _ = fabricproto.ReadFrame(stream, fabricproto.DefaultMaxPayload) +} + +func (t *QUICFabricTransport) acceptInboundStreams(ctx context.Context, conn *quic.Conn) { + if t == nil || conn == nil { + return + } + for { + stream, err := conn.AcceptStream(ctx) + if err != nil { + return + } + go t.handleInboundStream(ctx, conn, stream) + } +} + +func (t *QUICFabricTransport) handleInboundStream(ctx context.Context, conn *quic.Conn, stream *quic.Stream) { + session := fabricproto.NewSession(fabricproto.SessionConfig{}) + defer func() { _ = stream.Close() }() + t.logFabricSession(FabricSessionEventLogEntry{ + Event: "fabric_session_quic_reverse_stream_opened", + AcceptedBy: "quic_reverse", + RemoteAddr: conn.RemoteAddr().String(), + }) + defer t.logFabricSession(FabricSessionEventLogEntry{ + Event: "fabric_session_quic_reverse_stream_closed", + AcceptedBy: "quic_reverse", + RemoteAddr: conn.RemoteAddr().String(), + }) + for { + select { + case <-ctx.Done(): + _ = stream.Close() + return + default: + } + frame, err := fabricproto.ReadFrame(stream, fabricproto.DefaultMaxPayload) + if err != nil { + return + } + t.registerReverseHelloFrame(conn, frame) + if t.handleInboundProductionForwardFrame(ctx, stream, frame) { + continue + } + if t.handleInboundWebIngressForwardFrame(ctx, stream, frame) { + continue + } + if t.handleInboundFabricControlForwardFrame(ctx, stream, frame) { + continue + } + if t.handleInboundSyntheticForwardFrame(ctx, stream, frame) { + continue + } + event, responses, err := session.HandleFrame(frame) + if err != nil { + _ = stream.Close() + return + } + if event.Type != fabricproto.SessionEventNone { + t.logFabricSession(FabricSessionEventLogEntry{ + Event: "fabric_session_reverse_event", + SessionEvent: event.Type, + StreamID: event.StreamID, + Sequence: event.Sequence, + TrafficClass: event.TrafficClass, + AcceptedBy: "quic_reverse", + RemoteAddr: conn.RemoteAddr().String(), + }) + } + for _, response := range responses { + if err := fabricproto.WriteFrame(stream, response); err != nil { + return + } + } + } +} + +func (t *QUICFabricTransport) registerReverseHelloFrame(conn *quic.Conn, frame fabricproto.Frame) { + if t == nil || conn == nil || frame.Type != fabricproto.FramePing { + return + } + payload := string(frame.Payload) + if !strings.HasPrefix(payload, fabricQUICReverseHelloPrefix) { + return + } + peerID := strings.TrimPrefix(payload, fabricQUICReverseHelloPrefix) + t.RegisterReverseConn(peerID, conn) + t.logFabricSession(FabricSessionEventLogEntry{ + Event: "fabric_session_quic_reverse_registered", + AcceptedBy: "quic_reverse_hello", + RemoteAddr: conn.RemoteAddr().String(), + PeerID: peerID, + }) +} + +func (t *QUICFabricTransport) handleInboundProductionForwardFrame(ctx context.Context, stream *quic.Stream, frame fabricproto.Frame) bool { + if frame.Type != fabricproto.FrameData || frame.StreamID != ProductionForwardQUICStreamID { + return false + } + response := quicProductionForwardResponse{} + productionHandler, _, _, _, _ := t.inboundHandlers() + if productionHandler == nil { + response.Error = ErrForwardRuntimeUnavailable.Error() + } else { + var envelope ProductionEnvelope + if err := json.Unmarshal(frame.Payload, &envelope); err != nil { + response.Error = "invalid production mesh envelope" + } else if result, err := productionHandler(ctx, envelope); err != nil { + response.Error = err.Error() + } else { + response.Result = result + } + } + payload, err := json.Marshal(response) + if err == nil { + _ = fabricproto.WriteFrame(stream, fabricproto.Frame{Type: fabricproto.FrameData, TrafficClass: fabricproto.TrafficClassReliable, StreamID: ProductionForwardQUICStreamID, Sequence: frame.Sequence, Payload: payload}) + } + return true +} + +func (t *QUICFabricTransport) handleInboundWebIngressForwardFrame(ctx context.Context, stream *quic.Stream, frame fabricproto.Frame) bool { + if frame.Type != fabricproto.FrameData || frame.StreamID != WebIngressForwardQUICStreamID { + return false + } + response := quicWebIngressForwardResponse{} + _, webIngressHandler, _, _, _ := t.inboundHandlers() + if webIngressHandler == nil { + response.Error = ErrForwardRuntimeUnavailable.Error() + } else if payload, err := webIngressHandler(ctx, append([]byte(nil), frame.Payload...)); err != nil { + response.Error = err.Error() + } else { + response.Payload = append(json.RawMessage(nil), payload...) + } + payload, err := json.Marshal(response) + if err == nil { + _ = fabricproto.WriteFrame(stream, fabricproto.Frame{Type: fabricproto.FrameData, TrafficClass: fabricproto.TrafficClassReliable, StreamID: WebIngressForwardQUICStreamID, Sequence: frame.Sequence, Payload: payload}) + } + return true +} + +func (t *QUICFabricTransport) handleInboundFabricControlForwardFrame(ctx context.Context, stream *quic.Stream, frame fabricproto.Frame) bool { + if frame.Type != fabricproto.FrameData || frame.StreamID != FabricControlForwardQUICStreamID { + return false + } + response := quicFabricControlForwardResponse{} + _, _, fabricControlHandler, _, _ := t.inboundHandlers() + if fabricControlHandler == nil { + response.Error = ErrForwardRuntimeUnavailable.Error() + } else if payload, err := fabricControlHandler(ctx, append([]byte(nil), frame.Payload...)); err != nil { + response.Error = err.Error() + } else { + response.Payload = append(json.RawMessage(nil), payload...) + } + payload, err := json.Marshal(response) + if err == nil { + _ = fabricproto.WriteFrame(stream, fabricproto.Frame{Type: fabricproto.FrameData, TrafficClass: fabricproto.TrafficClassReliable, StreamID: FabricControlForwardQUICStreamID, Sequence: frame.Sequence, Payload: payload}) + } + return true +} + +func (t *QUICFabricTransport) handleInboundSyntheticForwardFrame(ctx context.Context, stream *quic.Stream, frame fabricproto.Frame) bool { + if frame.Type != fabricproto.FrameData || frame.StreamID != SyntheticForwardQUICStreamID { + return false + } + response := quicSyntheticForwardResponse{} + _, _, _, syntheticHandler, _ := t.inboundHandlers() + if syntheticHandler == nil { + response.Error = ErrMeshRuntimeDisabled.Error() + } else { + var envelope SyntheticEnvelope + if err := json.Unmarshal(frame.Payload, &envelope); err != nil { + response.Error = "invalid synthetic mesh envelope" + } else if ack, err := syntheticHandler(ctx, envelope); err != nil { + response.Error = err.Error() + } else { + response.Envelope = ack + } + } + payload, err := json.Marshal(response) + if err == nil { + _ = fabricproto.WriteFrame(stream, fabricproto.Frame{Type: fabricproto.FrameData, TrafficClass: fabricproto.TrafficClassReliable, StreamID: SyntheticForwardQUICStreamID, Sequence: frame.Sequence, Payload: payload}) + } + return true +} + +func (t *QUICFabricTransport) inboundHandlers() (func(context.Context, ProductionEnvelope) (ProductionForwardResult, error), func(context.Context, []byte) ([]byte, error), func(context.Context, []byte) ([]byte, error), func(context.Context, SyntheticEnvelope) (SyntheticEnvelope, error), FabricSessionEventLogger) { + if t == nil { + return nil, nil, nil, nil, nil + } + t.mu.Lock() + defer t.mu.Unlock() + return t.inboundProductionHandler, t.inboundWebIngressHandler, t.inboundFabricControlHandler, t.inboundSyntheticHandler, t.logger +} + +func (t *QUICFabricTransport) localPeerID() string { + if t == nil { + return "" + } + t.mu.Lock() + defer t.mu.Unlock() + return strings.TrimSpace(t.LocalPeerID) +} + +func (t *QUICFabricTransport) logFabricSession(entry FabricSessionEventLogEntry) { + _, _, _, _, logger := t.inboundHandlers() + if logger != nil { + logger(entry) + } +} + +func (t *QUICFabricTransport) RegisterReverseConn(peerID string, conn *quic.Conn) { + if t == nil || conn == nil { + return + } + peerID = strings.TrimSpace(peerID) + if peerID == "" { + return + } + t.mu.Lock() + defer t.mu.Unlock() + if t.reverseConns == nil { + t.reverseConns = map[string]*quicFabricConnEntry{} + } + if existing := t.reverseConns[peerID]; existing != nil && existing.conn != nil && existing.conn != conn { + select { + case <-existing.conn.Context().Done(): + default: + _ = existing.conn.CloseWithError(0, "reverse connection replaced") + } + } + t.reverseConns[peerID] = &quicFabricConnEntry{conn: conn, lastUsed: time.Now()} + t.stats.ReverseRegisters++ +} + +func (t *QUICFabricTransport) reverseConnForTarget(target FabricTransportTarget) (*quic.Conn, string, bool) { + peerID := strings.TrimSpace(target.PeerID) + if t == nil || peerID == "" || !fabricTransportPrefersReverseConn(target.Transport) { + return nil, "", false + } + t.mu.Lock() + defer t.mu.Unlock() + t.pruneIdleLocked(time.Now()) + entry := t.reverseConns[peerID] + if entry == nil || entry.conn == nil { + return nil, "", false + } + select { + case <-entry.conn.Context().Done(): + delete(t.reverseConns, peerID) + t.stats.ClosedEvicted++ + return nil, "", false + default: + entry.lastUsed = time.Now() + t.stats.ReverseReuses++ + return entry.conn, quicFabricReverseConnKey(peerID), true + } +} + func (t *QUICFabricTransport) reserveStream(key string, conn *quic.Conn) error { if t == nil || key == "" { return nil } t.mu.Lock() defer t.mu.Unlock() - entry := t.conns[key] + entry := t.connEntryLocked(key) if entry == nil || entry.conn != conn { return fmt.Errorf("quic fabric connection is not cached") } @@ -267,16 +644,26 @@ func (t *QUICFabricTransport) releaseStream(key string) { return } t.mu.Lock() - if entry := t.conns[key]; entry != nil { + if entry := t.connEntryLocked(key); entry != nil { if entry.activeStreams > 0 { entry.activeStreams-- } entry.lastUsed = time.Now() - t.stats.StreamCloses++ } + t.stats.StreamCloses++ t.mu.Unlock() } +func (t *QUICFabricTransport) connEntryLocked(key string) *quicFabricConnEntry { + if t == nil || key == "" { + return nil + } + if strings.HasPrefix(key, "reverse\x00") { + return t.reverseConns[strings.TrimPrefix(key, "reverse\x00")] + } + return t.conns[key] +} + func (t *QUICFabricTransport) evictConn(target FabricTransportTarget, conn *quic.Conn) { if t == nil || conn == nil { return @@ -315,6 +702,20 @@ func (t *QUICFabricTransport) pruneIdleLocked(now time.Time) { t.stats.IdleEvicted++ } } + for peerID, entry := range t.reverseConns { + if entry == nil || entry.conn == nil { + delete(t.reverseConns, peerID) + continue + } + if !entry.lastUsed.IsZero() && now.Sub(entry.lastUsed) > ttl { + if entry.activeStreams > 0 { + continue + } + _ = entry.conn.CloseWithError(0, "idle reverse") + delete(t.reverseConns, peerID) + t.stats.IdleEvicted++ + } + } } func quicFabricConnKey(target FabricTransportTarget) string { @@ -340,6 +741,23 @@ func parseQUICFabricConnKey(key string) (peerID string, endpoint string, certSHA return peerID, endpoint, certSHA256 } +func quicFabricReverseConnKey(peerID string) string { + peerID = strings.TrimSpace(peerID) + if peerID == "" { + return "" + } + return "reverse\x00" + peerID +} + +func fabricTransportPrefersReverseConn(transport string) bool { + switch strings.ToLower(strings.TrimSpace(transport)) { + case "reverse_quic", "relay_quic": + return true + default: + return false + } +} + func (t *QUICFabricTransport) Close() error { if t == nil { return nil @@ -348,12 +766,19 @@ func (t *QUICFabricTransport) Close() error { t.stats.CloseAllCalls++ conns := t.conns t.conns = map[string]*quicFabricConnEntry{} + reverseConns := t.reverseConns + t.reverseConns = map[string]*quicFabricConnEntry{} t.mu.Unlock() for _, entry := range conns { if entry != nil && entry.conn != nil { _ = entry.conn.CloseWithError(0, "closed") } } + for _, entry := range reverseConns { + if entry != nil && entry.conn != nil { + _ = entry.conn.CloseWithError(0, "closed") + } + } return nil } @@ -370,6 +795,7 @@ func (t *QUICFabricTransport) Snapshot() QUICFabricTransportSnapshot { } snapshot := QUICFabricTransportSnapshot{ SchemaVersion: "rap.quic_fabric_transport.v1", + LocalPeerID: strings.TrimSpace(t.LocalPeerID), MaxStreamsPerConn: limit, Stats: t.stats, } @@ -391,6 +817,40 @@ func (t *QUICFabricTransport) Snapshot() QUICFabricTransportSnapshot { PeerID: peerID, Endpoint: endpoint, CertSHA256: certSHA256, + Direction: "outbound", + ActiveStreams: entry.activeStreams, + MaxStreams: limit, + Saturated: entry.activeStreams >= limit, + } + if !entry.lastUsed.IsZero() { + connSnapshot.LastUsedUnixSec = entry.lastUsed.UTC().Unix() + } + if limit > 0 { + connSnapshot.CapacityPressurePercent = (entry.activeStreams * 100) / limit + } + snapshot.Connections = append(snapshot.Connections, connSnapshot) + if entry.activeStreams >= limit { + snapshot.SaturatedConnections++ + } + } + } + for peerID, entry := range t.reverseConns { + if entry == nil || entry.conn == nil { + delete(t.reverseConns, peerID) + continue + } + select { + case <-entry.conn.Context().Done(): + delete(t.reverseConns, peerID) + t.stats.ClosedEvicted++ + snapshot.Stats.ClosedEvicted++ + default: + snapshot.ActiveCount++ + snapshot.ActiveStreams += entry.activeStreams + connSnapshot := QUICFabricConnSnapshot{ + PeerID: peerID, + Endpoint: entry.conn.RemoteAddr().String(), + Direction: "reverse", ActiveStreams: entry.activeStreams, MaxStreams: limit, Saturated: entry.activeStreams >= limit, @@ -462,6 +922,7 @@ func (s *quicFabricSession) Close() error { s.closeOnce.Do(func() { close(s.done) if s.stream != nil { + s.stream.CancelRead(0) err = s.stream.Close() } if s.transport != 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 b6b2a11..d23bade 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 @@ -9,6 +9,7 @@ import ( "crypto/x509" "crypto/x509/pkix" "encoding/hex" + "encoding/json" "encoding/pem" "math/big" "strings" @@ -341,6 +342,119 @@ func TestQUICFabricTransportLimitsStreamsPerConnection(t *testing.T) { defer second.Close() } +func TestQUICFabricTransportReusesInboundConnectionForReverseStream(t *testing.T) { + reverseTransport := NewQUICFabricTransport(nil) + defer reverseTransport.Close() + server, err := StartQUICFabricServer(context.Background(), QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: testQUICTLSConfig(t), + ReverseTransport: reverseTransport, + SyntheticForwardHandler: func(_ context.Context, envelope SyntheticEnvelope) (SyntheticEnvelope, error) { + envelope.To, envelope.From = envelope.From, PeerIdentity{ClusterID: envelope.ClusterID, NodeID: "node-r"} + return envelope, nil + }, + }) + if err != nil { + t.Fatalf("start quic fabric server: %v", err) + } + defer server.Close() + + clientTransport := NewQUICFabricTransport(nil) + defer clientTransport.Close() + clientTransport.SetLocalPeerID("node-a") + clientTransport.SetInboundHandlers(func(_ context.Context, envelope ProductionEnvelope) (ProductionForwardResult, error) { + return ProductionForwardResult{ + Accepted: true, + Delivered: true, + Forwarded: true, + By: PeerIdentity{ClusterID: envelope.ClusterID, NodeID: "node-a"}, + MessageID: envelope.MessageID, + RouteID: envelope.RouteID, + }, nil + }, nil, nil) + + ctx, cancel := context.WithTimeout(context.Background(), 3*time.Second) + defer cancel() + session, err := clientTransport.Connect(ctx, FabricTransportTarget{ + PeerID: "node-r", + Endpoint: server.Addr().String(), + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + NextProtos: []string{fabricQUICNextProto}, + }, + Timeout: time.Second, + InboundBuffer: 4, + ErrorBuffer: 4, + }) + if err != nil { + t.Fatalf("client connect: %v", err) + } + defer session.Close() + deadline := time.Now().Add(time.Second) + for { + if reverseTransport.Snapshot().Stats.ReverseRegisters > 0 { + break + } + if time.Now().After(deadline) { + t.Fatalf("reverse hello did not register connection: %+v", reverseTransport.Snapshot()) + } + time.Sleep(10 * time.Millisecond) + } + + reverseSession, err := reverseTransport.Connect(ctx, FabricTransportTarget{ + PeerID: "node-a", + Endpoint: "10.0.0.2:19443", + Transport: "relay_quic", + Timeout: time.Second, + InboundBuffer: 4, + ErrorBuffer: 4, + }) + if err != nil { + t.Fatalf("reverse connect: %v", err) + } + defer reverseSession.Close() + productionPayload, err := json.Marshal(ProductionEnvelope{ + FabricProtocolVersion: ProtocolVersion, + MessageID: "msg-1", + RouteID: "route-r-a", + ClusterID: "cluster-1", + SourceNodeID: "node-r", + DestinationNodeID: "node-a", + CurrentHopNodeID: "node-a", + NextHopNodeID: "node-a", + ChannelClass: ProductionChannelFabricControl, + MessageType: ProductionMessageFabricControl, + TTL: 4, + CreatedAt: time.Now().UTC(), + ExpiresAt: time.Now().UTC().Add(time.Minute), + PayloadHash: "unused-by-test-handler", + }) + if err != nil { + t.Fatalf("marshal production: %v", err) + } + if err := reverseSession.Send(ctx, fabricproto.Frame{Type: fabricproto.FrameData, TrafficClass: fabricproto.TrafficClassReliable, StreamID: ProductionForwardQUICStreamID, Sequence: 2, Payload: productionPayload}); err != nil { + t.Fatalf("send reverse production: %v", err) + } + select { + case frame := <-reverseSession.Frames(): + var response quicProductionForwardResponse + if err := json.Unmarshal(frame.Payload, &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if !response.Result.Accepted || !response.Result.Delivered || response.Result.By.NodeID != "node-a" { + t.Fatalf("response = %+v", response) + } + case err := <-reverseSession.Errors(): + t.Fatalf("reverse session error: %v", err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + snapshot := reverseTransport.Snapshot() + if snapshot.Stats.ReverseRegisters == 0 || snapshot.Stats.ReverseReuses == 0 { + t.Fatalf("reverse connection was not registered/reused: %+v", snapshot) + } +} + func TestQUICFabricServerHandlesFabricFrames(t *testing.T) { var events []FabricSessionEventLogEntry server, err := StartQUICFabricServer(context.Background(), QUICFabricServerConfig{ @@ -389,6 +503,68 @@ func TestQUICFabricServerHandlesFabricFrames(t *testing.T) { } } +func TestQUICFabricServerHandlesWebIngressForwardFrames(t *testing.T) { + var received []byte + server, err := StartQUICFabricServer(context.Background(), QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: testQUICTLSConfig(t), + WebIngressForwardHandler: func(_ context.Context, payload []byte) ([]byte, error) { + received = append([]byte(nil), payload...) + return []byte(`{"schema_version":"rap.web_ingress.fabric_runtime_response.v1","status_code":200,"body_b64":"b2s="}`), 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() + session, err := NewQUICFabricTransport(nil).Connect(ctx, FabricTransportTarget{ + Endpoint: server.Addr().String(), + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + NextProtos: []string{fabricQUICNextProto}, + }, + Timeout: time.Second, + InboundBuffer: 4, + ErrorBuffer: 4, + }) + if err != nil { + t.Fatalf("connect quic fabric: %v", err) + } + defer session.Close() + if err := session.Send(ctx, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: fabricproto.TrafficClassReliable, + StreamID: WebIngressForwardQUICStreamID, + Sequence: 44, + Payload: []byte(`{"envelope":true}`), + }); err != nil { + t.Fatalf("send web ingress frame: %v", err) + } + select { + case frame := <-session.Frames(): + if frame.Type != fabricproto.FrameData || frame.StreamID != WebIngressForwardQUICStreamID || frame.Sequence != 44 { + t.Fatalf("frame = %+v", frame) + } + var response quicWebIngressForwardResponse + if err := json.Unmarshal(frame.Payload, &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if string(response.Payload) != `{"schema_version":"rap.web_ingress.fabric_runtime_response.v1","status_code":200,"body_b64":"b2s="}` || response.Error != "" { + t.Fatalf("response = %+v", response) + } + case err := <-session.Errors(): + t.Fatalf("session error: %v", err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } + if string(received) != `{"envelope":true}` { + t.Fatalf("received = %s", string(received)) + } +} + 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_health.go b/agents/rap-node-agent/internal/mesh/fabric_route_health.go new file mode 100644 index 0000000..6c30bf1 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_route_health.go @@ -0,0 +1,128 @@ +package mesh + +import ( + "strings" + "sync" + "time" +) + +type FabricRouteHealthTracker struct { + mu sync.Mutex + QuarantineTTL time.Duration + routes map[string]FabricRouteHealthEntry +} + +type FabricRouteHealthEntry struct { + Reason string `json:"reason,omitempty"` + Failures uint64 `json:"failures"` + LastFailure time.Time `json:"last_failure,omitempty"` + RetryAfter time.Time `json:"retry_after,omitempty"` +} + +type FabricRouteHealthSnapshot struct { + Quarantined map[string]FabricRouteHealthEntry `json:"quarantined,omitempty"` +} + +func NewFabricRouteHealthTracker(ttl time.Duration) *FabricRouteHealthTracker { + if ttl <= 0 { + ttl = 30 * time.Second + } + return &FabricRouteHealthTracker{QuarantineTTL: ttl, routes: map[string]FabricRouteHealthEntry{}} +} + +func (t *FabricRouteHealthTracker) MarkFailure(routeID string, reason string, now time.Time) { + routeID = strings.TrimSpace(routeID) + if t == nil || routeID == "" { + return + } + if now.IsZero() { + now = time.Now().UTC() + } + ttl := t.QuarantineTTL + if ttl <= 0 { + ttl = 30 * time.Second + } + t.mu.Lock() + entry := t.routes[routeID] + entry.Failures++ + entry.Reason = strings.TrimSpace(reason) + entry.LastFailure = now + entry.RetryAfter = now.Add(ttl) + if t.routes == nil { + t.routes = map[string]FabricRouteHealthEntry{} + } + t.routes[routeID] = entry + t.mu.Unlock() +} + +func (t *FabricRouteHealthTracker) MarkSuccess(routeID string) { + routeID = strings.TrimSpace(routeID) + if t == nil || routeID == "" { + return + } + t.mu.Lock() + delete(t.routes, routeID) + t.mu.Unlock() +} + +func (t *FabricRouteHealthTracker) Apply(routeSet FabricRouteSet, now time.Time) FabricRouteSet { + if t == nil { + return routeSet + } + if now.IsZero() { + now = time.Now().UTC() + } + t.mu.Lock() + defer t.mu.Unlock() + if len(t.routes) == 0 { + return routeSet + } + return mapFabricRouteSet(routeSet, func(route FabricRoute) FabricRoute { + entry, ok := t.routes[route.RouteID] + if !ok { + return route + } + if !entry.RetryAfter.IsZero() && !now.Before(entry.RetryAfter) { + delete(t.routes, route.RouteID) + return route + } + route.Healthy = false + route.Degraded = true + return route + }) +} + +func (t *FabricRouteHealthTracker) Snapshot(now time.Time) FabricRouteHealthSnapshot { + if t == nil { + return FabricRouteHealthSnapshot{} + } + if now.IsZero() { + now = time.Now().UTC() + } + t.mu.Lock() + defer t.mu.Unlock() + out := map[string]FabricRouteHealthEntry{} + for routeID, entry := range t.routes { + if !entry.RetryAfter.IsZero() && !now.Before(entry.RetryAfter) { + continue + } + out[routeID] = entry + } + if len(out) == 0 { + return FabricRouteHealthSnapshot{} + } + return FabricRouteHealthSnapshot{Quarantined: out} +} + +func mapFabricRouteSet(routeSet FabricRouteSet, fn func(FabricRoute) FabricRoute) FabricRouteSet { + if strings.TrimSpace(routeSet.Primary.RouteID) != "" { + routeSet.Primary = fn(routeSet.Primary) + } + for i := range routeSet.WarmStandby { + routeSet.WarmStandby[i] = fn(routeSet.WarmStandby[i]) + } + for i := range routeSet.ColdFallbacks { + routeSet.ColdFallbacks[i] = fn(routeSet.ColdFallbacks[i]) + } + return routeSet +} diff --git a/agents/rap-node-agent/internal/mesh/fabric_route_planner.go b/agents/rap-node-agent/internal/mesh/fabric_route_planner.go new file mode 100644 index 0000000..6c99461 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_route_planner.go @@ -0,0 +1,322 @@ +package mesh + +import ( + "encoding/json" + "fmt" + "strings" + "time" +) + +const ( + FabricCandidateReachabilityPublic = "public" + FabricCandidateReachabilityPrivate = "private" + FabricCandidateReachabilityRelay = "relay" + FabricCandidateReachabilityOutboundOnly = "outbound_only" + + FabricConnectivityDirect = "direct" + FabricConnectivityOutboundOnly = "outbound_only" + FabricConnectivityRelayRequired = "relay_required" +) + +type FabricRoutePlannerConfig struct { + ClusterID string + LocalNodeID string + LocalSegmentID string + LocalNATGroupID string + DefaultCapacity int + RelayCapacity int + ReverseCapacity int + Observations map[string]EndpointCandidateHealthObservation + CapacityPressure map[string]EndpointCandidateCapacityPressure + Now time.Time + MaxObservationAge time.Duration + MaxCapacityPressureAge time.Duration +} + +type FabricCandidateMetadata struct { + LocalSegmentID string `json:"local_segment_id,omitempty"` + NATGroupID string `json:"nat_group_id,omitempty"` + RelayNodeID string `json:"relay_node_id,omitempty"` + RelayEndpoint string `json:"relay_endpoint,omitempty"` + ViaNodeID string `json:"via_node_id,omitempty"` + STUNServer string `json:"stun_server,omitempty"` + ICEFoundation string `json:"ice_foundation,omitempty"` +} + +func FabricRouteSetForPeerEndpointCandidates(targetNodeID string, candidates []PeerEndpointCandidate, cfg FabricRoutePlannerConfig) FabricRouteSet { + targetNodeID = strings.TrimSpace(targetNodeID) + if targetNodeID == "" && len(candidates) > 0 { + targetNodeID = strings.TrimSpace(candidates[0].NodeID) + } + routeSet := FabricRouteSet{TargetKind: FabricChannelTargetNode, TargetID: targetNodeID} + if len(candidates) == 0 { + return routeSet + } + now := cfg.Now + if now.IsZero() { + now = time.Now().UTC() + } + ranked := RankPeerEndpointCandidates(candidates, EndpointCandidateScoreOptions{ + Now: now, + Observations: cfg.Observations, + MaxObservationAge: firstNonZeroDuration(cfg.MaxObservationAge, 30*time.Second), + CapacityPressure: cfg.CapacityPressure, + MaxCapacityPressureAge: firstNonZeroDuration(cfg.MaxCapacityPressureAge, 10*time.Second), + }) + routes := make([]FabricRoute, 0, len(ranked)) + for index, scored := range ranked { + route, ok := fabricRouteForPeerEndpointCandidate(scored.Candidate, cfg, scored.Score, index, now) + if ok { + routes = append(routes, route) + } + } + return routeSetFromRoutes(routeSet, routes) +} + +func FabricRouteSetsForPeerEndpointCandidates(candidatesByNode map[string][]PeerEndpointCandidate, cfg FabricRoutePlannerConfig) map[string]FabricRouteSet { + out := make(map[string]FabricRouteSet, len(candidatesByNode)) + for nodeID, candidates := range candidatesByNode { + nodeID = strings.TrimSpace(nodeID) + if nodeID == "" { + continue + } + routeSet := FabricRouteSetForPeerEndpointCandidates(nodeID, candidates, cfg) + if strings.TrimSpace(routeSet.Primary.RouteID) != "" || len(routeSet.WarmStandby) > 0 || len(routeSet.ColdFallbacks) > 0 { + out[nodeID] = routeSet + } + } + return out +} + +func fabricRouteForPeerEndpointCandidate(candidate PeerEndpointCandidate, cfg FabricRoutePlannerConfig, score int, index int, now time.Time) (FabricRoute, bool) { + candidate.EndpointID = strings.TrimSpace(candidate.EndpointID) + candidate.NodeID = strings.TrimSpace(candidate.NodeID) + candidate.Address = strings.TrimRight(strings.TrimSpace(candidate.Address), "/") + if candidate.EndpointID == "" || candidate.NodeID == "" || candidate.Address == "" || !isQUICOnlyCandidateTransport(candidate.Transport) { + return FabricRoute{}, false + } + metadata := decodeFabricCandidateMetadata(candidate.Metadata) + mode := fabricRouteModeForPeerEndpointCandidate(candidate, metadata, cfg) + hops := fabricRouteHopsForCandidate(candidate, metadata, mode, cfg) + if len(hops) == 0 { + return FabricRoute{}, false + } + relayCount := 0 + for _, hop := range hops { + if hop.Mode == FabricRouteRelay { + relayCount++ + } + } + latency := fabricRouteLatencyFromCandidate(candidate, cfg, score, index) + capacity := fabricRouteCapacityForMode(mode, cfg) + if capacity <= 0 { + capacity = 100 + } + healthy := true + degraded := false + if observation, ok := cfg.Observations[candidate.EndpointID]; ok { + healthy = observation.ReliabilityScore == 0 || observation.ReliabilityScore >= 50 + degraded = observation.LastLatencyMs > 0 && observation.LastLatencyMs >= 250 + } + return FabricRoute{ + RouteID: candidate.EndpointID, + ClusterID: strings.TrimSpace(cfg.ClusterID), + SourceNodeID: strings.TrimSpace(cfg.LocalNodeID), + DestinationNodeID: candidate.NodeID, + Hops: hops, + BaseLatencyMs: latency, + Capacity: capacity, + ActiveChannels: int(candidatePressureCount(candidate.EndpointID, cfg)), + RelayCount: relayCount, + Healthy: healthy, + Degraded: degraded, + LastUpdatedAt: now, + }, true +} + +func fabricRouteModeForPeerEndpointCandidate(candidate PeerEndpointCandidate, metadata FabricCandidateMetadata, cfg FabricRoutePlannerConfig) FabricRouteMode { + transportMode := fabricRouteModeForTransportTarget(FabricTransportTarget{Transport: candidate.Transport}) + if transportMode == FabricRouteRelay || transportMode == FabricRouteReverse || transportMode == FabricRouteICE || transportMode == FabricRouteLAN { + return transportMode + } + reachability := strings.ToLower(strings.TrimSpace(candidate.Reachability)) + connectivity := strings.ToLower(strings.TrimSpace(candidate.ConnectivityMode)) + if sameLocalSegment(metadata, cfg) || sameNATGroup(metadata, cfg) { + return FabricRouteLAN + } + if reachability == FabricCandidateReachabilityRelay || connectivity == FabricConnectivityRelayRequired || strings.TrimSpace(metadata.RelayEndpoint) != "" { + return FabricRouteRelay + } + if connectivity == FabricConnectivityOutboundOnly || reachability == FabricCandidateReachabilityOutboundOnly { + return FabricRouteReverse + } + if strings.TrimSpace(metadata.STUNServer) != "" || strings.TrimSpace(metadata.ICEFoundation) != "" || candidate.NATType != "" { + return FabricRouteICE + } + return FabricRouteDirect +} + +func fabricRouteHopsForCandidate(candidate PeerEndpointCandidate, metadata FabricCandidateMetadata, mode FabricRouteMode, cfg FabricRoutePlannerConfig) []FabricRouteHop { + localNodeID := strings.TrimSpace(cfg.LocalNodeID) + targetNodeID := strings.TrimSpace(candidate.NodeID) + endpoint := strings.TrimRight(strings.TrimSpace(candidate.Address), "/") + switch mode { + case FabricRouteRelay: + relayNodeID := firstNonEmpty(strings.TrimSpace(metadata.RelayNodeID), strings.TrimSpace(metadata.ViaNodeID)) + relayEndpoint := firstNonEmpty(strings.TrimRight(strings.TrimSpace(metadata.RelayEndpoint), "/"), endpoint) + hops := []FabricRouteHop{} + if localNodeID != "" { + hops = append(hops, FabricRouteHop{NodeID: localNodeID, Mode: FabricRouteDirect}) + } + if relayNodeID == "" { + hops = append(hops, FabricRouteHop{NodeID: targetNodeID, Mode: FabricRouteRelay, EndpointID: candidate.EndpointID, Address: endpoint, PeerCertSHA256: candidatePeerCertSHA256(candidate)}) + return hops + } + hops = append(hops, + FabricRouteHop{NodeID: relayNodeID, Mode: FabricRouteRelay, EndpointID: candidate.EndpointID + ":relay", Address: relayEndpoint}, + FabricRouteHop{NodeID: targetNodeID, Mode: FabricRouteRelay, EndpointID: candidate.EndpointID, Address: endpoint, PeerCertSHA256: candidatePeerCertSHA256(candidate)}, + ) + return hops + case FabricRouteLAN, FabricRouteICE, FabricRouteReverse, FabricRouteDirect: + hops := []FabricRouteHop{} + if localNodeID != "" { + hops = append(hops, FabricRouteHop{NodeID: localNodeID, Mode: mode}) + } + hops = append(hops, FabricRouteHop{NodeID: targetNodeID, Mode: mode, EndpointID: candidate.EndpointID, Address: endpoint, PeerCertSHA256: candidatePeerCertSHA256(candidate)}) + return hops + default: + return nil + } +} + +func isQUICOnlyCandidateTransport(transport string) bool { + switch strings.ToLower(strings.TrimSpace(transport)) { + case "quic", "direct_quic", "udp_quic", "quic_udp", + string(FabricRouteLAN), string(FabricRouteReverse), string(FabricRouteRelay), string(FabricRouteICE): + return true + default: + return false + } +} + +func fabricRouteLatencyFromCandidate(candidate PeerEndpointCandidate, cfg FabricRoutePlannerConfig, score int, index int) int { + if observation, ok := cfg.Observations[candidate.EndpointID]; ok && observation.LastLatencyMs > 0 { + if observation.LastLatencyMs > int64(^uint(0)>>1) { + return int(^uint(0) >> 1) + } + return int(observation.LastLatencyMs) + } + base := 10 + index + switch strings.ToLower(strings.TrimSpace(candidate.Reachability)) { + case FabricCandidateReachabilityPrivate: + base = 3 + index + case FabricCandidateReachabilityOutboundOnly: + base = 25 + index + case FabricCandidateReachabilityRelay: + base = 40 + index + } + if score < 100 { + base += (100 - score) / 10 + } + return base +} + +func fabricRouteCapacityForMode(mode FabricRouteMode, cfg FabricRoutePlannerConfig) int { + switch mode { + case FabricRouteRelay: + return firstPositiveInt(cfg.RelayCapacity, cfg.DefaultCapacity, 100) + case FabricRouteReverse: + return firstPositiveInt(cfg.ReverseCapacity, cfg.DefaultCapacity, 100) + default: + return firstPositiveInt(cfg.DefaultCapacity, 100) + } +} + +func candidatePressureCount(endpointID string, cfg FabricRoutePlannerConfig) int64 { + if pressure, ok := cfg.CapacityPressure[endpointID]; ok { + return pressure.Count + } + return 0 +} + +func sameLocalSegment(metadata FabricCandidateMetadata, cfg FabricRoutePlannerConfig) bool { + localSegment := strings.TrimSpace(cfg.LocalSegmentID) + if localSegment == "" { + return false + } + return strings.EqualFold(strings.TrimSpace(metadata.LocalSegmentID), localSegment) +} + +func sameNATGroup(metadata FabricCandidateMetadata, cfg FabricRoutePlannerConfig) bool { + localNATGroup := strings.TrimSpace(cfg.LocalNATGroupID) + if localNATGroup == "" { + return false + } + return strings.EqualFold(strings.TrimSpace(metadata.NATGroupID), localNATGroup) +} + +func decodeFabricCandidateMetadata(raw json.RawMessage) FabricCandidateMetadata { + if len(raw) == 0 { + return FabricCandidateMetadata{} + } + var metadata FabricCandidateMetadata + if err := json.Unmarshal(raw, &metadata); err != nil { + return FabricCandidateMetadata{} + } + return metadata +} + +func candidatePeerCertSHA256(candidate PeerEndpointCandidate) string { + var metadata struct { + PeerCertSHA256 string `json:"peer_cert_sha256,omitempty"` + TLSCertSHA256 string `json:"tls_cert_sha256,omitempty"` + } + if len(candidate.Metadata) == 0 { + return "" + } + if err := json.Unmarshal(candidate.Metadata, &metadata); err != nil { + return "" + } + return firstNonEmpty(strings.TrimSpace(metadata.PeerCertSHA256), strings.TrimSpace(metadata.TLSCertSHA256)) +} + +func firstPositiveInt(values ...int) int { + for _, value := range values { + if value > 0 { + return value + } + } + return 0 +} + +func firstNonZeroDuration(values ...time.Duration) time.Duration { + for _, value := range values { + if value > 0 { + return value + } + } + return 0 +} + +func FabricRouteSetForRelayFallback(clusterID string, sourceNodeID string, targetNodeID string, relayNodeID string, relayEndpoint string, targetEndpoint string) FabricRouteSet { + relayEndpoint = strings.TrimRight(strings.TrimSpace(relayEndpoint), "/") + targetEndpoint = strings.TrimRight(strings.TrimSpace(targetEndpoint), "/") + candidate := PeerEndpointCandidate{ + EndpointID: fmt.Sprintf("%s-via-%s-relay", strings.TrimSpace(targetNodeID), strings.TrimSpace(relayNodeID)), + NodeID: strings.TrimSpace(targetNodeID), + Transport: string(FabricRouteRelay), + Address: targetEndpoint, + Reachability: FabricCandidateReachabilityRelay, + ConnectivityMode: FabricConnectivityRelayRequired, + Metadata: mustMarshalFabricCandidateMetadata(FabricCandidateMetadata{RelayNodeID: relayNodeID, RelayEndpoint: relayEndpoint}), + } + return FabricRouteSetForPeerEndpointCandidates(targetNodeID, []PeerEndpointCandidate{candidate}, FabricRoutePlannerConfig{ + ClusterID: clusterID, + LocalNodeID: sourceNodeID, + }) +} + +func mustMarshalFabricCandidateMetadata(metadata FabricCandidateMetadata) json.RawMessage { + raw, _ := json.Marshal(metadata) + return raw +} 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 new file mode 100644 index 0000000..d72b6ac --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_route_planner_test.go @@ -0,0 +1,187 @@ +package mesh + +import ( + "encoding/json" + "testing" + "time" +) + +func TestFabricRouteSetForPeerEndpointCandidatesPrefersLocalLAN(t *testing.T) { + metadata, _ := json.Marshal(FabricCandidateMetadata{LocalSegmentID: "site-a", NATGroupID: "nat-a"}) + routeSet := FabricRouteSetForPeerEndpointCandidates("node-b", []PeerEndpointCandidate{ + { + EndpointID: "node-b-public", + NodeID: "node-b", + Transport: "quic", + Address: "quic://203.0.113.10:19443", + Reachability: "public", + ConnectivityMode: "direct", + Priority: 10, + }, + { + EndpointID: "node-b-lan", + NodeID: "node-b", + Transport: "quic", + Address: "quic://10.10.0.12:19443", + Reachability: "private", + ConnectivityMode: "direct", + PolicyTags: []string{"private-lan"}, + Metadata: metadata, + }, + }, FabricRoutePlannerConfig{ + ClusterID: "cluster-1", + LocalNodeID: "node-a", + LocalSegmentID: "site-a", + DefaultCapacity: 200, + Now: time.Unix(100, 0).UTC(), + }) + if routeSet.Primary.RouteID != "node-b-lan" { + t.Fatalf("primary route = %q, want node-b-lan", routeSet.Primary.RouteID) + } + if routeSet.Primary.Hops[len(routeSet.Primary.Hops)-1].Mode != FabricRouteLAN { + t.Fatalf("primary mode = %q, want lan", routeSet.Primary.Hops[len(routeSet.Primary.Hops)-1].Mode) + } +} + +func TestFabricRouteSetForPeerEndpointCandidatesBuildsRelayFallback(t *testing.T) { + metadata, _ := json.Marshal(FabricCandidateMetadata{RelayNodeID: "node-r", RelayEndpoint: "quic://node-r:19443"}) + routeSet := FabricRouteSetForPeerEndpointCandidates("node-b", []PeerEndpointCandidate{{ + EndpointID: "node-b-relay", + NodeID: "node-b", + Transport: "quic", + Address: "quic://node-b-passive:19443", + Reachability: "outbound_only", + ConnectivityMode: "relay_required", + NATType: "symmetric", + Metadata: metadata, + }}, FabricRoutePlannerConfig{ + ClusterID: "cluster-1", + LocalNodeID: "node-a", + RelayCapacity: 50, + Now: time.Unix(100, 0).UTC(), + }) + if routeSet.Primary.RouteID != "node-b-relay" { + t.Fatalf("primary route = %q", routeSet.Primary.RouteID) + } + if routeSet.Primary.RelayCount != 2 { + t.Fatalf("relay count = %d, want 2", routeSet.Primary.RelayCount) + } + if got := routeSet.Primary.Hops[1].NodeID; got != "node-r" { + t.Fatalf("relay hop = %q, want node-r", got) + } + if routeSet.Primary.Capacity != 50 { + t.Fatalf("capacity = %d, want 50", routeSet.Primary.Capacity) + } +} + +func TestFabricRouteSetForPeerEndpointCandidatesUsesTargetWhenRelayMetadataIsAbsent(t *testing.T) { + routeSet := FabricRouteSetForPeerEndpointCandidates("node-b", []PeerEndpointCandidate{{ + EndpointID: "node-b-relay", + NodeID: "node-b", + Transport: "relay_quic", + Address: "quic://node-b:19443", + Reachability: "relay", + ConnectivityMode: "relay_required", + Metadata: json.RawMessage(`{"tls_cert_sha256":"abc123"}`), + }}, FabricRoutePlannerConfig{ClusterID: "cluster-1", LocalNodeID: "node-a"}) + if routeSet.Primary.RouteID != "node-b-relay" { + t.Fatalf("primary route = %q", routeSet.Primary.RouteID) + } + if len(routeSet.Primary.Hops) != 2 { + t.Fatalf("hops = %+v, want local + target only", routeSet.Primary.Hops) + } + targetHop := routeSet.Primary.Hops[1] + if targetHop.NodeID != "node-b" || targetHop.Mode != FabricRouteRelay || targetHop.PeerCertSHA256 != "abc123" { + t.Fatalf("target hop = %+v, want relay-mode target with cert", targetHop) + } +} + +func TestFabricRouteSetForPeerEndpointCandidatesAcceptsExplicitQUICModes(t *testing.T) { + for _, tc := range []struct { + name string + transport string + wantMode FabricRouteMode + }{ + {name: "lan", transport: "lan_quic", wantMode: FabricRouteLAN}, + {name: "reverse", transport: "reverse_quic", wantMode: FabricRouteReverse}, + {name: "relay", transport: "relay_quic", wantMode: FabricRouteRelay}, + {name: "ice", transport: "ice_quic", wantMode: FabricRouteICE}, + } { + t.Run(tc.name, func(t *testing.T) { + routeSet := FabricRouteSetForPeerEndpointCandidates("node-b", []PeerEndpointCandidate{{ + EndpointID: "node-b-" + tc.name, + NodeID: "node-b", + Transport: tc.transport, + Address: "quic://node-b:19443", + Reachability: "private", + ConnectivityMode: "direct", + Metadata: json.RawMessage(`{"tls_cert_sha256":"abc123"}`), + }}, FabricRoutePlannerConfig{ClusterID: "cluster-1", LocalNodeID: "node-a"}) + if routeSet.Primary.RouteID == "" { + t.Fatalf("%s candidate produced empty route set", tc.transport) + } + hop := routeSet.Primary.Hops[len(routeSet.Primary.Hops)-1] + if hop.Mode != tc.wantMode { + t.Fatalf("mode = %q, want %q", hop.Mode, tc.wantMode) + } + if hop.PeerCertSHA256 != "abc123" { + t.Fatalf("peer cert = %q, want abc123", hop.PeerCertSHA256) + } + }) + } +} + +func TestFabricRouteSetForPeerEndpointCandidatesTreatsSameNATGroupAsLAN(t *testing.T) { + metadata, _ := json.Marshal(FabricCandidateMetadata{NATGroupID: "nat-a"}) + routeSet := FabricRouteSetForPeerEndpointCandidates("node-b", []PeerEndpointCandidate{{ + EndpointID: "node-b-nat-lan", + NodeID: "node-b", + Transport: "quic", + Address: "quic://10.44.0.12:19443", + Reachability: "private", + ConnectivityMode: "direct", + NATType: "symmetric", + Metadata: metadata, + }}, FabricRoutePlannerConfig{ + ClusterID: "cluster-1", + LocalNodeID: "node-a", + LocalNATGroupID: "nat-a", + }) + if routeSet.Primary.Hops[len(routeSet.Primary.Hops)-1].Mode != FabricRouteLAN { + t.Fatalf("route = %+v, want LAN mode for same NAT group", routeSet.Primary) + } +} + +func TestFabricRouteSetForPeerEndpointCandidatesRejectsNonQUIC(t *testing.T) { + for _, candidate := range []PeerEndpointCandidate{ + { + EndpointID: "node-b-http", + NodeID: "node-b", + Transport: "direct_http", + Address: "http://node-b:8080", + Reachability: "public", + ConnectivityMode: "direct", + }, + { + EndpointID: "node-b-legacy-relay", + NodeID: "node-b", + Transport: "relay", + Address: "quic://node-r:19443", + Reachability: "relay", + ConnectivityMode: "relay_required", + }, + { + EndpointID: "node-b-legacy-reverse", + NodeID: "node-b", + Transport: "outbound_reverse", + Address: "quic://node-b:19443", + Reachability: "outbound_only", + ConnectivityMode: "outbound_only", + }, + } { + routeSet := FabricRouteSetForPeerEndpointCandidates("node-b", []PeerEndpointCandidate{candidate}, FabricRoutePlannerConfig{ClusterID: "cluster-1", LocalNodeID: "node-a"}) + if routeSet.Primary.RouteID != "" || len(routeSet.WarmStandby) != 0 { + t.Fatalf("non-quic candidate produced route set: %+v", routeSet) + } + } +} diff --git a/agents/rap-node-agent/internal/mesh/fabric_route_pressure.go b/agents/rap-node-agent/internal/mesh/fabric_route_pressure.go new file mode 100644 index 0000000..f8a14ac --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_route_pressure.go @@ -0,0 +1,137 @@ +package mesh + +import ( + "strings" + "sync" + "sync/atomic" +) + +type FabricRoutePressureTracker struct { + mu sync.Mutex + active map[string]int + maxActive map[string]int + acquiredTotal uint64 + releasedTotal uint64 + maxActiveTotal int + lastAcquiredRoute string + lastReleasedRoute string +} + +type FabricRoutePressureSnapshot struct { + Active map[string]int `json:"active"` + MaxActive map[string]int `json:"max_active"` + ActiveTotal int `json:"active_total"` + MaxActiveTotal int `json:"max_active_total"` + AcquiredTotal uint64 `json:"acquired_total"` + ReleasedTotal uint64 `json:"released_total"` + LastAcquiredRoute string `json:"last_acquired_route,omitempty"` + LastReleasedRoute string `json:"last_released_route,omitempty"` +} + +func NewFabricRoutePressureTracker() *FabricRoutePressureTracker { + return &FabricRoutePressureTracker{ + active: map[string]int{}, + maxActive: map[string]int{}, + } +} + +func (t *FabricRoutePressureTracker) Apply(routeSet FabricRouteSet) FabricRouteSet { + if t == nil { + return routeSet + } + active := t.Snapshot() + if len(active) == 0 { + return routeSet + } + apply := func(route FabricRoute) FabricRoute { + if count := active[route.RouteID]; count > 0 { + route.ActiveChannels += count + } + return route + } + routeSet.Primary = apply(routeSet.Primary) + for i := range routeSet.WarmStandby { + routeSet.WarmStandby[i] = apply(routeSet.WarmStandby[i]) + } + for i := range routeSet.ColdFallbacks { + routeSet.ColdFallbacks[i] = apply(routeSet.ColdFallbacks[i]) + } + return routeSet +} + +func (t *FabricRoutePressureTracker) Acquire(routeID string) func() { + routeID = strings.TrimSpace(routeID) + if t == nil || routeID == "" { + return func() {} + } + t.mu.Lock() + if t.active == nil { + t.active = map[string]int{} + } + if t.maxActive == nil { + t.maxActive = map[string]int{} + } + t.active[routeID]++ + if t.active[routeID] > t.maxActive[routeID] { + t.maxActive[routeID] = t.active[routeID] + } + t.acquiredTotal++ + t.lastAcquiredRoute = routeID + if activeTotal := activeTotalLocked(t.active); activeTotal > t.maxActiveTotal { + t.maxActiveTotal = activeTotal + } + t.mu.Unlock() + var released atomic.Bool + return func() { + if released.Swap(true) { + return + } + t.mu.Lock() + if t.active[routeID] <= 1 { + delete(t.active, routeID) + } else { + t.active[routeID]-- + } + t.releasedTotal++ + t.lastReleasedRoute = routeID + t.mu.Unlock() + } +} + +func (t *FabricRoutePressureTracker) Snapshot() map[string]int { + return t.SnapshotPressure().Active +} + +func (t *FabricRoutePressureTracker) SnapshotPressure() FabricRoutePressureSnapshot { + if t == nil { + return FabricRoutePressureSnapshot{} + } + t.mu.Lock() + defer t.mu.Unlock() + active := make(map[string]int, len(t.active)) + for routeID, count := range t.active { + active[routeID] = count + } + maxActive := make(map[string]int, len(t.maxActive)) + for routeID, count := range t.maxActive { + maxActive[routeID] = count + } + return FabricRoutePressureSnapshot{ + Active: active, + MaxActive: maxActive, + ActiveTotal: activeTotalLocked(active), + MaxActiveTotal: t.maxActiveTotal, + AcquiredTotal: t.acquiredTotal, + ReleasedTotal: t.releasedTotal, + LastAcquiredRoute: t.lastAcquiredRoute, + LastReleasedRoute: t.lastReleasedRoute, + } +} + +func activeTotalLocked(active map[string]int) int { + total := 0 + for _, count := range active { + total += count + } + return total +} diff --git a/agents/rap-node-agent/internal/mesh/fabric_route_pressure_test.go b/agents/rap-node-agent/internal/mesh/fabric_route_pressure_test.go new file mode 100644 index 0000000..5a9447a --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/fabric_route_pressure_test.go @@ -0,0 +1,44 @@ +package mesh + +import "testing" + +func TestFabricRoutePressureTrackerAppliesAndReleasesActiveChannels(t *testing.T) { + tracker := NewFabricRoutePressureTracker() + releaseA := tracker.Acquire("route-a") + releaseAAgain := tracker.Acquire("route-a") + releaseB := tracker.Acquire("route-b") + routeSet := FabricRouteSet{ + TargetKind: FabricChannelTargetNode, + TargetID: "node-b", + Primary: testFabricRoute("route-a", "node-b", 10, 100, 3, true), + WarmStandby: []FabricRoute{ + testFabricRoute("route-b", "node-b", 10, 100, 0, true), + }, + } + + withPressure := tracker.Apply(routeSet) + if withPressure.Primary.ActiveChannels != 5 { + t.Fatalf("primary active = %d, want 5", withPressure.Primary.ActiveChannels) + } + if withPressure.WarmStandby[0].ActiveChannels != 1 { + t.Fatalf("standby active = %d, want 1", withPressure.WarmStandby[0].ActiveChannels) + } + + releaseA() + releaseA() + releaseAAgain() + releaseB() + snapshot := tracker.SnapshotPressure() + if len(snapshot.Active) != 0 || snapshot.ActiveTotal != 0 { + t.Fatalf("snapshot after release = %+v, want inactive", snapshot) + } + if snapshot.AcquiredTotal != 3 || snapshot.ReleasedTotal != 3 { + t.Fatalf("snapshot totals = %+v, want acquired/released 3", snapshot) + } + if snapshot.MaxActive["route-a"] != 2 || snapshot.MaxActive["route-b"] != 1 || snapshot.MaxActiveTotal != 3 { + t.Fatalf("snapshot max = %+v", snapshot) + } + if snapshot.LastAcquiredRoute != "route-b" || snapshot.LastReleasedRoute != "route-b" { + t.Fatalf("snapshot last routes = %+v", snapshot) + } +} 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 index 0ec399e..903abfa 100644 --- a/agents/rap-node-agent/internal/mesh/fabric_session_manager_test.go +++ b/agents/rap-node-agent/internal/mesh/fabric_session_manager_test.go @@ -12,8 +12,9 @@ import ( func TestFabricSessionPeerManagerReusesPeerPump(t *testing.T) { var opened int server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, + FabricSessionEnabled: true, + FabricSessionWebSocketEnabled: true, FabricSessionLogger: func(entry FabricSessionEventLogEntry) { if entry.Event == "fabric_session_websocket_opened" { opened++ @@ -83,8 +84,9 @@ func TestFabricSessionPeerManagerReusesPeerPump(t *testing.T) { func TestFabricSessionPeerManagerClosePeerReopens(t *testing.T) { var opened int server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, + FabricSessionEnabled: true, + FabricSessionWebSocketEnabled: true, FabricSessionLogger: func(entry FabricSessionEventLogEntry) { if entry.Event == "fabric_session_websocket_opened" { opened++ @@ -131,8 +133,9 @@ func TestFabricSessionPeerManagerClosePeerReopens(t *testing.T) { func TestFabricSessionPeerManagerReopensClosedPump(t *testing.T) { var opened int server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, + FabricSessionEnabled: true, + FabricSessionWebSocketEnabled: true, FabricSessionLogger: func(entry FabricSessionEventLogEntry) { if entry.Event == "fabric_session_websocket_opened" { opened++ diff --git a/agents/rap-node-agent/internal/mesh/fabric_transport.go b/agents/rap-node-agent/internal/mesh/fabric_transport.go index e64db1a..28e315b 100644 --- a/agents/rap-node-agent/internal/mesh/fabric_transport.go +++ b/agents/rap-node-agent/internal/mesh/fabric_transport.go @@ -40,73 +40,22 @@ type FabricTransportTarget struct { ErrorBuffer int } -func FabricTransportForTarget(target FabricTransportTarget, websocket *WebSocketFabricTransport, quicTransport *QUICFabricTransport) (FabricTransport, FabricTransportTarget, error) { +func FabricTransportForTarget(target FabricTransportTarget, quicTransport *QUICFabricTransport) (FabricTransport, FabricTransportTarget, error) { transportLabel := strings.ToLower(strings.TrimSpace(target.Transport)) endpoint := strings.TrimSpace(target.Endpoint) if strings.HasPrefix(strings.ToLower(endpoint), "quic://") { - transportLabel = "quic" + if transportLabel == "" { + transportLabel = "quic" + } target.Endpoint = strings.TrimPrefix(endpoint, "quic://") } switch transportLabel { - case "quic", "direct_quic", "udp_quic", "quic_udp": + case "quic", "direct_quic", "udp_quic", "quic_udp", "lan_quic", "reverse_quic", "relay_quic", "ice_quic": if quicTransport == nil { quicTransport = NewQUICFabricTransport(nil) } return quicTransport, target, nil - case "", "websocket", "ws", "wss", "direct_http", "direct_https", "direct_tcp_tls": - if websocket == nil { - websocket = NewWebSocketFabricTransport(nil) - } - return websocket, target, nil default: - return nil, target, fmt.Errorf("unsupported fabric transport %q", target.Transport) + return nil, target, fmt.Errorf("unsupported fabric transport %q: quic is required", target.Transport) } } - -type WebSocketFabricTransport struct { - Manager *FabricSessionPeerManager -} - -func NewWebSocketFabricTransport(manager *FabricSessionPeerManager) *WebSocketFabricTransport { - if manager == nil { - manager = NewFabricSessionPeerManager() - } - return &WebSocketFabricTransport{Manager: manager} -} - -func (t *WebSocketFabricTransport) Connect(ctx context.Context, target FabricTransportTarget) (FabricTransportSession, error) { - manager := t.Manager - if manager == nil { - manager = NewFabricSessionPeerManager() - t.Manager = manager - } - return manager.Get(ctx, FabricSessionPeerTarget{ - PeerID: target.PeerID, - BaseURL: target.Endpoint, - Options: FabricSessionDialOptions{ - Token: target.Token, - Header: target.Header, - Timeout: target.Timeout, - MaxPayload: target.MaxPayload, - }, - Pump: FabricSessionPumpOptions{ - OutboundBuffer: target.OutboundBuffer, - InboundBuffer: target.InboundBuffer, - ErrorBuffer: target.ErrorBuffer, - }, - }) -} - -func (t *WebSocketFabricTransport) Close() error { - if t == nil || t.Manager == nil { - return nil - } - return t.Manager.Close() -} - -func (t *WebSocketFabricTransport) Snapshot() FabricSessionPeerManagerSnapshot { - if t == nil || t.Manager == nil { - return FabricSessionPeerManagerSnapshot{SchemaVersion: "rap.fabric_session_peer_manager.v1"} - } - return t.Manager.Snapshot() -} diff --git a/agents/rap-node-agent/internal/mesh/fabric_transport_test.go b/agents/rap-node-agent/internal/mesh/fabric_transport_test.go index 7510c52..7ead52e 100644 --- a/agents/rap-node-agent/internal/mesh/fabric_transport_test.go +++ b/agents/rap-node-agent/internal/mesh/fabric_transport_test.go @@ -1,117 +1,27 @@ package mesh import ( - "context" - "net/http/httptest" + "strings" "testing" - "time" - - "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" ) -func TestWebSocketFabricTransportConnectsAndReusesSession(t *testing.T) { - var opened int - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionLogger: func(entry FabricSessionEventLogEntry) { - if entry.Event == "fabric_session_websocket_opened" { - opened++ - } - }, - }.Handler()) - defer server.Close() - - transport := NewWebSocketFabricTransport(nil) - defer transport.Close() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - target := FabricTransportTarget{ - PeerID: "node-a", - Endpoint: server.URL, - Token: "rap_fsn_transport", - Timeout: time.Second, - OutboundBuffer: 4, - InboundBuffer: 4, - ErrorBuffer: 4, - } - - first, err := transport.Connect(ctx, target) - if err != nil { - t.Fatalf("first connect: %v", err) - } - second, err := transport.Connect(ctx, target) - if err != nil { - t.Fatalf("second connect: %v", err) - } - if first != second { - t.Fatal("transport did not reuse session") - } - if opened != 1 { - t.Fatalf("opened = %d, want 1", opened) - } - if err := first.Send(ctx, fabricproto.Frame{Type: fabricproto.FramePing, Sequence: 1, Payload: []byte("transport")}); err != nil { - t.Fatalf("send ping: %v", err) - } - select { - case frame := <-first.Frames(): - if frame.Type != fabricproto.FramePong || frame.Sequence != 1 || string(frame.Payload) != "transport" { - t.Fatalf("frame = %+v", frame) +func TestFabricTransportRejectsWebSocketTransport(t *testing.T) { + for _, target := range []FabricTransportTarget{ + {Transport: "wss", Endpoint: "wss://node-a.example/fabric/session"}, + {Transport: "relay", Endpoint: "quic://node-r.example:19443"}, + {Transport: "outbound_reverse", Endpoint: "quic://node-b.example:19443"}, + } { + _, _, err := FabricTransportForTarget(target, nil) + if err == nil || !strings.Contains(err.Error(), "quic is required") { + t.Fatalf("target = %+v err = %v, want quic-only rejection", target, err) } - case err := <-first.Errors(): - t.Fatalf("session error: %v", err) - case <-ctx.Done(): - t.Fatal(ctx.Err()) - } -} - -func TestWebSocketFabricTransportReopensClosedSession(t *testing.T) { - var opened int - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - FabricSessionLogger: func(entry FabricSessionEventLogEntry) { - if entry.Event == "fabric_session_websocket_opened" { - opened++ - } - }, - }.Handler()) - defer server.Close() - - transport := NewWebSocketFabricTransport(nil) - defer transport.Close() - ctx, cancel := context.WithTimeout(context.Background(), 2*time.Second) - defer cancel() - target := FabricTransportTarget{ - PeerID: "node-a", - Endpoint: server.URL, - Token: "rap_fsn_transport_reopen", - Timeout: time.Second, - } - - first, err := transport.Connect(ctx, target) - if err != nil { - t.Fatalf("first connect: %v", err) - } - if err := first.Close(); err != nil { - t.Fatalf("close first session: %v", err) - } - second, err := transport.Connect(ctx, target) - if err != nil { - t.Fatalf("second connect: %v", err) - } - if first == second { - t.Fatal("transport reused closed session") - } - if opened != 2 { - t.Fatalf("opened = %d, want 2", opened) } } func TestFabricTransportForTargetSelectsQUICByScheme(t *testing.T) { transport, target, err := FabricTransportForTarget(FabricTransportTarget{ Endpoint: "quic://127.0.0.1:4433", - }, nil, nil) + }, nil) if err != nil { t.Fatalf("select transport: %v", err) } @@ -123,15 +33,12 @@ func TestFabricTransportForTargetSelectsQUICByScheme(t *testing.T) { } } -func TestFabricTransportForTargetSelectsWebSocketByDefault(t *testing.T) { - transport, target, err := FabricTransportForTarget(FabricTransportTarget{ +func TestFabricTransportForTargetRejectsNonQUICByDefault(t *testing.T) { + _, target, err := FabricTransportForTarget(FabricTransportTarget{ Endpoint: "https://node.example", - }, nil, nil) - if err != nil { - t.Fatalf("select transport: %v", err) - } - if _, ok := transport.(*WebSocketFabricTransport); !ok { - t.Fatalf("transport = %T, want websocket", transport) + }, nil) + if err == nil { + t.Fatal("non-QUIC target unexpectedly selected a transport") } if target.Endpoint != "https://node.example" { t.Fatalf("endpoint = %q", target.Endpoint) diff --git a/agents/rap-node-agent/internal/mesh/http_transport.go b/agents/rap-node-agent/internal/mesh/http_transport.go deleted file mode 100644 index 19d7aa1..0000000 --- a/agents/rap-node-agent/internal/mesh/http_transport.go +++ /dev/null @@ -1,42 +0,0 @@ -package mesh - -import ( - "context" - "net/http" - "strings" -) - -// HTTPPeerTransport sends synthetic mesh envelopes to explicitly configured -// peer endpoints. It is intentionally narrow: production forwarding remains -// disabled and only SyntheticRuntime messages use this transport. -type HTTPPeerTransport struct { - PeerURLs map[string]string - HTTPClient *http.Client -} - -func NewHTTPPeerTransport(peerURLs map[string]string) *HTTPPeerTransport { - normalized := make(map[string]string, len(peerURLs)) - for nodeID, baseURL := range peerURLs { - nodeID = strings.TrimSpace(nodeID) - baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") - if nodeID != "" && baseURL != "" { - normalized[nodeID] = baseURL - } - } - return &HTTPPeerTransport{PeerURLs: normalized} -} - -func (t *HTTPPeerTransport) SendSynthetic(ctx context.Context, nextNodeID string, envelope SyntheticEnvelope) (SyntheticEnvelope, error) { - if t == nil { - return SyntheticEnvelope{}, ErrSyntheticPeerUnavailable - } - baseURL := strings.TrimRight(strings.TrimSpace(t.PeerURLs[nextNodeID]), "/") - if baseURL == "" { - return SyntheticEnvelope{}, ErrSyntheticPeerUnavailable - } - client := NewClient(baseURL) - if t.HTTPClient != nil { - client.HTTPClient = t.HTTPClient - } - return client.SendSynthetic(ctx, envelope) -} diff --git a/agents/rap-node-agent/internal/mesh/http_transport_test.go b/agents/rap-node-agent/internal/mesh/http_transport_test.go deleted file mode 100644 index b8a7107..0000000 --- a/agents/rap-node-agent/internal/mesh/http_transport_test.go +++ /dev/null @@ -1,130 +0,0 @@ -package mesh - -import ( - "context" - "errors" - "net/http" - "net/http/httptest" - "testing" - "time" -) - -func TestHTTPPeerTransportDirectSyntheticProbe(t *testing.T) { - nodeA := newLiveSyntheticNode(t, PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}) - defer nodeA.Close() - nodeB := newLiveSyntheticNode(t, PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"}) - defer nodeB.Close() - - route := liveSyntheticRoute("route-direct", []string{"node-a", "node-b"}) - routes := []SyntheticRoute{route} - nodeA.Runtime = newLiveRuntime(nodeA.Local, routes, map[string]string{"node-b": nodeB.URL}) - nodeB.Runtime = newLiveRuntime(nodeB.Local, routes, map[string]string{}) - - ack, err := nodeA.Runtime.SendProbe(context.Background(), route.RouteID, SyntheticChannelFabricControl, "probe-live-direct") - if err != nil { - t.Fatalf("send live direct probe: %v", err) - } - if ack.MessageType != SyntheticMessageProbeAck { - t.Fatalf("MessageType = %q, want %q", ack.MessageType, SyntheticMessageProbeAck) - } - payload := decodeAckPayload(t, ack) - if got, want := payload.Path, []string{"node-a", "node-b"}; !sameStrings(got, want) { - t.Fatalf("path = %v, want %v", got, want) - } -} - -func TestHTTPPeerTransportSingleRelaySyntheticProbe(t *testing.T) { - nodeA := newLiveSyntheticNode(t, PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}) - defer nodeA.Close() - nodeR := newLiveSyntheticNode(t, PeerIdentity{ClusterID: "cluster-1", NodeID: "node-r"}) - defer nodeR.Close() - nodeB := newLiveSyntheticNode(t, PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"}) - defer nodeB.Close() - - route := liveSyntheticRoute("route-relay", []string{"node-a", "node-r", "node-b"}) - routes := []SyntheticRoute{route} - nodeA.Runtime = newLiveRuntime(nodeA.Local, routes, map[string]string{"node-r": nodeR.URL}) - nodeR.Runtime = newLiveRuntime(nodeR.Local, routes, map[string]string{"node-b": nodeB.URL}) - nodeB.Runtime = newLiveRuntime(nodeB.Local, routes, map[string]string{}) - - ack, err := nodeA.Runtime.SendProbe(context.Background(), route.RouteID, SyntheticChannelFabricControl, "probe-live-relay") - if err != nil { - t.Fatalf("send live relay probe: %v", err) - } - if ack.MessageType != SyntheticMessageProbeAck { - t.Fatalf("MessageType = %q, want %q", ack.MessageType, SyntheticMessageProbeAck) - } - payload := decodeAckPayload(t, ack) - if got, want := payload.Path, []string{"node-a", "node-r", "node-b"}; !sameStrings(got, want) { - t.Fatalf("path = %v, want %v", got, want) - } -} - -func TestHTTPPeerTransportMissingPeer(t *testing.T) { - transport := NewHTTPPeerTransport(map[string]string{}) - _, err := transport.SendSynthetic(context.Background(), "node-missing", SyntheticEnvelope{}) - if !errors.Is(err, ErrSyntheticPeerUnavailable) { - t.Fatalf("err = %v, want ErrSyntheticPeerUnavailable", err) - } -} - -type liveSyntheticNode struct { - Local PeerIdentity - Runtime *SyntheticRuntime - URL string - server *httptest.Server -} - -func newLiveSyntheticNode(t *testing.T, local PeerIdentity) *liveSyntheticNode { - t.Helper() - node := &liveSyntheticNode{Local: local} - node.server = httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { - Server{Local: node.Local, SyntheticRuntime: node.Runtime}.Handler().ServeHTTP(w, r) - })) - node.URL = node.server.URL - return node -} - -func (n *liveSyntheticNode) Close() { - if n.server != nil { - n.server.Close() - } -} - -func newLiveRuntime(local PeerIdentity, routes []SyntheticRoute, peers map[string]string) *SyntheticRuntime { - return NewSyntheticRuntime(SyntheticRuntimeConfig{ - Enabled: true, - Local: local, - Routes: routes, - Transport: NewHTTPPeerTransport(peers), - }) -} - -func liveSyntheticRoute(routeID string, hops []string) SyntheticRoute { - return SyntheticRoute{ - RouteID: routeID, - ClusterID: "cluster-1", - SourceNodeID: hops[0], - DestinationNodeID: hops[len(hops)-1], - Hops: hops, - AllowedChannels: []string{SyntheticChannelFabricControl}, - MaxTTL: 8, - MaxHops: 8, - ExpiresAt: time.Now().UTC().Add(time.Hour), - RouteVersion: "route-v1", - PolicyVersion: "policy-v1", - PeerDirectoryVersion: "peers-v1", - } -} - -func sameStrings(left, right []string) bool { - if len(left) != len(right) { - return false - } - for i := range left { - if left[i] != right[i] { - return false - } - } - return true -} diff --git a/agents/rap-node-agent/internal/mesh/peer_cache.go b/agents/rap-node-agent/internal/mesh/peer_cache.go index 5919b1b..54e97a7 100644 --- a/agents/rap-node-agent/internal/mesh/peer_cache.go +++ b/agents/rap-node-agent/internal/mesh/peer_cache.go @@ -1,6 +1,7 @@ package mesh import ( + "encoding/json" "sort" "strings" "time" @@ -53,9 +54,11 @@ type PeerCacheEntry struct { BestReachability string `json:"best_reachability,omitempty"` BestConnectivity string `json:"best_connectivity,omitempty"` BestNATType string `json:"best_nat_type,omitempty"` + BestRegion string `json:"best_region,omitempty"` BestPolicyTags []string `json:"best_policy_tags,omitempty"` BestCandidateScore int `json:"best_candidate_score,omitempty"` BestScoreReasons []string `json:"best_score_reasons,omitempty"` + BestPeerCertSHA256 string `json:"best_peer_cert_sha256,omitempty"` EndpointCandidates []PeerEndpointCandidate `json:"endpoint_candidates,omitempty"` RendezvousLeaseID string `json:"rendezvous_lease_id,omitempty"` RelayNodeID string `json:"relay_node_id,omitempty"` @@ -132,9 +135,11 @@ func NewPeerCache(cfg PeerCacheConfig) *PeerCache { entry.BestReachability = scored[0].Candidate.Reachability entry.BestConnectivity = scored[0].Candidate.ConnectivityMode entry.BestNATType = scored[0].Candidate.NATType + entry.BestRegion = scored[0].Candidate.Region entry.BestPolicyTags = append([]string{}, scored[0].Candidate.PolicyTags...) entry.BestCandidateScore = scored[0].Score entry.BestScoreReasons = append([]string{}, scored[0].Reasons...) + entry.BestPeerCertSHA256 = candidatePeerCertSHA256(scored[0].Candidate) entry.bestScore = scored[0].Score if strings.TrimSpace(scored[0].Candidate.Address) != "" { entry.Endpoint = strings.TrimSpace(scored[0].Candidate.Address) @@ -188,6 +193,7 @@ func NewPeerCache(cfg PeerCacheConfig) *PeerCache { if lease.PeerNodeID != cfg.Local.NodeID { entry := peerCacheEntry(entries, lease.PeerNodeID) useLeaseEndpoint := shouldUseRendezvousEndpoint(*entry) + localRelay := lease.RelayNodeID == cfg.Local.NodeID entry.RendezvousLeaseID = lease.LeaseID entry.RelayNodeID = lease.RelayNodeID entry.RelayEndpoint = strings.TrimRight(strings.TrimSpace(lease.RelayEndpoint), "/") @@ -195,12 +201,21 @@ func NewPeerCache(cfg PeerCacheConfig) *PeerCache { entry.CandidateCount = maxInt(entry.CandidateCount, 1) entry.ConnectivityModes = mergeStrings(entry.ConnectivityModes, []string{firstNonEmpty(lease.ConnectivityMode, "relay_required"), "relay_control"}) if useLeaseEndpoint { - entry.BestTransport = firstNonEmpty(lease.Transport, "relay_control") + if localRelay { + entry.BestTransport = "reverse_quic" + } else { + entry.BestTransport = firstNonEmpty(lease.Transport, "relay_quic") + } entry.BestReachability = "relay" entry.BestConnectivity = firstNonEmpty(lease.ConnectivityMode, "relay_required") - entry.Endpoint = entry.RelayEndpoint - entry.BestCandidateID = lease.LeaseID - entry.BestCandidateAddr = entry.RelayEndpoint + if !localRelay { + entry.Endpoint = entry.RelayEndpoint + entry.BestCandidateID = lease.LeaseID + entry.BestCandidateAddr = entry.RelayEndpoint + entry.BestPeerCertSHA256 = rendezvousLeasePeerCertSHA256(lease) + } else if strings.TrimSpace(entry.Endpoint) == "" { + entry.Endpoint = firstNonEmpty(entry.BestCandidateAddr, entry.RelayEndpoint) + } entry.bestScore = maxInt(entry.bestScore, 500) } } @@ -262,6 +277,20 @@ func NewPeerCache(cfg PeerCacheConfig) *PeerCache { }} } +func rendezvousLeasePeerCertSHA256(lease PeerRendezvousLease) string { + var metadata struct { + PeerCertSHA256 string `json:"peer_cert_sha256,omitempty"` + TLSCertSHA256 string `json:"tls_cert_sha256,omitempty"` + } + if len(lease.Metadata) == 0 { + return "" + } + if err := json.Unmarshal(lease.Metadata, &metadata); err != nil { + return "" + } + return firstNonEmpty(strings.TrimSpace(metadata.PeerCertSHA256), strings.TrimSpace(metadata.TLSCertSHA256)) +} + func (c *PeerCache) Snapshot() PeerCacheSnapshot { if c == nil { return PeerCacheSnapshot{} diff --git a/agents/rap-node-agent/internal/mesh/peer_cache_test.go b/agents/rap-node-agent/internal/mesh/peer_cache_test.go index 9a0c0ad..640be35 100644 --- a/agents/rap-node-agent/internal/mesh/peer_cache_test.go +++ b/agents/rap-node-agent/internal/mesh/peer_cache_test.go @@ -10,15 +10,15 @@ func TestPeerCacheSelectsAdjacentWarmPeersWithinLimit(t *testing.T) { cache := NewPeerCache(PeerCacheConfig{ Local: local, PeerEndpoints: map[string]string{ - "node-a": "http://node-a:19000", - "node-r": "http://node-r:19000", - "node-c": "http://node-c:19000", + "node-a": "quic://node-a:19443", + "node-r": "quic://node-r:19443", + "node-c": "quic://node-c:19443", }, Routes: []SyntheticRoute{ peerCacheRoute("route-1", []string{"node-a", local.NodeID, "node-r", "node-c"}), }, RecoverySeeds: []PeerRecoverySeed{ - {NodeID: "node-seed", Endpoint: "https://seed.example.test", Transport: "direct_tcp_tls", Priority: 10}, + {NodeID: "node-seed", Endpoint: "quic://seed.example.test:19443", Transport: "direct_quic", Priority: 10}, }, WarmPeerLimit: 2, Now: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC), @@ -42,7 +42,7 @@ func TestPeerCachePromotesRecoverySeedAfterRoutePeers(t *testing.T) { peerCacheRoute("route-1", []string{"node-a", local.NodeID, "node-r"}), }, RecoverySeeds: []PeerRecoverySeed{ - {NodeID: "node-seed", Endpoint: "wss://seed.example.test/mesh", Transport: "wss", ConnectivityMode: "direct", Priority: 1}, + {NodeID: "node-seed", Endpoint: "quic://seed.example.test:19443", Transport: "direct_quic", ConnectivityMode: "direct", Priority: 1}, }, WarmPeerLimit: 3, Now: time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC), @@ -68,7 +68,7 @@ func TestPeerCacheUsesBestEndpointCandidate(t *testing.T) { { EndpointID: "node-b-relay", NodeID: "node-b", - Transport: "relay", + Transport: "relay_quic", Address: "relay.example.test", Reachability: "relay", ConnectivityMode: "relay_required", @@ -77,8 +77,8 @@ func TestPeerCacheUsesBestEndpointCandidate(t *testing.T) { { EndpointID: "node-b-public", NodeID: "node-b", - Transport: "direct_tcp_tls", - Address: "203.0.113.20:443", + Transport: "direct_quic", + Address: "quic://203.0.113.20:19443", Reachability: "public", NATType: "none", ConnectivityMode: "direct", @@ -119,10 +119,10 @@ func TestPeerCacheAppliesEndpointHealthObservations(t *testing.T) { LastVerifiedAt: &now, }, { - EndpointID: "node-b-wss", + EndpointID: "node-b-ice", NodeID: "node-b", - Transport: "wss", - Address: "https://node-b.example.test:443", + Transport: "ice_quic", + Address: "quic://node-b.example.test:19444", Reachability: "public", NATType: "none", ConnectivityMode: "direct", @@ -148,10 +148,10 @@ func TestPeerCacheAppliesEndpointHealthObservations(t *testing.T) { if !ok { t.Fatal("node-b missing from cache") } - if entry.BestCandidateID != "node-b-wss" || entry.Endpoint != "https://node-b.example.test:443" { + if entry.BestCandidateID != "node-b-ice" || entry.Endpoint != "quic://node-b.example.test:19444" { t.Fatalf("peer cache did not apply endpoint observations: %+v", entry) } - if !containsString(entry.BestScoreReasons, "transport:wss") { + if !containsString(entry.BestScoreReasons, "transport:ice_quic") { t.Fatalf("peer cache did not expose score reasons: %+v", entry.BestScoreReasons) } } @@ -161,15 +161,15 @@ func TestPeerCacheUsesPreferredCorporateEndpointAddress(t *testing.T) { cache := NewPeerCache(PeerCacheConfig{ Local: local, PeerEndpoints: map[string]string{ - "node-b": "https://node-b.public.example.test:443", + "node-b": "quic://node-b.public.example.test:19443", }, PeerEndpointCandidates: map[string][]PeerEndpointCandidate{ "node-b": { { EndpointID: "node-b-public", NodeID: "node-b", - Transport: "direct_tcp_tls", - Address: "https://node-b.public.example.test:443", + Transport: "direct_quic", + Address: "quic://node-b.public.example.test:19443", Reachability: "public", NATType: "none", ConnectivityMode: "direct", @@ -179,8 +179,8 @@ func TestPeerCacheUsesPreferredCorporateEndpointAddress(t *testing.T) { { EndpointID: "node-b-corp-lan", NodeID: "node-b", - Transport: "direct_tcp_tls", - Address: "http://10.24.10.20:19001", + Transport: "lan_quic", + Address: "quic://10.24.10.20:19443", Reachability: "private", NATType: "none", ConnectivityMode: "direct", @@ -199,7 +199,7 @@ func TestPeerCacheUsesPreferredCorporateEndpointAddress(t *testing.T) { if !ok { t.Fatal("node-b missing from peer cache") } - if entry.BestCandidateID != "node-b-corp-lan" || entry.Endpoint != "http://10.24.10.20:19001" { + if entry.BestCandidateID != "node-b-corp-lan" || entry.Endpoint != "quic://10.24.10.20:19443" { t.Fatalf("peer cache did not choose corp LAN endpoint: %+v", entry) } } diff --git a/agents/rap-node-agent/internal/mesh/peer_connection_intent.go b/agents/rap-node-agent/internal/mesh/peer_connection_intent.go index 1f4c167..c102e21 100644 --- a/agents/rap-node-agent/internal/mesh/peer_connection_intent.go +++ b/agents/rap-node-agent/internal/mesh/peer_connection_intent.go @@ -29,6 +29,7 @@ type PeerConnectionIntentPlanConfig struct { PeerCache PeerCacheSnapshot RecoveryPlan PeerRecoveryPlan RendezvousLeases []PeerRendezvousLease + PreferredRegion string Now time.Time } @@ -62,12 +63,14 @@ type PeerConnectionIntent struct { Reachability string `json:"reachability,omitempty"` ConnectivityMode string `json:"connectivity_mode,omitempty"` NATType string `json:"nat_type,omitempty"` + Region string `json:"region,omitempty"` PolicyTags []string `json:"policy_tags,omitempty"` RequiresRendezvous bool `json:"requires_rendezvous"` RendezvousResolved bool `json:"rendezvous_resolved"` DirectCandidate bool `json:"direct_candidate"` RelayCandidate bool `json:"relay_candidate"` BestCandidateID string `json:"best_candidate_id,omitempty"` + BestPeerCertSHA256 string `json:"best_peer_cert_sha256,omitempty"` RendezvousLeaseID string `json:"rendezvous_lease_id,omitempty"` RelayNodeID string `json:"relay_node_id,omitempty"` RelayEndpoint string `json:"relay_endpoint,omitempty"` @@ -94,33 +97,35 @@ func PlanPeerConnectionIntents(cfg PeerConnectionIntentPlanConfig) PeerConnectio } entry := entryByNode[candidate.NodeID] intent := PeerConnectionIntent{ - NodeID: candidate.NodeID, - Action: connectionIntentAction(candidate), - Reason: candidate.Reason, - Endpoint: candidate.Endpoint, - ConnectionState: candidate.ConnectionState, - Transport: firstNonEmpty(candidate.BestTransport, entry.BestTransport), - Reachability: entry.BestReachability, - ConnectivityMode: entry.BestConnectivity, - NATType: entry.BestNATType, - PolicyTags: append([]string{}, entry.BestPolicyTags...), - BestCandidateID: firstNonEmpty(candidate.BestCandidateID, entry.BestCandidateID), - RendezvousLeaseID: entry.RendezvousLeaseID, - RelayNodeID: entry.RelayNodeID, - RelayEndpoint: entry.RelayEndpoint, - RelayCandidate: entry.RelayControl, - ControlPlaneOnly: entry.RelayControl, - RecoverySeed: candidate.RecoverySeed || entry.RecoverySeed, - Priority: candidate.Priority, - GeneratedAt: now, + NodeID: candidate.NodeID, + Action: connectionIntentAction(candidate), + Reason: candidate.Reason, + Endpoint: candidate.Endpoint, + ConnectionState: candidate.ConnectionState, + Transport: firstNonEmpty(candidate.BestTransport, entry.BestTransport), + Reachability: entry.BestReachability, + ConnectivityMode: entry.BestConnectivity, + NATType: entry.BestNATType, + Region: entry.BestRegion, + PolicyTags: append([]string{}, entry.BestPolicyTags...), + BestCandidateID: firstNonEmpty(candidate.BestCandidateID, entry.BestCandidateID), + BestPeerCertSHA256: entry.BestPeerCertSHA256, + RendezvousLeaseID: entry.RendezvousLeaseID, + RelayNodeID: entry.RelayNodeID, + RelayEndpoint: entry.RelayEndpoint, + RelayCandidate: entry.RelayControl, + ControlPlaneOnly: entry.RelayControl, + RecoverySeed: candidate.RecoverySeed || entry.RecoverySeed, + Priority: candidate.Priority, + GeneratedAt: now, } - mode, requiresRendezvous, directCandidate := classifyPeerTransport(intent) + mode, requiresRendezvous, directCandidate := classifyPeerTransport(intent, cfg.PreferredRegion) intent.TransportMode = mode intent.RequiresRendezvous = requiresRendezvous intent.DirectCandidate = directCandidate if intent.RequiresRendezvous { if lease, ok := rendezvousLeaseForPeer(cfg.RendezvousLeases, intent.NodeID, now); ok { - applyRendezvousLease(&intent, lease) + applyRendezvousLease(&intent, lease, cfg.PeerCache.LocalNodeID) } } intents = append(intents, intent) @@ -185,10 +190,12 @@ func connectionIntentAction(candidate PeerRecoveryCandidate) string { } } -func classifyPeerTransport(intent PeerConnectionIntent) (string, bool, bool) { +func classifyPeerTransport(intent PeerConnectionIntent, preferredRegion string) (string, bool, bool) { transport := strings.ToLower(strings.TrimSpace(intent.Transport)) connectivity := strings.ToLower(strings.TrimSpace(intent.ConnectivityMode)) reachability := strings.ToLower(strings.TrimSpace(intent.Reachability)) + region := strings.TrimSpace(intent.Region) + preferredRegion = strings.TrimSpace(preferredRegion) tags := lowerStringSet(intent.PolicyTags) if strings.Contains(transport, "relay") || connectivity == "relay_required" || reachability == "relay" { @@ -201,6 +208,9 @@ func classifyPeerTransport(intent PeerConnectionIntent) (string, bool, bool) { return PeerTransportModeCorporateLAN, false, true } if tags["private-lan"] || reachability == "private" || endpointHasPrivateHost(intent.Endpoint) { + if preferredRegion != "" && region != "" && !strings.EqualFold(region, preferredRegion) { + return PeerTransportModeRelayRequired, true, false + } return PeerTransportModePrivateLAN, false, true } if strings.Contains(transport, "direct") || reachability == "public" || connectivity == "direct" { @@ -246,9 +256,16 @@ func rendezvousLeaseForPeer(leases []PeerRendezvousLease, peerNodeID string, now return candidates[0], true } -func applyRendezvousLease(intent *PeerConnectionIntent, lease PeerRendezvousLease) { - intent.Endpoint = strings.TrimRight(strings.TrimSpace(lease.RelayEndpoint), "/") - intent.Transport = firstNonEmpty(lease.Transport, "relay_control") +func applyRendezvousLease(intent *PeerConnectionIntent, lease PeerRendezvousLease, localNodeID string) { + localRelay := strings.TrimSpace(lease.RelayNodeID) == strings.TrimSpace(localNodeID) + if !localRelay { + intent.Endpoint = strings.TrimRight(strings.TrimSpace(lease.RelayEndpoint), "/") + } + if localRelay { + intent.Transport = "reverse_quic" + } else { + intent.Transport = firstNonEmpty(lease.Transport, "relay_quic") + } intent.TransportMode = PeerTransportModeRelayControl intent.RequiresRendezvous = false intent.RendezvousResolved = true @@ -256,17 +273,33 @@ func applyRendezvousLease(intent *PeerConnectionIntent, lease PeerRendezvousLeas intent.RelayCandidate = true intent.RendezvousLeaseID = lease.LeaseID intent.RelayNodeID = lease.RelayNodeID - intent.RelayEndpoint = intent.Endpoint + intent.RelayEndpoint = strings.TrimRight(strings.TrimSpace(lease.RelayEndpoint), "/") intent.ControlPlaneOnly = true + if certSHA256 := rendezvousLeasePeerCertSHA256(lease); certSHA256 != "" && !localRelay { + intent.BestPeerCertSHA256 = certSHA256 + } if lease.ConnectivityMode != "" { intent.ConnectivityMode = lease.ConnectivityMode } } func endpointHasPrivateHost(rawEndpoint string) bool { + addr, ok := endpointHostAddr(rawEndpoint) + if !ok { + return false + } + return addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() +} + +func endpointHasUnspecifiedHost(rawEndpoint string) bool { + addr, ok := endpointHostAddr(rawEndpoint) + return ok && addr.IsUnspecified() +} + +func endpointHostAddr(rawEndpoint string) (netip.Addr, bool) { rawEndpoint = strings.TrimSpace(rawEndpoint) if rawEndpoint == "" { - return false + return netip.Addr{}, false } host := rawEndpoint if parsed, err := url.Parse(rawEndpoint); err == nil && parsed.Host != "" { @@ -277,9 +310,9 @@ func endpointHasPrivateHost(rawEndpoint string) bool { } addr, err := netip.ParseAddr(strings.Trim(host, "[]")) if err != nil { - return false + return netip.Addr{}, false } - return addr.IsPrivate() || addr.IsLoopback() || addr.IsLinkLocalUnicast() + return addr, true } func lowerStringSet(values []string) map[string]bool { diff --git a/agents/rap-node-agent/internal/mesh/peer_connection_intent_test.go b/agents/rap-node-agent/internal/mesh/peer_connection_intent_test.go index 87fce03..6b2e790 100644 --- a/agents/rap-node-agent/internal/mesh/peer_connection_intent_test.go +++ b/agents/rap-node-agent/internal/mesh/peer_connection_intent_test.go @@ -1,6 +1,7 @@ package mesh import ( + "encoding/json" "testing" "time" ) @@ -11,8 +12,8 @@ func TestPeerConnectionIntentsClassifyCorporateDirect(t *testing.T) { PeerCache: PeerCacheSnapshot{Entries: []PeerCacheEntry{ { NodeID: "node-b", - Endpoint: "http://10.24.10.20:19001", - BestTransport: "direct_tcp_tls", + Endpoint: "quic://10.24.10.20:19443", + BestTransport: "lan_quic", BestReachability: "private", BestConnectivity: "direct", BestPolicyTags: []string{"corp-lan", "same-site"}, @@ -23,7 +24,7 @@ func TestPeerConnectionIntentsClassifyCorporateDirect(t *testing.T) { Candidates: []PeerRecoveryCandidate{ { NodeID: "node-b", - Endpoint: "http://10.24.10.20:19001", + Endpoint: "quic://10.24.10.20:19443", ConnectionState: PeerConnectionReady, Reason: "maintain_ready", Priority: 100, @@ -48,15 +49,15 @@ func TestPeerConnectionIntentsClassifyOutboundAndRelayAsRendezvousRequired(t *te PeerCache: PeerCacheSnapshot{Entries: []PeerCacheEntry{ { NodeID: "node-b", - Endpoint: "https://node-b.example.test:443", - BestTransport: "direct_tcp_tls", + Endpoint: "quic://node-b.example.test:19443", + BestTransport: "reverse_quic", BestReachability: "outbound_only", BestConnectivity: "outbound_only", }, { NodeID: "node-c", Endpoint: "relay://fabric-relay/node-c", - BestTransport: "relay", + BestTransport: "relay_quic", BestReachability: "relay", BestConnectivity: "relay_required", }, @@ -66,7 +67,7 @@ func TestPeerConnectionIntentsClassifyOutboundAndRelayAsRendezvousRequired(t *te Candidates: []PeerRecoveryCandidate{ { NodeID: "node-b", - Endpoint: "https://node-b.example.test:443", + Endpoint: "quic://node-b.example.test:19443", ConnectionState: PeerConnectionDisconnected, Reason: "recover_warm", Priority: 90, @@ -91,6 +92,42 @@ func TestPeerConnectionIntentsClassifyOutboundAndRelayAsRendezvousRequired(t *te } } +func TestPeerConnectionIntentsRequireRendezvousForRemotePrivateRegion(t *testing.T) { + now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) + plan := PlanPeerConnectionIntents(PeerConnectionIntentPlanConfig{ + PreferredRegion: "ifcm", + PeerCache: PeerCacheSnapshot{Entries: []PeerCacheEntry{ + { + NodeID: "node-b", + Endpoint: "quic://192.168.200.61:19132", + BestTransport: "direct_quic", + BestReachability: "private", + BestConnectivity: "private_lan", + BestRegion: "docker-test", + }, + }}, + RecoveryPlan: PeerRecoveryPlan{ + Mode: PeerRecoveryModeRecovery, + Candidates: []PeerRecoveryCandidate{{ + NodeID: "node-b", + Endpoint: "quic://192.168.200.61:19132", + ConnectionState: PeerConnectionDisconnected, + Reason: "recover_warm", + Priority: 100, + }}, + }, + Now: now, + }) + + if plan.IntentCount != 1 || plan.RelayRequiredCount != 1 || plan.RendezvousRequiredCount != 1 { + t.Fatalf("unexpected remote private plan counts: %+v", plan) + } + intent := plan.Intents[0] + if intent.DirectCandidate || !intent.RequiresRendezvous || intent.TransportMode != PeerTransportModeRelayRequired { + t.Fatalf("unexpected remote private intent: %+v", intent) + } +} + func TestPeerConnectionIntentsResolveRendezvousWithRelayLease(t *testing.T) { now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) plan := PlanPeerConnectionIntents(PeerConnectionIntentPlanConfig{ @@ -120,13 +157,14 @@ func TestPeerConnectionIntentsResolveRendezvousWithRelayLease(t *testing.T) { LeaseID: "lease-node-b-via-node-r", PeerNodeID: "node-b", RelayNodeID: "node-r", - RelayEndpoint: "http://node-r:19000", - Transport: "relay_control", + RelayEndpoint: "quic://node-r:19443", + Transport: "relay_quic", ConnectivityMode: "relay_required", Priority: 10, ControlPlaneOnly: true, IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Minute), + Metadata: peerConnectionIntentLeaseMetadata(t, "abc123"), }, }, Now: now, @@ -137,9 +175,10 @@ func TestPeerConnectionIntentsResolveRendezvousWithRelayLease(t *testing.T) { } intent := plan.Intents[0] if intent.TransportMode != PeerTransportModeRelayControl || - intent.Endpoint != "http://node-r:19000" || + intent.Endpoint != "quic://node-r:19443" || intent.RelayNodeID != "node-r" || intent.RendezvousLeaseID != "lease-node-b-via-node-r" || + intent.BestPeerCertSHA256 != "abc123" || !intent.RelayCandidate || !intent.RendezvousResolved || intent.RequiresRendezvous { @@ -176,8 +215,8 @@ func TestPeerConnectionIntentsSkipExpiredRendezvousLeaseAndReselect(t *testing.T LeaseID: "lease-expired-preferred", PeerNodeID: "node-b", RelayNodeID: "node-r-old", - RelayEndpoint: "http://node-r-old:19000", - Transport: "relay_control", + RelayEndpoint: "quic://node-r-old:19443", + Transport: "relay_quic", ConnectivityMode: "relay_required", Priority: 1, ControlPlaneOnly: true, @@ -188,8 +227,8 @@ func TestPeerConnectionIntentsSkipExpiredRendezvousLeaseAndReselect(t *testing.T LeaseID: "lease-active-reselected", PeerNodeID: "node-b", RelayNodeID: "node-r-new", - RelayEndpoint: "http://node-r-new:19000", - Transport: "relay_control", + RelayEndpoint: "quic://node-r-new:19443", + Transport: "relay_quic", ConnectivityMode: "relay_required", Priority: 20, ControlPlaneOnly: true, @@ -206,20 +245,29 @@ func TestPeerConnectionIntentsSkipExpiredRendezvousLeaseAndReselect(t *testing.T intent := plan.Intents[0] if intent.RendezvousLeaseID != "lease-active-reselected" || intent.RelayNodeID != "node-r-new" || - intent.Endpoint != "http://node-r-new:19000" { + intent.Endpoint != "quic://node-r-new:19443" { t.Fatalf("expired lease was not skipped: %+v", intent) } } +func peerConnectionIntentLeaseMetadata(t *testing.T, certSHA256 string) json.RawMessage { + t.Helper() + payload, err := json.Marshal(map[string]string{"peer_cert_sha256": certSHA256}) + if err != nil { + t.Fatalf("marshal metadata: %v", err) + } + return payload +} + func TestPeerConnectionIntentsClassifyPrivateEndpointWithoutCandidateHints(t *testing.T) { plan := PlanPeerConnectionIntents(PeerConnectionIntentPlanConfig{ PeerCache: PeerCacheSnapshot{Entries: []PeerCacheEntry{ - {NodeID: "node-b", Endpoint: "http://192.168.10.20:19001"}, + {NodeID: "node-b", Endpoint: "quic://192.168.10.20:19443"}, }}, RecoveryPlan: PeerRecoveryPlan{Candidates: []PeerRecoveryCandidate{ { NodeID: "node-b", - Endpoint: "http://192.168.10.20:19001", + Endpoint: "quic://192.168.10.20:19443", ConnectionState: PeerConnectionDisconnected, Reason: "recover_peer", Priority: 10, 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 2867313..e65d803 100644 --- a/agents/rap-node-agent/internal/mesh/peer_connection_manager.go +++ b/agents/rap-node-agent/internal/mesh/peer_connection_manager.go @@ -2,6 +2,7 @@ package mesh import ( "context" + "fmt" "net/http" "strings" "sync" @@ -25,6 +26,8 @@ type PeerConnectionManagerConfig struct { Tracker *PeerConnectionTracker RendezvousLeases []PeerRendezvousLease HTTPClient *http.Client + QUICTransport *QUICFabricTransport + PreferredRegion string ProbeTimeout time.Duration Now func() time.Time } @@ -35,6 +38,8 @@ type PeerConnectionManager struct { tracker *PeerConnectionTracker rendezvousLeases []PeerRendezvousLease httpClient *http.Client + quicTransport *QUICFabricTransport + preferredRegion string probeTimeout time.Duration now func() time.Time @@ -101,9 +106,10 @@ type PeerConnectionCandidateProbeResult struct { } type peerConnectionProbeTarget struct { - CandidateID string - Endpoint string - Transport string + CandidateID string + Endpoint string + Transport string + PeerCertSHA256 string } func NewPeerConnectionManager(cfg PeerConnectionManagerConfig) *PeerConnectionManager { @@ -132,6 +138,8 @@ func NewPeerConnectionManager(cfg PeerConnectionManagerConfig) *PeerConnectionMa tracker: cfg.Tracker, rendezvousLeases: append([]PeerRendezvousLease{}, cfg.RendezvousLeases...), httpClient: httpClient, + quicTransport: cfg.QUICTransport, + preferredRegion: strings.TrimSpace(cfg.PreferredRegion), probeTimeout: probeTimeout, now: now, } @@ -155,6 +163,7 @@ func (m *PeerConnectionManager) ProbeOnce(ctx context.Context) PeerConnectionMan PeerCache: peerSnapshot, RecoveryPlan: recoveryPlan, RendezvousLeases: rendezvousLeases, + PreferredRegion: m.preferredRegion, Now: startedAt, }) entriesByNode := map[string]PeerCacheEntry{} @@ -215,6 +224,15 @@ func (m *PeerConnectionManager) UpdatePeerConfig(peerCache *PeerCache, rendezvou m.rendezvousLeases = append([]PeerRendezvousLease{}, rendezvousLeases...) } +func (m *PeerConnectionManager) UpdateQUICTransport(transport *QUICFabricTransport) { + if m == nil { + return + } + m.mu.Lock() + defer m.mu.Unlock() + m.quicTransport = transport +} + func (m *PeerConnectionManager) peerConfigSnapshot() (*PeerCache, []PeerRendezvousLease) { if m == nil { return nil, nil @@ -242,17 +260,18 @@ func (m *PeerConnectionManager) probeIntent(ctx context.Context, intent PeerConn StartedAt: startedAt, } peer := PeerCacheEntry{ - NodeID: intent.NodeID, - Endpoint: intent.Endpoint, - Warm: true, - WarmReason: intent.Reason, - RecoverySeed: intent.RecoverySeed, - BestCandidateID: intent.BestCandidateID, - BestTransport: intent.Transport, - RendezvousLeaseID: intent.RendezvousLeaseID, - RelayNodeID: intent.RelayNodeID, - RelayEndpoint: intent.RelayEndpoint, - RelayControl: intent.RelayCandidate, + NodeID: intent.NodeID, + Endpoint: intent.Endpoint, + Warm: true, + WarmReason: intent.Reason, + RecoverySeed: intent.RecoverySeed, + BestCandidateID: intent.BestCandidateID, + BestTransport: intent.Transport, + RendezvousLeaseID: intent.RendezvousLeaseID, + RelayNodeID: intent.RelayNodeID, + RelayEndpoint: intent.RelayEndpoint, + RelayControl: intent.RelayCandidate, + BestPeerCertSHA256: firstNonEmpty(intent.BestPeerCertSHA256, cacheEntry.BestPeerCertSHA256), } if intent.RequiresRendezvous { result.LinkStatus = PeerConnectionProbeDeferred @@ -282,13 +301,12 @@ func (m *PeerConnectionManager) probeIntent(ctx context.Context, intent PeerConn ClusterID: m.local.ClusterID, NodeID: intent.NodeID, } - if intent.RelayCandidate && intent.RelayNodeID != "" { - target.NodeID = intent.RelayNodeID - } + target.NodeID = peerConnectionProbeTargetNodeID(intent, m.local.NodeID) targets := []peerConnectionProbeTarget{{ - CandidateID: intent.BestCandidateID, - Endpoint: intent.Endpoint, - Transport: intent.Transport, + CandidateID: intent.BestCandidateID, + Endpoint: intent.Endpoint, + Transport: intent.Transport, + PeerCertSHA256: intent.BestPeerCertSHA256, }} if intent.DirectCandidate { targets = peerConnectionProbeTargets(intent, cacheEntry) @@ -300,13 +318,14 @@ func (m *PeerConnectionManager) probeIntent(ctx context.Context, intent PeerConn probePeer.BestCandidateID = strings.TrimSpace(probeTarget.CandidateID) probePeer.BestCandidateAddr = probePeer.Endpoint probePeer.BestTransport = strings.TrimSpace(probeTarget.Transport) + probePeer.BestPeerCertSHA256 = firstNonEmpty(probeTarget.PeerCertSHA256, probePeer.BestPeerCertSHA256) if probePeer.Endpoint == "" { continue } candidateStartedAt := normalizedNow(m.now()) m.tracker.BeginProbe(probePeer, candidateStartedAt) probeCtx, cancel := context.WithTimeout(ctx, m.probeTimeout) - _, err := NewClient(probePeer.Endpoint).withHTTPClient(m.httpClient).SendHealth(probeCtx, NewHealthMessage(m.local, target)) + err := m.probePeerTarget(probeCtx, probePeer, target) cancel() completedAt := normalizedNow(m.now()) candidateResult := PeerConnectionCandidateProbeResult{ @@ -354,47 +373,97 @@ func (m *PeerConnectionManager) probeIntent(ctx context.Context, intent PeerConn return result } +func peerConnectionProbeTargetNodeID(intent PeerConnectionIntent, localNodeID string) string { + if intent.RelayCandidate && strings.TrimSpace(intent.RelayNodeID) != "" && strings.TrimSpace(intent.RelayNodeID) != strings.TrimSpace(localNodeID) { + return intent.RelayNodeID + } + return intent.NodeID +} + +func (m *PeerConnectionManager) probePeerTarget(ctx context.Context, probePeer PeerCacheEntry, target PeerIdentity) error { + endpoint := strings.TrimRight(strings.TrimSpace(probePeer.Endpoint), "/") + transport := strings.TrimSpace(probePeer.BestTransport) + if hasLegacyEndpointScheme(endpoint) { + return fmt.Errorf("non_quic_probe_rejected") + } + if peerConnectionTargetIsQUIC(transport, endpoint) { + carrier, selectedTarget, err := FabricTransportForTarget(FabricTransportTarget{ + EndpointID: probePeer.BestCandidateID, + PeerID: target.NodeID, + Endpoint: endpoint, + Transport: transport, + Timeout: m.probeTimeout, + PeerCertSHA256: strings.TrimSpace(probePeer.BestPeerCertSHA256), + }, m.quicTransport) + if err != nil { + return err + } + session, err := carrier.Connect(ctx, selectedTarget) + if err != nil { + return err + } + return session.Close() + } + return fmt.Errorf("non_quic_probe_rejected") +} + func peerConnectionProbeTargets(intent PeerConnectionIntent, cacheEntry PeerCacheEntry) []peerConnectionProbeTarget { seen := map[string]struct{}{} out := make([]peerConnectionProbeTarget, 0, len(cacheEntry.EndpointCandidates)+1) - add := func(candidateID, endpoint, transport string) { + add := func(candidateID, endpoint, transport, peerCertSHA256 string) { endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/") if endpoint == "" { return } + if endpointHasUnspecifiedHost(endpoint) { + return + } key := candidateID + "|" + endpoint if _, ok := seen[key]; ok { return } seen[key] = struct{}{} out = append(out, peerConnectionProbeTarget{ - CandidateID: strings.TrimSpace(candidateID), - Endpoint: endpoint, - Transport: strings.TrimSpace(transport), + CandidateID: strings.TrimSpace(candidateID), + Endpoint: endpoint, + Transport: strings.TrimSpace(transport), + PeerCertSHA256: strings.TrimSpace(peerCertSHA256), }) } for _, candidate := range cacheEntry.EndpointCandidates { if !candidateUsableForDirectProbe(candidate) { continue } - add(candidate.EndpointID, candidate.Address, candidate.Transport) + add(candidate.EndpointID, candidate.Address, candidate.Transport, candidatePeerCertSHA256(candidate)) } - add(intent.BestCandidateID, intent.Endpoint, intent.Transport) + add(intent.BestCandidateID, intent.Endpoint, intent.Transport, cacheEntry.BestPeerCertSHA256) return out } +func peerConnectionTargetIsQUIC(transport string, endpoint string) bool { + return isQUICOnlyCandidateTransport(transport) || strings.HasPrefix(strings.ToLower(strings.TrimSpace(endpoint)), "quic://") +} + func candidateUsableForDirectProbe(candidate PeerEndpointCandidate) bool { endpoint := strings.TrimSpace(candidate.Address) if endpoint == "" || strings.HasPrefix(endpoint, "relay://") || strings.HasPrefix(endpoint, "outbound://") { return false } + if endpointHasUnspecifiedHost(endpoint) { + return false + } connectivity := strings.ToLower(strings.TrimSpace(candidate.ConnectivityMode)) reachability := strings.ToLower(strings.TrimSpace(candidate.Reachability)) transport := strings.ToLower(strings.TrimSpace(candidate.Transport)) if connectivity == "outbound_only" || connectivity == "relay_required" || reachability == "outbound_only" || reachability == "relay" { return false } - return transport == "" || strings.Contains(transport, "direct") || transport == "wss" || strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") + return transport == "" || + strings.Contains(transport, "direct_quic") || + transport == "quic" || + transport == "lan_quic" || + transport == "ice_quic" || + strings.HasPrefix(endpoint, "quic://") } func (m *PeerConnectionManager) connectionState(nodeID string) PeerConnectionState { 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 0fe1716..721d7cb 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 @@ -2,8 +2,8 @@ package mesh import ( "context" + "encoding/json" "net/http" - "net/http/httptest" "testing" "time" ) @@ -11,12 +11,18 @@ import ( func TestPeerConnectionManagerProbesDirectAndDefersRendezvous(t *testing.T) { now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) current := now - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"}, - }.Handler()) + 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) cache := NewPeerCache(PeerCacheConfig{ Local: local, PeerEndpointCandidates: map[string][]PeerEndpointCandidate{ @@ -24,19 +30,20 @@ func TestPeerConnectionManagerProbesDirectAndDefersRendezvous(t *testing.T) { { EndpointID: "node-b-direct", NodeID: "node-b", - Transport: "direct_tcp_tls", - Address: server.URL, + Transport: "direct_quic", + Address: "quic://" + server.Addr().String(), Reachability: "private", ConnectivityMode: "direct", PolicyTags: []string{"corp-lan", "same-site"}, Priority: 1, + Metadata: peerConnectionProbeMetadata(t, certSHA256), }, }, "node-c": { { EndpointID: "node-c-relay", NodeID: "node-c", - Transport: "relay", + Transport: "relay_quic", Address: "relay://fabric/node-c", Reachability: "relay", ConnectivityMode: "relay_required", @@ -49,10 +56,11 @@ func TestPeerConnectionManagerProbesDirectAndDefersRendezvous(t *testing.T) { }) tracker := NewPeerConnectionTracker(cache.Snapshot(), now) manager := NewPeerConnectionManager(PeerConnectionManagerConfig{ - Local: local, - PeerCache: cache, - Tracker: tracker, - ProbeTimeout: time.Second, + Local: local, + PeerCache: cache, + Tracker: tracker, + QUICTransport: NewQUICFabricTransport(nil), + ProbeTimeout: time.Second, Now: func() time.Time { current = current.Add(10 * time.Millisecond) return current @@ -116,24 +124,31 @@ func TestPeerConnectionManagerRecordsFailureAndSuppressesActiveBackoff(t *testin func TestPeerConnectionManagerProbesRelayControlLease(t *testing.T) { now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) current := now - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-r"}, - }.Handler()) + 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: server.URL, - Transport: "relay_control", + RelayEndpoint: "quic://" + server.Addr().String(), + Transport: "relay_quic", ConnectivityMode: "relay_required", Priority: 10, ControlPlaneOnly: true, IssuedAt: now.Add(-time.Minute), ExpiresAt: now.Add(time.Minute), + Metadata: peerConnectionProbeMetadata(t, certSHA256), }, } cache := NewPeerCache(PeerCacheConfig{ @@ -143,7 +158,7 @@ func TestPeerConnectionManagerProbesRelayControlLease(t *testing.T) { { EndpointID: "node-b-relay", NodeID: "node-b", - Transport: "relay", + Transport: "relay_quic", Address: "relay://fabric/node-b", Reachability: "relay", ConnectivityMode: "relay_required", @@ -161,6 +176,7 @@ func TestPeerConnectionManagerProbesRelayControlLease(t *testing.T) { PeerCache: cache, Tracker: tracker, RendezvousLeases: leases, + QUICTransport: NewQUICFabricTransport(nil), ProbeTimeout: time.Second, Now: func() time.Time { current = current.Add(10 * time.Millisecond) @@ -189,15 +205,37 @@ func TestPeerConnectionManagerProbesRelayControlLease(t *testing.T) { } } +func TestPeerConnectionProbeTargetKeepsPeerForLocalRelayReverseQUIC(t *testing.T) { + intent := PeerConnectionIntent{ + NodeID: "node-b", + RelayCandidate: true, + RelayNodeID: "node-a", + Transport: "reverse_quic", + } + if got := peerConnectionProbeTargetNodeID(intent, "node-a"); got != "node-b" { + t.Fatalf("local relay reverse probe target = %q, want peer node-b", got) + } + intent.RelayNodeID = "node-r" + if got := peerConnectionProbeTargetNodeID(intent, "node-a"); got != "node-r" { + t.Fatalf("remote relay probe target = %q, want relay node-r", got) + } +} + func TestPeerConnectionManagerFallsBackAcrossEndpointCandidates(t *testing.T) { now := time.Date(2026, 4, 30, 12, 0, 0, 0, time.UTC) current := now - server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"}, - }.Handler()) + 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) cache := NewPeerCache(PeerCacheConfig{ Local: local, PeerEndpointCandidates: map[string][]PeerEndpointCandidate{ @@ -205,8 +243,8 @@ func TestPeerConnectionManagerFallsBackAcrossEndpointCandidates(t *testing.T) { { EndpointID: "node-b-dead", NodeID: "node-b", - Transport: "direct_http", - Address: "http://127.0.0.1:1", + Transport: "lan_quic", + Address: "quic://127.0.0.1:1", Reachability: "private", ConnectivityMode: "private_lan", Priority: 1, @@ -214,11 +252,12 @@ func TestPeerConnectionManagerFallsBackAcrossEndpointCandidates(t *testing.T) { { EndpointID: "node-b-live", NodeID: "node-b", - Transport: "direct_http", - Address: server.URL, + Transport: "lan_quic", + Address: "quic://" + server.Addr().String(), Reachability: "private", ConnectivityMode: "private_lan", Priority: 2, + Metadata: peerConnectionProbeMetadata(t, certSHA256), }, }, }, @@ -227,11 +266,11 @@ func TestPeerConnectionManagerFallsBackAcrossEndpointCandidates(t *testing.T) { }) tracker := NewPeerConnectionTracker(cache.Snapshot(), now) manager := NewPeerConnectionManager(PeerConnectionManagerConfig{ - Local: local, - PeerCache: cache, - Tracker: tracker, - HTTPClient: &http.Client{Timeout: 100 * time.Millisecond}, - ProbeTimeout: 100 * time.Millisecond, + Local: local, + PeerCache: cache, + Tracker: tracker, + QUICTransport: NewQUICFabricTransport(nil), + ProbeTimeout: 100 * time.Millisecond, Now: func() time.Time { current = current.Add(10 * time.Millisecond) return current @@ -243,7 +282,7 @@ func TestPeerConnectionManagerFallsBackAcrossEndpointCandidates(t *testing.T) { t.Fatalf("unexpected cycle: %+v", cycle) } result := cycle.Results[0] - if result.LinkStatus != PeerConnectionProbeReachable || result.SelectedCandidateID != "node-b-live" || result.SelectedEndpoint != server.URL { + if result.LinkStatus != PeerConnectionProbeReachable || result.SelectedCandidateID != "node-b-live" || result.SelectedEndpoint != "quic://"+server.Addr().String() { t.Fatalf("fallback did not select live candidate: %+v", result) } if len(result.CandidateResults) != 2 || @@ -252,7 +291,85 @@ func TestPeerConnectionManagerFallsBackAcrossEndpointCandidates(t *testing.T) { t.Fatalf("candidate probe trail mismatch: %+v", result.CandidateResults) } snapshot := tracker.Snapshot() - if snapshot.Ready != 1 || len(snapshot.Entries) != 1 || snapshot.Entries[0].BestCandidateID != "node-b-live" || snapshot.Entries[0].Endpoint != server.URL { + if snapshot.Ready != 1 || len(snapshot.Entries) != 1 || snapshot.Entries[0].BestCandidateID != "node-b-live" || snapshot.Entries[0].Endpoint != "quic://"+server.Addr().String() { t.Fatalf("tracker did not retain selected candidate: %+v", snapshot) } } + +func TestPeerConnectionManagerSkipsUnspecifiedQUICCandidates(t *testing.T) { + now := time.Date(2026, 5, 17, 6, 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) + cache := NewPeerCache(PeerCacheConfig{ + Local: local, + PeerEndpointCandidates: map[string][]PeerEndpointCandidate{ + "node-b": { + { + EndpointID: "node-b-unspecified-v6", + NodeID: "node-b", + Transport: "direct_quic", + Address: "quic://[::]:19131", + Reachability: "public", + ConnectivityMode: "direct", + Priority: 1, + }, + { + EndpointID: "node-b-live", + NodeID: "node-b", + Transport: "direct_quic", + Address: "quic://" + server.Addr().String(), + Reachability: "public", + ConnectivityMode: "direct", + Priority: 2, + Metadata: peerConnectionProbeMetadata(t, certSHA256), + }, + }, + }, + WarmPeerLimit: 1, + Now: now, + }) + tracker := NewPeerConnectionTracker(cache.Snapshot(), now) + manager := NewPeerConnectionManager(PeerConnectionManagerConfig{ + Local: local, + PeerCache: cache, + Tracker: tracker, + 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-live" || result.SelectedEndpoint != "quic://"+server.Addr().String() { + t.Fatalf("manager did not skip unspecified endpoint: %+v", result) + } + if len(result.CandidateResults) != 1 || result.CandidateResults[0].CandidateID != "node-b-live" { + t.Fatalf("unspecified endpoint should not be probed: %+v", result.CandidateResults) + } +} + +func peerConnectionProbeMetadata(t *testing.T, certSHA256 string) json.RawMessage { + t.Helper() + payload, err := json.Marshal(map[string]string{"peer_cert_sha256": certSHA256}) + if err != nil { + t.Fatalf("marshal probe metadata: %v", err) + } + return payload +} diff --git a/agents/rap-node-agent/internal/mesh/peer_connection_state_test.go b/agents/rap-node-agent/internal/mesh/peer_connection_state_test.go index 5a6fca1..ee1f1ba 100644 --- a/agents/rap-node-agent/internal/mesh/peer_connection_state_test.go +++ b/agents/rap-node-agent/internal/mesh/peer_connection_state_test.go @@ -9,7 +9,7 @@ func TestPeerConnectionTrackerTransitionsReadyAndDegraded(t *testing.T) { now := time.Date(2026, 4, 28, 12, 0, 0, 0, time.UTC) tracker := NewPeerConnectionTracker(PeerCacheSnapshot{ Entries: []PeerCacheEntry{ - {NodeID: "node-b", Warm: true, WarmReason: "route_adjacent", Endpoint: "http://node-b:19000"}, + {NodeID: "node-b", Warm: true, WarmReason: "route_adjacent", Endpoint: "quic://node-b:19443"}, }, }, now) 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 97655d0..818ad50 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 @@ -76,12 +76,12 @@ func TestPeerRecoveryPlanMaintainsRelayReadyPeersInSteadyMode(t *testing.T) { Entries: []PeerCacheEntry{ { NodeID: "node-c", - Endpoint: "http://relay:19001", + Endpoint: "quic://relay:19443", Warm: true, WarmReason: "rendezvous_lease", RendezvousLeaseID: "lease-1", RelayNodeID: "node-r", - RelayEndpoint: "http://relay:19001", + RelayEndpoint: "quic://relay:19443", RelayControl: true, }, }, @@ -121,7 +121,7 @@ func TestPeerRecoveryPlanCapsTargetByConnectablePeers(t *testing.T) { func recoveryPlanPeer(nodeID string, warm bool, recoverySeed bool, warmReason string) PeerCacheEntry { return PeerCacheEntry{ NodeID: nodeID, - Endpoint: "http://" + nodeID + ":19001", + Endpoint: "quic://" + nodeID + ":19443", Warm: warm, WarmReason: warmReason, RecoverySeed: recoverySeed, diff --git a/agents/rap-node-agent/internal/mesh/production_transport.go b/agents/rap-node-agent/internal/mesh/production_transport.go index 5c0ba2a..5890b8b 100644 --- a/agents/rap-node-agent/internal/mesh/production_transport.go +++ b/agents/rap-node-agent/internal/mesh/production_transport.go @@ -2,42 +2,369 @@ package mesh import ( "context" - "net/http" + "encoding/json" + "fmt" "strings" + "sync/atomic" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" ) type ProductionForwardTransport interface { SendProduction(ctx context.Context, nextNodeID string, envelope ProductionEnvelope) (ProductionForwardResult, error) } -type HTTPProductionForwardTransport struct { - PeerURLs map[string]string - HTTPClient *http.Client +type QUICProductionForwardTransport struct { + Targets map[string]FabricTransportTarget + RouteSets map[string]FabricRouteSet + Transport FabricTransport + Router FabricChannelRouter + Timeout time.Duration + Pressure *FabricRoutePressureTracker + Health *FabricRouteHealthTracker + sequence atomic.Uint64 } -func NewHTTPProductionForwardTransport(peerURLs map[string]string) *HTTPProductionForwardTransport { - normalized := make(map[string]string, len(peerURLs)) - for nodeID, baseURL := range peerURLs { +type QUICProductionForwardTransportSnapshot struct { + RoutePressure FabricRoutePressureSnapshot `json:"route_pressure"` + RouteHealth FabricRouteHealthSnapshot `json:"route_health,omitempty"` +} + +func NewQUICProductionForwardTransport(targets map[string]FabricTransportTarget, transport *QUICFabricTransport) *QUICProductionForwardTransport { + routeSets := make(map[string]FabricRouteSet, len(targets)) + for nodeID, target := range targets { nodeID = strings.TrimSpace(nodeID) - baseURL = strings.TrimRight(strings.TrimSpace(baseURL), "/") - if nodeID != "" && baseURL != "" { - normalized[nodeID] = baseURL + target.Endpoint = strings.TrimRight(strings.TrimSpace(target.Endpoint), "/") + target.Transport = strings.TrimSpace(target.Transport) + if nodeID != "" && target.Endpoint != "" { + target.PeerID = firstNonEmpty(strings.TrimSpace(target.PeerID), nodeID) + routeSets[nodeID] = FabricRouteSetForTransportTargets("", "", nodeID, []FabricTransportTarget{target}) } } - return &HTTPProductionForwardTransport{PeerURLs: normalized} + if transport == nil { + transport = NewQUICFabricTransport(nil) + } + return NewQUICProductionForwardTransportFromRouteSets(routeSets, transport) } -func (t *HTTPProductionForwardTransport) SendProduction(ctx context.Context, nextNodeID string, envelope ProductionEnvelope) (ProductionForwardResult, error) { - if t == nil { - return ProductionForwardResult{}, ErrForwardPeerUnavailable +func NewQUICProductionForwardTransportFromRouteSets(routeSets map[string]FabricRouteSet, transport FabricTransport) *QUICProductionForwardTransport { + normalizedRouteSets := make(map[string]FabricRouteSet, len(routeSets)) + targets := make(map[string]FabricTransportTarget, len(routeSets)) + for nodeID, routeSet := range routeSets { + nodeID = strings.TrimSpace(nodeID) + if nodeID == "" { + continue + } + normalizedRouteSets[nodeID] = routeSet + if target, err := FabricTransportTargetForRoute(routeSet.Primary); err == nil { + targets[nodeID] = target + } } - baseURL := strings.TrimRight(strings.TrimSpace(t.PeerURLs[nextNodeID]), "/") - if baseURL == "" { - return ProductionForwardResult{}, ErrForwardPeerUnavailable + if transport == nil { + transport = NewQUICFabricTransport(nil) } - client := NewClient(baseURL) - if t.HTTPClient != nil { - client.HTTPClient = t.HTTPClient + return &QUICProductionForwardTransport{ + Targets: targets, + RouteSets: normalizedRouteSets, + Transport: transport, + Router: NewFabricChannelRouter(FabricChannelRouterConfig{ + MaxAckLatencyMs: 2000, + MinRerouteInterval: 50 * time.Millisecond, + }), + Timeout: 30 * time.Second, + Pressure: NewFabricRoutePressureTracker(), + Health: NewFabricRouteHealthTracker(30 * time.Second), } - return client.SendProduction(ctx, envelope) +} + +func (t *QUICProductionForwardTransport) SendProduction(ctx context.Context, nextNodeID string, envelope ProductionEnvelope) (ProductionForwardResult, error) { + if t == nil || t.Transport == nil { + return ProductionForwardResult{}, ErrForwardPeerUnavailable + } + nextNodeID = strings.TrimSpace(nextNodeID) + routeSet, ok := t.RouteSets[nextNodeID] + if !ok { + target, targetOK := t.Targets[nextNodeID] + if !targetOK || strings.TrimSpace(target.Endpoint) == "" { + return ProductionForwardResult{}, ErrForwardPeerUnavailable + } + routeSet = FabricRouteSetForTransportTargets(envelope.ClusterID, envelope.CurrentHopNodeID, nextNodeID, []FabricTransportTarget{target}) + } + spec := FabricChannelSpec{ + ChannelID: firstNonEmpty(strings.TrimSpace(envelope.MessageID), fmt.Sprintf("production-%d", t.sequence.Add(1))), + ClusterID: envelope.ClusterID, + SourceNodeID: firstNonEmpty(productionRouteSetSourceNodeID(routeSet), envelope.CurrentHopNodeID), + TargetKind: FabricChannelTargetNode, + TargetID: nextNodeID, + TrafficClass: FabricServiceChannelReliable, + CreatedAt: time.Now().UTC(), + } + payload, err := json.Marshal(envelope) + if err != nil { + return ProductionForwardResult{}, err + } + result, err := t.sendProductionWithRouteSet(ctx, spec, routeSet, payload) + if err != nil { + return ProductionForwardResult{}, err + } + return result, nil +} + +func productionRouteSetSourceNodeID(routeSet FabricRouteSet) string { + for _, route := range flattenFabricRouteSet(routeSet) { + if sourceNodeID := strings.TrimSpace(route.SourceNodeID); sourceNodeID != "" { + return sourceNodeID + } + } + return "" +} + +func (t *QUICProductionForwardTransport) sendProductionWithRouteSet(ctx context.Context, spec FabricChannelSpec, routeSet FabricRouteSet, payload []byte) (ProductionForwardResult, error) { + router := t.Router + if router.Config.MaxRoutePressure == 0 { + router = NewFabricChannelRouter(FabricChannelRouterConfig{MaxAckLatencyMs: 2000, MinRerouteInterval: 50 * time.Millisecond}) + } + routeSet = t.routeSetForScheduling(routeSet) + channel, _, err := router.OpenChannel(spec, routeSet, time.Now().UTC()) + if err != nil { + return ProductionForwardResult{}, err + } + timeout := t.Timeout + if timeout <= 0 { + timeout = 30 * time.Second + } + for { + routeSet = t.routeSetForScheduling(routeSet) + route, ok := findFabricRoute(routeSet, channel.RouteID) + if !ok { + return ProductionForwardResult{}, ErrFabricRouteNotFound + } + target, err := FabricTransportTargetForRoute(route) + if err != nil { + return ProductionForwardResult{}, err + } + target.PeerID = firstNonEmpty(strings.TrimSpace(target.PeerID), spec.TargetID) + target.MaxPayload = fabricproto.DefaultMaxPayload + releaseRoute := t.acquireProductionRoute(route.RouteID) + session, err := t.Transport.Connect(ctx, target) + if err != nil { + releaseRoute() + t.markProductionRouteFailure(route.RouteID, err) + updated, event, rerouteErr := router.ObserveChannel(channel, routeSet, FabricChannelObservation{ + ChannelID: spec.ChannelID, + RouteID: route.RouteID, + Failed: true, + Reason: "connect_failed", + ObservedAt: time.Now().UTC(), + }, time.Now().UTC()) + channel = updated + if event.Type == FabricChannelRouteEventReroute { + continue + } + if rerouteErr != nil { + return ProductionForwardResult{}, rerouteErr + } + return ProductionForwardResult{}, err + } + response, ackMs, err := t.sendProductionOnSession(ctx, session, payload, timeout) + _ = session.Close() + releaseRoute() + if err == nil { + t.markProductionRouteSuccess(route.RouteID) + _, _, _ = router.ObserveChannel(channel, routeSet, FabricChannelObservation{ + ChannelID: spec.ChannelID, + RouteID: route.RouteID, + AckLatencyMs: ackMs, + BytesSent: uint64(len(payload)), + FramesSent: 1, + BytesRecv: uint64(len(response.Payload)), + FramesRecv: 1, + ObservedAt: time.Now().UTC(), + }, time.Now().UTC()) + return decodeQUICProductionForwardResponse(response.Payload) + } + t.markProductionRouteFailure(route.RouteID, err) + updated, event, rerouteErr := router.ObserveChannel(channel, routeSet, FabricChannelObservation{ + ChannelID: spec.ChannelID, + RouteID: route.RouteID, + Failed: true, + Reason: "response_failed", + ObservedAt: time.Now().UTC(), + }, time.Now().UTC()) + channel = updated + if event.Type == FabricChannelRouteEventReroute { + continue + } + if rerouteErr != nil { + return ProductionForwardResult{}, rerouteErr + } + return ProductionForwardResult{}, err + } +} + +func (t *QUICProductionForwardTransport) routeSetWithActiveChannels(routeSet FabricRouteSet) FabricRouteSet { + if t == nil || t.Pressure == nil { + return routeSet + } + return t.Pressure.Apply(routeSet) +} + +func (t *QUICProductionForwardTransport) routeSetForScheduling(routeSet FabricRouteSet) FabricRouteSet { + if t != nil && t.Health != nil { + routeSet = t.Health.Apply(routeSet, time.Now().UTC()) + } + return t.routeSetWithActiveChannels(routeSet) +} + +func (t *QUICProductionForwardTransport) acquireProductionRoute(routeID string) func() { + if t == nil || t.Pressure == nil { + return func() {} + } + return t.Pressure.Acquire(routeID) +} + +func (t *QUICProductionForwardTransport) markProductionRouteFailure(routeID string, err error) { + if t == nil || t.Health == nil || err == nil { + return + } + t.Health.MarkFailure(routeID, err.Error(), time.Now().UTC()) +} + +func (t *QUICProductionForwardTransport) markProductionRouteSuccess(routeID string) { + if t == nil || t.Health == nil { + return + } + t.Health.MarkSuccess(routeID) +} + +func (t *QUICProductionForwardTransport) Snapshot() QUICProductionForwardTransportSnapshot { + if t == nil { + return QUICProductionForwardTransportSnapshot{} + } + var pressure FabricRoutePressureSnapshot + if t.Pressure != nil { + pressure = t.Pressure.SnapshotPressure() + } + var health FabricRouteHealthSnapshot + if t.Health != nil { + health = t.Health.Snapshot(time.Now().UTC()) + } + return QUICProductionForwardTransportSnapshot{RoutePressure: pressure, RouteHealth: health} +} + +func (t *QUICProductionForwardTransport) sendProductionOnSession(ctx context.Context, session FabricTransportSession, payload []byte, timeout time.Duration) (fabricproto.Frame, int64, error) { + sequence := t.sequence.Add(1) + if err := session.Send(ctx, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: fabricproto.TrafficClassReliable, + StreamID: ProductionForwardQUICStreamID, + Sequence: sequence, + Payload: payload, + }); err != nil { + return fabricproto.Frame{}, 0, err + } + waitCtx := ctx + if timeout > 0 { + var cancel context.CancelFunc + waitCtx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + started := time.Now() + for { + select { + case <-waitCtx.Done(): + return fabricproto.Frame{}, 0, waitCtx.Err() + case err, ok := <-session.Errors(): + if !ok { + return fabricproto.Frame{}, 0, ErrForwardPeerUnavailable + } + if err != nil { + return fabricproto.Frame{}, 0, err + } + case frame, ok := <-session.Frames(): + if !ok { + return fabricproto.Frame{}, 0, ErrForwardPeerUnavailable + } + if frame.Type != fabricproto.FrameData || frame.StreamID != ProductionForwardQUICStreamID || frame.Sequence != sequence { + continue + } + return frame, time.Since(started).Milliseconds(), nil + } + } +} + +func decodeQUICProductionForwardResponse(payload []byte) (ProductionForwardResult, error) { + var response quicProductionForwardResponse + if err := json.Unmarshal(payload, &response); err != nil { + return ProductionForwardResult{}, err + } + if strings.TrimSpace(response.Error) != "" { + return ProductionForwardResult{}, fmt.Errorf("%w: %s", ErrForwardPeerUnavailable, response.Error) + } + return response.Result, nil +} + +func FabricRouteSetForTransportTargets(clusterID string, sourceNodeID string, targetNodeID string, targets []FabricTransportTarget) FabricRouteSet { + routeSet := FabricRouteSet{TargetKind: FabricChannelTargetNode, TargetID: strings.TrimSpace(targetNodeID)} + routes := make([]FabricRoute, 0, len(targets)) + for index, target := range targets { + target.Endpoint = strings.TrimRight(strings.TrimSpace(target.Endpoint), "/") + if strings.TrimSpace(target.Endpoint) == "" { + continue + } + peerID := firstNonEmpty(strings.TrimSpace(target.PeerID), strings.TrimSpace(targetNodeID)) + routeID := strings.TrimSpace(target.EndpointID) + if routeID == "" { + routeID = fmt.Sprintf("%s-quic-%d", peerID, index) + } + routes = append(routes, FabricRoute{ + RouteID: routeID, + ClusterID: strings.TrimSpace(clusterID), + SourceNodeID: strings.TrimSpace(sourceNodeID), + DestinationNodeID: peerID, + Hops: []FabricRouteHop{{ + NodeID: peerID, + Mode: fabricRouteModeForTransportTarget(target), + EndpointID: strings.TrimSpace(target.EndpointID), + Address: target.Endpoint, + PeerCertSHA256: strings.TrimSpace(target.PeerCertSHA256), + }}, + BaseLatencyMs: routeLatencyForIndex(index), + Capacity: 100, + ActiveChannels: 0, + Healthy: true, + LastUpdatedAt: time.Now().UTC(), + }) + } + if len(routes) == 0 { + return routeSet + } + routeSet.Primary = routes[0] + if len(routes) > 1 { + routeSet.WarmStandby = append(routeSet.WarmStandby, routes[1:]...) + } + return routeSet +} + +func fabricRouteModeForTransportTarget(target FabricTransportTarget) FabricRouteMode { + switch strings.ToLower(strings.TrimSpace(target.Transport)) { + case string(FabricRouteLAN): + return FabricRouteLAN + case string(FabricRouteReverse): + return FabricRouteReverse + case string(FabricRouteRelay): + return FabricRouteRelay + case string(FabricRouteICE): + return FabricRouteICE + default: + return FabricRouteDirect + } +} + +func routeLatencyForIndex(index int) int { + if index <= 0 { + return 10 + } + return 10 + index } diff --git a/agents/rap-node-agent/internal/mesh/production_transport_test.go b/agents/rap-node-agent/internal/mesh/production_transport_test.go new file mode 100644 index 0000000..f9dfa08 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/production_transport_test.go @@ -0,0 +1,339 @@ +package mesh + +import ( + "context" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" +) + +func TestQUICProductionForwardTransportReroutesOnConnectFailure(t *testing.T) { + transport := newFakeProductionForwardFabricTransport() + transport.failConnect["quic://dead.example.test:19443"] = true + transport.results["quic://fast.example.test:19443"] = ProductionForwardResult{ + Delivered: true, + MessageID: "message-1", + RouteID: "route-1", + } + forward := NewQUICProductionForwardTransportFromRouteSets(map[string]FabricRouteSet{ + "node-b": FabricRouteSetForTransportTargets("cluster-a", "node-a", "node-b", []FabricTransportTarget{ + {EndpointID: "dead", PeerID: "node-b", Endpoint: "quic://dead.example.test:19443", Transport: "quic"}, + {EndpointID: "fast", PeerID: "node-b", Endpoint: "quic://fast.example.test:19443", Transport: "quic"}, + }), + }, transport) + forward.Timeout = time.Second + + result, err := forward.SendProduction(context.Background(), "node-b", testProductionForwardEnvelope("message-1")) + if err != nil { + t.Fatalf("send production: %v", err) + } + if !result.Delivered || result.MessageID != "message-1" { + t.Fatalf("result = %+v", result) + } + if got := transport.connectCount("quic://dead.example.test:19443"); got != 1 { + t.Fatalf("dead connect count = %d, want 1", got) + } + if got := transport.connectCount("quic://fast.example.test:19443"); got != 1 { + t.Fatalf("fast connect count = %d, want 1", got) + } + snapshot := forward.Snapshot() + if snapshot.RoutePressure.AcquiredTotal != 2 || snapshot.RoutePressure.ReleasedTotal != 2 || snapshot.RoutePressure.MaxActiveTotal == 0 { + t.Fatalf("route pressure snapshot = %+v", snapshot) + } +} + +func TestQUICProductionForwardTransportQuarantinesFailedRoute(t *testing.T) { + transport := newFakeProductionForwardFabricTransport() + transport.failConnect["quic://dead.example.test:19443"] = true + transport.results["quic://fast.example.test:19443"] = ProductionForwardResult{Delivered: true, MessageID: "message-1"} + forward := NewQUICProductionForwardTransportFromRouteSets(map[string]FabricRouteSet{ + "node-b": FabricRouteSetForTransportTargets("cluster-a", "node-a", "node-b", []FabricTransportTarget{ + {EndpointID: "dead", PeerID: "node-b", Endpoint: "quic://dead.example.test:19443", Transport: "quic"}, + {EndpointID: "fast", PeerID: "node-b", Endpoint: "quic://fast.example.test:19443", Transport: "quic"}, + }), + }, transport) + forward.Timeout = time.Second + + for i := 0; i < 2; i++ { + result, err := forward.SendProduction(context.Background(), "node-b", testProductionForwardEnvelope("message-1")) + if err != nil { + t.Fatalf("send production #%d: %v", i+1, err) + } + if !result.Delivered { + t.Fatalf("result #%d = %+v", i+1, result) + } + } + if got := transport.connectCount("quic://dead.example.test:19443"); got != 1 { + t.Fatalf("dead connect count = %d, want quarantine after first failure", got) + } + if got := transport.connectCount("quic://fast.example.test:19443"); got != 2 { + t.Fatalf("fast connect count = %d, want both sends on healthy route", got) + } + snapshot := forward.Snapshot() + if snapshot.RouteHealth.Quarantined["dead"].Failures != 1 { + t.Fatalf("route health snapshot = %+v, want dead route quarantined", snapshot.RouteHealth) + } +} + +func TestFabricRouteHealthTrackerExpiresQuarantine(t *testing.T) { + routeSet := FabricRouteSetForTransportTargets("cluster-a", "node-a", "node-b", []FabricTransportTarget{ + {EndpointID: "dead", PeerID: "node-b", Endpoint: "quic://dead.example.test:19443", Transport: "quic"}, + {EndpointID: "fast", PeerID: "node-b", Endpoint: "quic://fast.example.test:19443", Transport: "quic"}, + }) + tracker := NewFabricRouteHealthTracker(time.Second) + now := time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC) + + tracker.MarkFailure("dead", "connect failed", now) + applied := tracker.Apply(routeSet, now.Add(500*time.Millisecond)) + if applied.Primary.Healthy || !applied.Primary.Degraded { + t.Fatalf("primary after quarantine = %+v, want unhealthy degraded route", applied.Primary) + } + if len(tracker.Snapshot(now.Add(500*time.Millisecond)).Quarantined) != 1 { + t.Fatalf("route health snapshot = %+v, want one quarantined route", tracker.Snapshot(now.Add(500*time.Millisecond))) + } + + applied = tracker.Apply(routeSet, now.Add(2*time.Second)) + if !applied.Primary.Healthy || applied.Primary.Degraded { + t.Fatalf("primary after ttl = %+v, want route restored", applied.Primary) + } + if snapshot := tracker.Snapshot(now.Add(2 * time.Second)); len(snapshot.Quarantined) != 0 { + t.Fatalf("route health snapshot after ttl = %+v, want empty quarantine", snapshot) + } +} + +func TestQUICProductionForwardTransportReroutesOnResponseTimeout(t *testing.T) { + transport := newFakeProductionForwardFabricTransport() + transport.delays["quic://slow.example.test:19443"] = 100 * time.Millisecond + transport.results["quic://slow.example.test:19443"] = ProductionForwardResult{Delivered: true, MessageID: "message-1"} + transport.results["quic://fast.example.test:19443"] = ProductionForwardResult{Delivered: true, MessageID: "message-1"} + forward := NewQUICProductionForwardTransportFromRouteSets(map[string]FabricRouteSet{ + "node-b": FabricRouteSetForTransportTargets("cluster-a", "node-a", "node-b", []FabricTransportTarget{ + {EndpointID: "slow", PeerID: "node-b", Endpoint: "quic://slow.example.test:19443", Transport: "quic"}, + {EndpointID: "fast", PeerID: "node-b", Endpoint: "quic://fast.example.test:19443", Transport: "quic"}, + }), + }, transport) + forward.Timeout = 10 * time.Millisecond + + result, err := forward.SendProduction(context.Background(), "node-b", testProductionForwardEnvelope("message-1")) + if err != nil { + t.Fatalf("send production: %v", err) + } + if !result.Delivered || result.MessageID != "message-1" { + t.Fatalf("result = %+v", result) + } + if got := transport.connectCount("quic://slow.example.test:19443"); got != 1 { + t.Fatalf("slow connect count = %d, want 1", got) + } + if got := transport.connectCount("quic://fast.example.test:19443"); got != 1 { + t.Fatalf("fast connect count = %d, want 1", got) + } +} + +func TestQUICProductionForwardTransportSchedulesWithRouteSetSourceForForwardedEnvelope(t *testing.T) { + transport := newFakeProductionForwardFabricTransport() + transport.results["quic://node-c.example.test:19443"] = ProductionForwardResult{Delivered: true, MessageID: "message-forwarded"} + forward := NewQUICProductionForwardTransportFromRouteSets(map[string]FabricRouteSet{ + "node-c": FabricRouteSetForTransportTargets("cluster-a", "node-b", "node-c", []FabricTransportTarget{ + {EndpointID: "node-c-direct", PeerID: "node-c", Endpoint: "quic://node-c.example.test:19443", Transport: "quic"}, + }), + }, transport) + forward.Timeout = time.Second + envelope := testProductionForwardEnvelope("message-forwarded") + envelope.ClusterID = "cluster-a" + envelope.SourceNodeID = "node-a" + envelope.DestinationNodeID = "node-c" + envelope.CurrentHopNodeID = "node-c" + envelope.NextHopNodeID = "node-c" + + result, err := forward.SendProduction(context.Background(), "node-c", envelope) + if err != nil { + t.Fatalf("send production: %v", err) + } + if !result.Delivered || result.MessageID != "message-forwarded" { + t.Fatalf("result = %+v", result) + } + if got := transport.connectCount("quic://node-c.example.test:19443"); got != 1 { + t.Fatalf("connect count = %d, want 1", got) + } +} + +func TestQUICProductionForwardTransportSpreadsConcurrentChannelsByActivePressure(t *testing.T) { + transport := newFakeProductionForwardFabricTransport() + transport.delays["quic://route-a.example.test:19443"] = 80 * time.Millisecond + transport.results["quic://route-a.example.test:19443"] = ProductionForwardResult{Delivered: true, MessageID: "message-1"} + transport.results["quic://route-b.example.test:19443"] = ProductionForwardResult{Delivered: true, MessageID: "message-2"} + routeSet := FabricRouteSetForTransportTargets("cluster-a", "node-a", "node-b", []FabricTransportTarget{ + {EndpointID: "route-a", PeerID: "node-b", Endpoint: "quic://route-a.example.test:19443", Transport: "quic"}, + {EndpointID: "route-b", PeerID: "node-b", Endpoint: "quic://route-b.example.test:19443", Transport: "quic"}, + }) + routeSet.Primary.Capacity = 100 + routeSet.WarmStandby[0].Capacity = 100 + forward := NewQUICProductionForwardTransportFromRouteSets(map[string]FabricRouteSet{"node-b": routeSet}, transport) + forward.Timeout = time.Second + + firstDone := make(chan error, 1) + go func() { + _, err := forward.SendProduction(context.Background(), "node-b", testProductionForwardEnvelope("message-1")) + firstDone <- err + }() + transport.waitForConnect(t, "quic://route-a.example.test:19443", 1) + result, err := forward.SendProduction(context.Background(), "node-b", testProductionForwardEnvelope("message-2")) + if err != nil { + t.Fatalf("second send production: %v", err) + } + if !result.Delivered || result.MessageID != "message-2" { + t.Fatalf("second result = %+v", result) + } + if got := transport.connectCount("quic://route-b.example.test:19443"); got != 1 { + t.Fatalf("route-b connect count = %d, want 1", got) + } + if err := <-firstDone; err != nil { + t.Fatalf("first send production: %v", err) + } + snapshot := forward.Snapshot() + if snapshot.RoutePressure.MaxActive["route-a"] != 1 || snapshot.RoutePressure.MaxActive["route-b"] != 1 || snapshot.RoutePressure.AcquiredTotal != 2 { + t.Fatalf("route pressure snapshot = %+v", snapshot) + } +} + +type fakeProductionForwardFabricTransport struct { + mu sync.Mutex + failConnect map[string]bool + delays map[string]time.Duration + results map[string]ProductionForwardResult + connects map[string]int +} + +func newFakeProductionForwardFabricTransport() *fakeProductionForwardFabricTransport { + return &fakeProductionForwardFabricTransport{ + failConnect: map[string]bool{}, + delays: map[string]time.Duration{}, + results: map[string]ProductionForwardResult{}, + connects: map[string]int{}, + } +} + +func (t *fakeProductionForwardFabricTransport) Connect(_ context.Context, target FabricTransportTarget) (FabricTransportSession, error) { + endpoint := target.Endpoint + t.mu.Lock() + t.connects[endpoint]++ + fail := t.failConnect[endpoint] + delay := t.delays[endpoint] + result := t.results[endpoint] + t.mu.Unlock() + if fail { + return nil, ErrForwardPeerUnavailable + } + return &fakeProductionForwardFabricSession{ + delay: delay, + result: result, + frames: make(chan fabricproto.Frame, 16), + errors: make(chan error, 1), + done: make(chan struct{}), + }, nil +} + +func (t *fakeProductionForwardFabricTransport) Close() error { + return nil +} + +func (t *fakeProductionForwardFabricTransport) connectCount(endpoint string) int { + t.mu.Lock() + defer t.mu.Unlock() + return t.connects[endpoint] +} + +func (t *fakeProductionForwardFabricTransport) waitForConnect(tb testing.TB, endpoint string, count int) { + tb.Helper() + deadline := time.Now().Add(time.Second) + for { + t.mu.Lock() + got := t.connects[endpoint] + t.mu.Unlock() + if got >= count { + return + } + if time.Now().After(deadline) { + tb.Fatalf("timed out waiting for %s connect count %d, got %d", endpoint, count, got) + } + time.Sleep(time.Millisecond) + } +} + +type fakeProductionForwardFabricSession struct { + delay time.Duration + result ProductionForwardResult + frames chan fabricproto.Frame + errors chan error + done chan struct{} + once sync.Once +} + +func (s *fakeProductionForwardFabricSession) Send(_ context.Context, frame fabricproto.Frame) error { + if frame.Type != fabricproto.FrameData { + return nil + } + responsePayload, _ := json.Marshal(quicProductionForwardResponse{Result: s.result}) + go func() { + if s.delay > 0 { + time.Sleep(s.delay) + } + select { + case <-s.done: + case s.frames <- fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: frame.TrafficClass, + StreamID: frame.StreamID, + Sequence: frame.Sequence, + Payload: responsePayload, + }: + } + }() + return nil +} + +func (s *fakeProductionForwardFabricSession) Frames() <-chan fabricproto.Frame { + return s.frames +} + +func (s *fakeProductionForwardFabricSession) Errors() <-chan error { + return s.errors +} + +func (s *fakeProductionForwardFabricSession) Close() error { + s.once.Do(func() { + close(s.done) + }) + return nil +} + +func (s *fakeProductionForwardFabricSession) Closed() bool { + select { + case <-s.done: + return true + default: + return false + } +} + +func testProductionForwardEnvelope(messageID string) ProductionEnvelope { + now := time.Now().UTC() + return ProductionEnvelope{ + FabricProtocolVersion: ProtocolVersion, + MessageID: messageID, + RouteID: "route-1", + ClusterID: "cluster-a", + SourceNodeID: "node-a", + DestinationNodeID: "node-b", + CurrentHopNodeID: "node-a", + NextHopNodeID: "node-b", + ChannelClass: ProductionChannelFabricControl, + MessageType: ProductionMessageFabricControl, + TTL: 8, + CreatedAt: now, + ExpiresAt: now.Add(time.Minute), + } +} diff --git a/agents/rap-node-agent/internal/mesh/scoped_config.go b/agents/rap-node-agent/internal/mesh/scoped_config.go index 663f7ff..b8e5c34 100644 --- a/agents/rap-node-agent/internal/mesh/scoped_config.go +++ b/agents/rap-node-agent/internal/mesh/scoped_config.go @@ -106,6 +106,9 @@ func (cfg ScopedSyntheticConfig) Validate(local PeerIdentity) error { if strings.TrimSpace(nodeID) == "" || strings.TrimSpace(endpoint) == "" { return fmt.Errorf("scoped synthetic mesh config contains empty peer endpoint") } + if hasLegacyEndpointScheme(endpoint) { + return fmt.Errorf("scoped synthetic mesh config contains non-QUIC peer endpoint") + } } for nodeID, candidates := range cfg.PeerEndpointCandidates { if strings.TrimSpace(nodeID) == "" { @@ -121,6 +124,9 @@ func (cfg ScopedSyntheticConfig) Validate(local PeerIdentity) error { strings.TrimSpace(candidate.ConnectivityMode) == "" { return fmt.Errorf("scoped synthetic mesh config contains invalid peer endpoint candidate") } + if !isQUICOnlyCandidateTransport(candidate.Transport) || hasLegacyEndpointScheme(candidate.Address) { + return fmt.Errorf("scoped synthetic mesh config contains non-QUIC peer endpoint candidate") + } } } for endpointID, observation := range cfg.PeerEndpointObservations { @@ -179,6 +185,14 @@ func validatePeerDirectory(entries []PeerDirectoryEntry, localNodeID string) err return nil } +func hasLegacyEndpointScheme(endpoint string) bool { + endpoint = strings.ToLower(strings.TrimSpace(endpoint)) + return strings.HasPrefix(endpoint, "http://") || + strings.HasPrefix(endpoint, "https://") || + strings.HasPrefix(endpoint, "ws://") || + strings.HasPrefix(endpoint, "wss://") +} + func validateRecoverySeeds(seeds []PeerRecoverySeed) error { if len(seeds) > 20 { return fmt.Errorf("scoped synthetic mesh config contains too many recovery seeds") @@ -191,6 +205,9 @@ func validateRecoverySeeds(seeds []PeerRecoverySeed) error { strings.TrimSpace(seed.Transport) == "" { return fmt.Errorf("scoped synthetic mesh config contains invalid recovery seed") } + if !isQUICOnlyCandidateTransport(seed.Transport) || hasLegacyEndpointScheme(seed.Endpoint) { + return fmt.Errorf("scoped synthetic mesh config contains non-QUIC recovery seed") + } if _, duplicate := seen[key]; duplicate { return fmt.Errorf("scoped synthetic mesh config contains duplicate recovery seed") } @@ -224,6 +241,9 @@ func validateRendezvousLeases(leases []PeerRendezvousLease, routes []SyntheticRo (len(lease.Metadata) > 0 && !json.Valid(lease.Metadata)) { return fmt.Errorf("scoped synthetic mesh config contains invalid rendezvous lease") } + if !isQUICOnlyCandidateTransport(lease.Transport) || hasLegacyEndpointScheme(lease.RelayEndpoint) { + return fmt.Errorf("scoped synthetic mesh config contains non-QUIC rendezvous lease") + } if _, duplicate := seen[lease.LeaseID]; duplicate { return fmt.Errorf("scoped synthetic mesh config contains duplicate rendezvous lease") } diff --git a/agents/rap-node-agent/internal/mesh/scoped_config_test.go b/agents/rap-node-agent/internal/mesh/scoped_config_test.go index d775cc5..76f4644 100644 --- a/agents/rap-node-agent/internal/mesh/scoped_config_test.go +++ b/agents/rap-node-agent/internal/mesh/scoped_config_test.go @@ -18,14 +18,14 @@ func TestLoadScopedSyntheticConfig(t *testing.T) { ConfigVersion: "config-v1", PeerDirectoryVersion: "peers-v1", PolicyVersion: "policy-v1", - PeerEndpoints: map[string]string{"node-b": "http://127.0.0.1:19002"}, + PeerEndpoints: map[string]string{"node-b": "quic://127.0.0.1:19443"}, PeerEndpointCandidates: map[string][]PeerEndpointCandidate{ "node-b": { { EndpointID: "node-b-public", NodeID: "node-b", - Transport: "direct_tcp_tls", - Address: "203.0.113.20:443", + Transport: "direct_quic", + Address: "quic://203.0.113.20:19443", Reachability: "public", NATType: "restricted", ConnectivityMode: "direct", @@ -55,8 +55,8 @@ func TestLoadScopedSyntheticConfig(t *testing.T) { RecoverySeeds: []PeerRecoverySeed{ { NodeID: "node-b", - Endpoint: "https://node-b.example.test:443", - Transport: "direct_tcp_tls", + Endpoint: "quic://node-b.example.test:19443", + Transport: "direct_quic", ConnectivityMode: "direct", Priority: 10, }, @@ -66,8 +66,8 @@ func TestLoadScopedSyntheticConfig(t *testing.T) { LeaseID: "lease-node-b-via-node-r", PeerNodeID: "node-b", RelayNodeID: "node-r", - RelayEndpoint: "http://node-r:19000", - Transport: "relay_control", + RelayEndpoint: "quic://node-r:19443", + Transport: "relay_quic", ConnectivityMode: "relay_required", RouteIDs: []string{"route-a-b"}, AllowedChannels: []string{"fabric_control", "route_control"}, @@ -158,8 +158,8 @@ func TestLoadScopedSyntheticConfigRejectsInvalidPeerEndpointCandidate(t *testing { EndpointID: "node-b-public", NodeID: "node-c", - Transport: "direct_tcp_tls", - Address: "203.0.113.20:443", + Transport: "direct_quic", + Address: "quic://203.0.113.20:19443", Reachability: "public", ConnectivityMode: "direct", }, @@ -174,6 +174,73 @@ func TestLoadScopedSyntheticConfigRejectsInvalidPeerEndpointCandidate(t *testing } } +func TestLoadScopedSyntheticConfigRejectsLegacyPeerEndpoint(t *testing.T) { + path := writeScopedConfig(t, ScopedSyntheticConfig{ + SchemaVersion: "c17f.synthetic.v1", + ClusterID: "cluster-1", + LocalNodeID: "node-a", + PeerEndpoints: map[string]string{"node-b": "https://node-b.example.test:443"}, + Routes: []SyntheticRoute{liveSyntheticRoute("route-a-b", []string{"node-a", "node-b"})}, + }) + + _, err := LoadScopedSyntheticConfig(path, PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}) + if err == nil { + t.Fatal("expected non-QUIC peer endpoint error") + } +} + +func TestLoadScopedSyntheticConfigRejectsLegacyPeerEndpointCandidateTransport(t *testing.T) { + path := writeScopedConfig(t, ScopedSyntheticConfig{ + SchemaVersion: "c17f.synthetic.v1", + ClusterID: "cluster-1", + LocalNodeID: "node-a", + PeerEndpointCandidates: map[string][]PeerEndpointCandidate{ + "node-b": { + { + EndpointID: "node-b-websocket", + NodeID: "node-b", + Transport: "websocket", + Address: "quic://203.0.113.20:19443", + Reachability: "public", + ConnectivityMode: "direct", + }, + }, + }, + Routes: []SyntheticRoute{liveSyntheticRoute("route-a-b", []string{"node-a", "node-b"})}, + }) + + _, err := LoadScopedSyntheticConfig(path, PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}) + if err == nil { + t.Fatal("expected non-QUIC peer endpoint candidate error") + } +} + +func TestLoadScopedSyntheticConfigRejectsLegacyPeerEndpointCandidateScheme(t *testing.T) { + path := writeScopedConfig(t, ScopedSyntheticConfig{ + SchemaVersion: "c17f.synthetic.v1", + ClusterID: "cluster-1", + LocalNodeID: "node-a", + PeerEndpointCandidates: map[string][]PeerEndpointCandidate{ + "node-b": { + { + EndpointID: "node-b-https", + NodeID: "node-b", + Transport: "direct_quic", + Address: "https://node-b.example.test:443", + Reachability: "public", + ConnectivityMode: "direct", + }, + }, + }, + Routes: []SyntheticRoute{liveSyntheticRoute("route-a-b", []string{"node-a", "node-b"})}, + }) + + _, err := LoadScopedSyntheticConfig(path, PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}) + if err == nil { + t.Fatal("expected non-QUIC peer endpoint candidate error") + } +} + func TestLoadScopedSyntheticConfigRejectsInvalidPeerEndpointObservation(t *testing.T) { path := writeScopedConfig(t, ScopedSyntheticConfig{ SchemaVersion: "c17f.synthetic.v1", @@ -217,7 +284,7 @@ func TestLoadScopedSyntheticConfigRejectsInvalidRecoverySeed(t *testing.T) { ClusterID: "cluster-1", LocalNodeID: "node-a", RecoverySeeds: []PeerRecoverySeed{ - {NodeID: "node-b", Endpoint: "", Transport: "direct_tcp_tls"}, + {NodeID: "node-b", Endpoint: "", Transport: "direct_quic"}, }, Routes: []SyntheticRoute{liveSyntheticRoute("route-a-b", []string{"node-a", "node-b"})}, }) @@ -228,6 +295,23 @@ func TestLoadScopedSyntheticConfigRejectsInvalidRecoverySeed(t *testing.T) { } } +func TestLoadScopedSyntheticConfigRejectsLegacyRecoverySeed(t *testing.T) { + path := writeScopedConfig(t, ScopedSyntheticConfig{ + SchemaVersion: "c17f.synthetic.v1", + ClusterID: "cluster-1", + LocalNodeID: "node-a", + RecoverySeeds: []PeerRecoverySeed{ + {NodeID: "node-b", Endpoint: "https://node-b.example.test:443", Transport: "direct_quic"}, + }, + Routes: []SyntheticRoute{liveSyntheticRoute("route-a-b", []string{"node-a", "node-b"})}, + }) + + _, err := LoadScopedSyntheticConfig(path, PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}) + if err == nil { + t.Fatal("expected non-QUIC recovery seed error") + } +} + func TestLoadScopedSyntheticConfigRejectsInvalidRendezvousLease(t *testing.T) { path := writeScopedConfig(t, ScopedSyntheticConfig{ SchemaVersion: "c17z12.synthetic.v1", @@ -238,8 +322,8 @@ func TestLoadScopedSyntheticConfigRejectsInvalidRendezvousLease(t *testing.T) { LeaseID: "lease-node-b-via-node-r", PeerNodeID: "node-b", RelayNodeID: "node-r", - RelayEndpoint: "http://node-r:19000", - Transport: "relay_control", + RelayEndpoint: "quic://node-r:19443", + Transport: "relay_quic", RouteIDs: []string{"route-a-b"}, ExpiresAt: time.Now().UTC().Add(time.Hour), }, @@ -253,6 +337,36 @@ func TestLoadScopedSyntheticConfigRejectsInvalidRendezvousLease(t *testing.T) { } } +func TestLoadScopedSyntheticConfigRejectsLegacyRendezvousLease(t *testing.T) { + path := writeScopedConfig(t, ScopedSyntheticConfig{ + SchemaVersion: "c17z12.synthetic.v1", + ClusterID: "cluster-1", + LocalNodeID: "node-a", + RendezvousLeases: []PeerRendezvousLease{ + { + LeaseID: "lease-node-b-via-node-r", + PeerNodeID: "node-b", + RelayNodeID: "node-r", + RelayEndpoint: "https://node-r.example.test:443", + Transport: "relay_quic", + ConnectivityMode: "relay_required", + RouteIDs: []string{"route-a-b"}, + AllowedChannels: []string{"fabric_control", "route_control"}, + Priority: 10, + ControlPlaneOnly: true, + IssuedAt: time.Now().UTC().Add(-time.Minute), + ExpiresAt: time.Now().UTC().Add(time.Hour), + }, + }, + Routes: []SyntheticRoute{liveSyntheticRoute("route-a-b", []string{"node-a", "node-r", "node-b"})}, + }) + + _, err := LoadScopedSyntheticConfig(path, PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}) + if err == nil { + t.Fatal("expected non-QUIC rendezvous lease error") + } +} + func writeScopedConfig(t *testing.T, cfg ScopedSyntheticConfig) string { t.Helper() payload, err := json.Marshal(cfg) @@ -265,3 +379,32 @@ func writeScopedConfig(t *testing.T, cfg ScopedSyntheticConfig) string { } return path } + +func liveSyntheticRoute(routeID string, hops []string) SyntheticRoute { + return SyntheticRoute{ + RouteID: routeID, + ClusterID: "cluster-1", + SourceNodeID: hops[0], + DestinationNodeID: hops[len(hops)-1], + Hops: hops, + AllowedChannels: []string{SyntheticChannelFabricControl}, + MaxTTL: 8, + MaxHops: 8, + ExpiresAt: time.Now().UTC().Add(time.Hour), + RouteVersion: "route-v1", + PolicyVersion: "policy-v1", + PeerDirectoryVersion: "peers-v1", + } +} + +func sameStrings(left, right []string) bool { + if len(left) != len(right) { + return false + } + for i := range left { + if left[i] != right[i] { + return false + } + } + return true +} diff --git a/agents/rap-node-agent/internal/mesh/server.go b/agents/rap-node-agent/internal/mesh/server.go index 1923894..5038096 100644 --- a/agents/rap-node-agent/internal/mesh/server.go +++ b/agents/rap-node-agent/internal/mesh/server.go @@ -69,22 +69,24 @@ type VPNPacketIngressRoutePreference interface { } type Server struct { - Local PeerIdentity - SyntheticRuntime *SyntheticRuntime - ProductionForwardingEnabled bool - ProductionEnvelopeObserver ProductionEnvelopeObserver - ProductionEnvelopeDelivery ProductionEnvelopeDelivery - ProductionForwardTransport ProductionForwardTransport - ProductionForwardLogger ProductionForwardLogger - FabricServiceChannelLogger FabricServiceChannelAccessLogger - RemoteWorkspaceFrameSink RemoteWorkspaceFrameSink - ProductionRoutes []SyntheticRoute - VPNPacketIngress VPNPacketIngress - BackendProxyBaseURL string - ClusterAuthorityPublicKey string - ServiceChannelIntrospection bool - FabricSessionEnabled 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 + FabricSessionEnabled bool + FabricSessionWebSocketEnabled bool + FabricSessionLogger FabricSessionEventLogger } func (s Server) Handler() http.Handler { @@ -92,7 +94,7 @@ 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 { + if s.FabricSessionEnabled && s.FabricSessionWebSocketEnabled { mux.HandleFunc("/mesh/v1/fabric/session/ws", s.handleFabricSessionWebSocket) } if s.RemoteWorkspaceFrameSink != nil { @@ -198,6 +200,7 @@ 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"` @@ -2079,16 +2082,12 @@ func (s Server) handleForward(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusMethodNotAllowed) return } + if s.DisableHTTPDataPlane { + http.Error(w, "mesh data-plane forwarding requires QUIC fabric transport", http.StatusGone) + return + } if !s.ProductionForwardingEnabled { - s.logProductionForward(ProductionForwardLogEntry{ - Event: "production_forward_rejected", - ClusterID: s.Local.ClusterID, - LocalNodeID: s.Local.NodeID, - Reason: ErrForwardDisabled.Error(), - StatusCode: http.StatusNotImplemented, - OccurredAt: time.Now().UTC(), - }) - http.Error(w, ErrForwardDisabled.Error(), http.StatusNotImplemented) + s.rejectProductionForward(w, ProductionEnvelope{}, ErrForwardDisabled, forwardStatusCode(ErrForwardDisabled)) return } var envelope ProductionEnvelope @@ -2104,54 +2103,57 @@ func (s Server) handleForward(w http.ResponseWriter, r *http.Request) { http.Error(w, "invalid production mesh envelope", http.StatusBadRequest) return } - if err := ValidateProductionEnvelope(s.Local, envelope, time.Now().UTC()); err != nil { + result, err := s.ForwardProduction(r.Context(), envelope) + if err != nil { s.rejectProductionForward(w, envelope, err, forwardStatusCode(err)) return } + writeProductionForwardResult(w, result) +} + +func (s Server) ForwardProduction(ctx context.Context, envelope ProductionEnvelope) (ProductionForwardResult, error) { + if !s.ProductionForwardingEnabled { + return ProductionForwardResult{}, ErrForwardDisabled + } + if err := ValidateProductionEnvelope(s.Local, envelope, time.Now().UTC()); err != nil { + return ProductionForwardResult{}, err + } if err := ValidateProductionEnvelopeRouteConfig(s.Local, envelope, s.ProductionRoutes, time.Now().UTC()); err != nil { - s.rejectProductionForward(w, envelope, err, forwardStatusCode(err)) - return + return ProductionForwardResult{}, err } s.logProductionForward(productionForwardLogEntry("production_forward_accepted", s.Local, envelope, "", 0)) if s.ProductionEnvelopeObserver != nil { observation := NewProductionEnvelopeObservation(envelope, time.Now().UTC()) - if err := observeProductionEnvelope(r.Context(), s.ProductionEnvelopeObserver, observation); err != nil { + if err := observeProductionEnvelope(ctx, s.ProductionEnvelopeObserver, observation); err != nil { s.logProductionForward(productionForwardLogEntry("production_forward_rejected", s.Local, envelope, ErrForwardObservationFailed.Error(), http.StatusInternalServerError)) - http.Error(w, ErrForwardObservationFailed.Error(), http.StatusInternalServerError) - return + return ProductionForwardResult{}, ErrForwardObservationFailed } } if envelope.DestinationNodeID == s.Local.NodeID { - if err := deliverProductionEnvelope(r.Context(), s.ProductionEnvelopeDelivery, envelope); err != nil { + if err := deliverProductionEnvelope(ctx, s.ProductionEnvelopeDelivery, envelope); err != nil { s.logProductionForward(productionForwardLogEntry("production_forward_rejected", s.Local, envelope, ErrForwardDeliveryFailed.Error(), http.StatusInternalServerError)) - http.Error(w, ErrForwardDeliveryFailed.Error(), http.StatusInternalServerError) - return + return ProductionForwardResult{}, ErrForwardDeliveryFailed } s.logProductionForward(productionForwardLogEntry("production_forward_delivered", s.Local, envelope, "", http.StatusOK)) - writeProductionForwardResult(w, ProductionForwardResult{ + return ProductionForwardResult{ Accepted: true, Delivered: true, By: s.Local, MessageID: envelope.MessageID, RouteID: envelope.RouteID, - }) - return + }, nil } if envelope.NextHopNodeID == s.Local.NodeID { - s.rejectProductionForward(w, envelope, ErrLoopDetected, forwardStatusCode(ErrLoopDetected)) - return + return ProductionForwardResult{}, ErrLoopDetected } if len(envelope.RoutePath) == 0 && envelope.NextHopNodeID != envelope.DestinationNodeID { - s.rejectProductionForward(w, envelope, ErrForwardRuntimeUnavailable, http.StatusNotImplemented) - return + return ProductionForwardResult{}, ErrForwardRuntimeUnavailable } if s.ProductionForwardTransport == nil { - s.rejectProductionForward(w, envelope, ErrForwardRuntimeUnavailable, http.StatusNotImplemented) - return + return ProductionForwardResult{}, ErrForwardRuntimeUnavailable } if envelope.TTL <= 1 { - s.rejectProductionForward(w, envelope, ErrTTLExhausted, forwardStatusCode(ErrTTLExhausted)) - return + return ProductionForwardResult{}, ErrTTLExhausted } forwarded := envelope forwarded.CurrentHopNodeID = envelope.NextHopNodeID @@ -2159,10 +2161,9 @@ func (s Server) handleForward(w http.ResponseWriter, r *http.Request) { forwarded.TTL = envelope.TTL - 1 forwarded.HopCount = envelope.HopCount + 1 forwarded.VisitedNodeIDs = append(append([]string{}, envelope.VisitedNodeIDs...), s.Local.NodeID) - result, err := s.ProductionForwardTransport.SendProduction(r.Context(), envelope.NextHopNodeID, forwarded) + result, err := s.ProductionForwardTransport.SendProduction(ctx, envelope.NextHopNodeID, forwarded) if err != nil { - s.rejectProductionForward(w, envelope, err, forwardStatusCode(err)) - return + return ProductionForwardResult{}, err } s.logProductionForward(productionForwardLogEntry("production_forward_forwarded", s.Local, envelope, "", http.StatusOK)) result.Accepted = true @@ -2171,7 +2172,7 @@ func (s Server) handleForward(w http.ResponseWriter, r *http.Request) { result.MessageID = envelope.MessageID result.RouteID = envelope.RouteID result.NextNodeID = envelope.NextHopNodeID - writeProductionForwardResult(w, result) + return result, nil } func (s Server) rejectProductionForward(w http.ResponseWriter, envelope ProductionEnvelope, err error, statusCode int) { @@ -2262,6 +2263,10 @@ func (s Server) handleSyntheticProbe(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusMethodNotAllowed) return } + if s.DisableHTTPDataPlane { + http.Error(w, "mesh synthetic probes require QUIC fabric transport", http.StatusGone) + return + } if s.SyntheticRuntime == nil { http.Error(w, ErrMeshRuntimeDisabled.Error(), http.StatusServiceUnavailable) return @@ -2307,17 +2312,19 @@ func syntheticStatusCode(err error) int { } func forwardStatusCode(err error) int { - switch err { - case ErrClusterMismatch, ErrNodeMismatch, ErrUnauthorizedChannel, ErrLoopDetected: + switch { + case errors.Is(err, ErrClusterMismatch), errors.Is(err, ErrNodeMismatch), errors.Is(err, ErrUnauthorizedChannel), errors.Is(err, ErrLoopDetected): return http.StatusForbidden - case ErrRouteExpired, ErrTTLExhausted, ErrInvalidRoutePath, ErrRouteIDRequired: + case errors.Is(err, ErrRouteExpired), errors.Is(err, ErrTTLExhausted), errors.Is(err, ErrInvalidRoutePath), errors.Is(err, ErrRouteIDRequired), errors.Is(err, ErrForwardEnvelopeInvalid): return http.StatusBadRequest - case ErrForwardRuntimeUnavailable: + case errors.Is(err, ErrForwardRuntimeUnavailable), errors.Is(err, ErrForwardDisabled): return http.StatusNotImplemented - case ErrRouteNotFound: + case errors.Is(err, ErrRouteNotFound): return http.StatusNotFound - case ErrForwardPeerUnavailable: + case errors.Is(err, ErrForwardPeerUnavailable): return http.StatusBadGateway + case errors.Is(err, ErrForwardObservationFailed), errors.Is(err, ErrForwardDeliveryFailed): + return http.StatusInternalServerError default: return http.StatusBadRequest } diff --git a/agents/rap-node-agent/internal/mesh/server_test.go b/agents/rap-node-agent/internal/mesh/server_test.go index dde2b81..0dabb40 100644 --- a/agents/rap-node-agent/internal/mesh/server_test.go +++ b/agents/rap-node-agent/internal/mesh/server_test.go @@ -23,6 +23,18 @@ import ( "github.com/gorilla/websocket" ) +type testProductionForwardTransport struct { + targets map[string]Server +} + +func (t testProductionForwardTransport) SendProduction(ctx context.Context, nextNodeID string, envelope ProductionEnvelope) (ProductionForwardResult, error) { + target, ok := t.targets[strings.TrimSpace(nextNodeID)] + if !ok { + return ProductionForwardResult{}, ErrForwardPeerUnavailable + } + return target.ForwardProduction(ctx, envelope) +} + func TestMeshHealthAcceptsSameCluster(t *testing.T) { local := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"} server := httptest.NewServer(Server{Local: local}.Handler()) @@ -92,8 +104,9 @@ func TestFabricSessionWebSocketDisabledByDefault(t *testing.T) { func TestFabricSessionWebSocketPingPongAndEvents(t *testing.T) { var events []FabricSessionEventLogEntry server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, + FabricSessionEnabled: true, + FabricSessionWebSocketEnabled: true, FabricSessionLogger: func(entry FabricSessionEventLogEntry) { events = append(events, entry) }, @@ -119,8 +132,9 @@ func TestFabricSessionWebSocketPingPongAndEvents(t *testing.T) { func TestFabricSessionWebSocketOpenStreamDataAck(t *testing.T) { server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, + FabricSessionEnabled: true, + FabricSessionWebSocketEnabled: true, }.Handler()) defer server.Close() @@ -151,8 +165,9 @@ func TestFabricSessionWebSocketOpenStreamDataAck(t *testing.T) { func TestFabricSessionWebSocketRequiresToken(t *testing.T) { server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, + FabricSessionEnabled: true, + FabricSessionWebSocketEnabled: true, }.Handler()) defer server.Close() @@ -172,9 +187,10 @@ func TestFabricSessionWebSocketRequiresSignedAuthorityWhenConfigured(t *testing. t.Fatalf("generate key: %v", err) } server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, + FabricSessionEnabled: true, + FabricSessionWebSocketEnabled: true, + ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), }.Handler()) defer server.Close() @@ -196,9 +212,10 @@ func TestFabricSessionWebSocketAcceptsSignedAuthority(t *testing.T) { token := "rap_fsn_signedtest" var events []FabricSessionEventLogEntry server := httptest.NewServer(Server{ - Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-a"}, - FabricSessionEnabled: true, - ClusterAuthorityPublicKey: base64.StdEncoding.EncodeToString(publicKey), + 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) }, @@ -360,23 +377,20 @@ func TestMeshForwardingGateDeliversFabricControlAtDestination(t *testing.T) { func TestMeshForwardingGateForwardsDirectFabricControlToNextHop(t *testing.T) { nodeC := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-c"} var deliveredObservation ProductionEnvelopeObservation - serverC := httptest.NewServer(Server{ + serverC := Server{ Local: nodeC, ProductionForwardingEnabled: true, ProductionEnvelopeObserver: func(_ context.Context, observation ProductionEnvelopeObservation) error { deliveredObservation = observation return nil }, - }.Handler()) - defer serverC.Close() + } nodeB := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"} serverB := httptest.NewServer(Server{ Local: nodeB, ProductionForwardingEnabled: true, - ProductionForwardTransport: NewHTTPProductionForwardTransport(map[string]string{ - nodeC.NodeID: serverC.URL, - }), + ProductionForwardTransport: testProductionForwardTransport{targets: map[string]Server{nodeC.NodeID: serverC}}, }.Handler()) defer serverB.Close() @@ -414,36 +428,30 @@ func TestMeshForwardingGateForwardsMultiHopFabricControlByRoutePath(t *testing.T var deliveredObservation ProductionEnvelopeObservation var nodeREvents []ProductionForwardLogEntry var nodeBEvents []ProductionForwardLogEntry - serverC := httptest.NewServer(Server{ + serverC := Server{ Local: nodeC, ProductionForwardingEnabled: true, ProductionEnvelopeObserver: func(_ context.Context, observation ProductionEnvelopeObservation) error { deliveredObservation = observation return nil }, - }.Handler()) - defer serverC.Close() + } nodeR := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-r"} - serverR := httptest.NewServer(Server{ + serverR := Server{ Local: nodeR, ProductionForwardingEnabled: true, - ProductionForwardTransport: NewHTTPProductionForwardTransport(map[string]string{ - nodeC.NodeID: serverC.URL, - }), + ProductionForwardTransport: testProductionForwardTransport{targets: map[string]Server{nodeC.NodeID: serverC}}, ProductionForwardLogger: func(entry ProductionForwardLogEntry) { nodeREvents = append(nodeREvents, entry) }, - }.Handler()) - defer serverR.Close() + } nodeB := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"} serverB := httptest.NewServer(Server{ Local: nodeB, ProductionForwardingEnabled: true, - ProductionForwardTransport: NewHTTPProductionForwardTransport(map[string]string{ - nodeR.NodeID: serverR.URL, - }), + ProductionForwardTransport: testProductionForwardTransport{targets: map[string]Server{nodeR.NodeID: serverR}}, ProductionForwardLogger: func(entry ProductionForwardLogEntry) { nodeBEvents = append(nodeBEvents, entry) }, @@ -490,7 +498,7 @@ func TestMeshForwardingGateForwardsConfiguredProductionRoute(t *testing.T) { nodeC := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-c"} route := configuredProductionRoute("route-1", []string{"node-a", "node-b", "node-r", nodeC.NodeID}) var deliveredObservation ProductionEnvelopeObservation - serverC := httptest.NewServer(Server{ + serverC := Server{ Local: nodeC, ProductionForwardingEnabled: true, ProductionRoutes: []SyntheticRoute{route}, @@ -498,28 +506,22 @@ func TestMeshForwardingGateForwardsConfiguredProductionRoute(t *testing.T) { deliveredObservation = observation return nil }, - }.Handler()) - defer serverC.Close() + } nodeR := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-r"} - serverR := httptest.NewServer(Server{ + serverR := Server{ Local: nodeR, ProductionForwardingEnabled: true, ProductionRoutes: []SyntheticRoute{route}, - ProductionForwardTransport: NewHTTPProductionForwardTransport(map[string]string{ - nodeC.NodeID: serverC.URL, - }), - }.Handler()) - defer serverR.Close() + ProductionForwardTransport: testProductionForwardTransport{targets: map[string]Server{nodeC.NodeID: serverC}}, + } nodeB := PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"} serverB := httptest.NewServer(Server{ Local: nodeB, ProductionForwardingEnabled: true, ProductionRoutes: []SyntheticRoute{route}, - ProductionForwardTransport: NewHTTPProductionForwardTransport(map[string]string{ - nodeR.NodeID: serverR.URL, - }), + ProductionForwardTransport: testProductionForwardTransport{targets: map[string]Server{nodeR.NodeID: serverR}}, }.Handler()) defer serverB.Close() @@ -5016,3 +5018,30 @@ func TestSyntheticEndpointDisabledByDefault(t *testing.T) { t.Fatalf("status = %d, want %d", resp.StatusCode, http.StatusServiceUnavailable) } } + +func TestHTTPDataPlaneDisabledRequiresQUIC(t *testing.T) { + server := httptest.NewServer(Server{ + Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"}, + SyntheticRuntime: NewSyntheticRuntime(SyntheticRuntimeConfig{Enabled: true, Local: PeerIdentity{ClusterID: "cluster-1", NodeID: "node-b"}}), + DisableHTTPDataPlane: true, + }.Handler()) + defer server.Close() + + resp, err := http.Post(server.URL+"/mesh/v1/synthetic/probe", "application/json", bytes.NewReader([]byte(`{}`))) + if err != nil { + t.Fatalf("post synthetic probe: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusGone { + t.Fatalf("synthetic status = %d, want %d", resp.StatusCode, http.StatusGone) + } + + resp, err = http.Post(server.URL+"/mesh/v1/forward", "application/json", bytes.NewReader([]byte(`{}`))) + if err != nil { + t.Fatalf("post production forward: %v", err) + } + defer resp.Body.Close() + if resp.StatusCode != http.StatusGone { + t.Fatalf("forward status = %d, want %d", resp.StatusCode, http.StatusGone) + } +} diff --git a/agents/rap-node-agent/internal/mesh/synthetic_quic_transport.go b/agents/rap-node-agent/internal/mesh/synthetic_quic_transport.go new file mode 100644 index 0000000..0a9a5d3 --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/synthetic_quic_transport.go @@ -0,0 +1,268 @@ +package mesh + +import ( + "context" + "encoding/json" + "fmt" + "strings" + "sync/atomic" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" +) + +type QUICSyntheticTransport struct { + Targets map[string]FabricTransportTarget + RouteSets map[string]FabricRouteSet + Transport FabricTransport + Router FabricChannelRouter + Timeout time.Duration + Pressure *FabricRoutePressureTracker + Health *FabricRouteHealthTracker + sequence atomic.Uint64 +} + +type QUICSyntheticTransportSnapshot struct { + RoutePressure FabricRoutePressureSnapshot `json:"route_pressure"` + RouteHealth FabricRouteHealthSnapshot `json:"route_health,omitempty"` +} + +func NewQUICSyntheticTransportFromRouteSets(routeSets map[string]FabricRouteSet, transport FabricTransport) *QUICSyntheticTransport { + normalizedRouteSets := make(map[string]FabricRouteSet, len(routeSets)) + targets := make(map[string]FabricTransportTarget, len(routeSets)) + for nodeID, routeSet := range routeSets { + nodeID = strings.TrimSpace(nodeID) + if nodeID == "" { + continue + } + normalizedRouteSets[nodeID] = routeSet + if target, err := FabricTransportTargetForRoute(routeSet.Primary); err == nil { + targets[nodeID] = target + } + } + if transport == nil { + transport = NewQUICFabricTransport(nil) + } + return &QUICSyntheticTransport{ + Targets: targets, + RouteSets: normalizedRouteSets, + Transport: transport, + Router: NewFabricChannelRouter(FabricChannelRouterConfig{ + MaxAckLatencyMs: 2000, + MinRerouteInterval: 50 * time.Millisecond, + }), + Timeout: 10 * time.Second, + Pressure: NewFabricRoutePressureTracker(), + Health: NewFabricRouteHealthTracker(30 * time.Second), + } +} + +func (t *QUICSyntheticTransport) SendSynthetic(ctx context.Context, nextNodeID string, envelope SyntheticEnvelope) (SyntheticEnvelope, error) { + if t == nil || t.Transport == nil { + return SyntheticEnvelope{}, ErrSyntheticPeerUnavailable + } + nextNodeID = strings.TrimSpace(nextNodeID) + routeSet, ok := t.RouteSets[nextNodeID] + if !ok { + target, targetOK := t.Targets[nextNodeID] + if !targetOK || strings.TrimSpace(target.Endpoint) == "" { + return SyntheticEnvelope{}, ErrSyntheticPeerUnavailable + } + routeSet = FabricRouteSetForTransportTargets(envelope.ClusterID, envelope.From.NodeID, nextNodeID, []FabricTransportTarget{target}) + } + spec := FabricChannelSpec{ + ChannelID: fmt.Sprintf("synthetic-%d", t.sequence.Add(1)), + ClusterID: envelope.ClusterID, + SourceNodeID: envelope.From.NodeID, + TargetKind: FabricChannelTargetNode, + TargetID: nextNodeID, + TrafficClass: FabricServiceChannelReliable, + CreatedAt: time.Now().UTC(), + } + payload, err := json.Marshal(envelope) + if err != nil { + return SyntheticEnvelope{}, err + } + return t.sendSyntheticWithRouteSet(ctx, spec, routeSet, payload) +} + +func (t *QUICSyntheticTransport) sendSyntheticWithRouteSet(ctx context.Context, spec FabricChannelSpec, routeSet FabricRouteSet, payload []byte) (SyntheticEnvelope, error) { + router := t.Router + if router.Config.MaxRoutePressure == 0 { + router = NewFabricChannelRouter(FabricChannelRouterConfig{MaxAckLatencyMs: 2000, MinRerouteInterval: 50 * time.Millisecond}) + } + routeSet = t.routeSetForScheduling(routeSet) + channel, _, err := router.OpenChannel(spec, routeSet, time.Now().UTC()) + if err != nil { + return SyntheticEnvelope{}, err + } + timeout := t.Timeout + if timeout <= 0 { + timeout = 10 * time.Second + } + for { + routeSet = t.routeSetForScheduling(routeSet) + route, ok := findFabricRoute(routeSet, channel.RouteID) + if !ok { + return SyntheticEnvelope{}, ErrFabricRouteNotFound + } + target, err := FabricTransportTargetForRoute(route) + if err != nil { + return SyntheticEnvelope{}, err + } + target.PeerID = firstNonEmpty(strings.TrimSpace(target.PeerID), spec.TargetID) + target.MaxPayload = fabricproto.DefaultMaxPayload + releaseRoute := t.acquireSyntheticRoute(route.RouteID) + session, err := t.Transport.Connect(ctx, target) + if err != nil { + releaseRoute() + t.markSyntheticRouteFailure(route.RouteID, err) + updated, event, rerouteErr := router.ObserveChannel(channel, routeSet, FabricChannelObservation{ + ChannelID: spec.ChannelID, + RouteID: route.RouteID, + Failed: true, + Reason: "connect_failed", + ObservedAt: time.Now().UTC(), + }, time.Now().UTC()) + channel = updated + if event.Type == FabricChannelRouteEventReroute { + continue + } + if rerouteErr != nil { + return SyntheticEnvelope{}, rerouteErr + } + return SyntheticEnvelope{}, fmt.Errorf("%w: %v", ErrSyntheticPeerUnavailable, err) + } + response, ackMs, err := t.sendSyntheticOnSession(ctx, session, payload, timeout) + _ = session.Close() + releaseRoute() + if err == nil { + t.markSyntheticRouteSuccess(route.RouteID) + _, _, _ = router.ObserveChannel(channel, routeSet, FabricChannelObservation{ + ChannelID: spec.ChannelID, + RouteID: route.RouteID, + AckLatencyMs: ackMs, + BytesSent: uint64(len(payload)), + FramesSent: 1, + BytesRecv: uint64(len(response.Payload)), + FramesRecv: 1, + ObservedAt: time.Now().UTC(), + }, time.Now().UTC()) + return decodeQUICSyntheticForwardResponse(response.Payload) + } + t.markSyntheticRouteFailure(route.RouteID, err) + updated, event, rerouteErr := router.ObserveChannel(channel, routeSet, FabricChannelObservation{ + ChannelID: spec.ChannelID, + RouteID: route.RouteID, + Failed: true, + Reason: "response_failed", + ObservedAt: time.Now().UTC(), + }, time.Now().UTC()) + channel = updated + if event.Type == FabricChannelRouteEventReroute { + continue + } + if rerouteErr != nil { + return SyntheticEnvelope{}, rerouteErr + } + return SyntheticEnvelope{}, fmt.Errorf("%w: %v", ErrSyntheticPeerUnavailable, err) + } +} + +func (t *QUICSyntheticTransport) routeSetForScheduling(routeSet FabricRouteSet) FabricRouteSet { + if t != nil && t.Health != nil { + routeSet = t.Health.Apply(routeSet, time.Now().UTC()) + } + if t != nil && t.Pressure != nil { + routeSet = t.Pressure.Apply(routeSet) + } + return routeSet +} + +func (t *QUICSyntheticTransport) acquireSyntheticRoute(routeID string) func() { + if t == nil || t.Pressure == nil { + return func() {} + } + return t.Pressure.Acquire(routeID) +} + +func (t *QUICSyntheticTransport) markSyntheticRouteFailure(routeID string, err error) { + if t == nil || t.Health == nil || err == nil { + return + } + t.Health.MarkFailure(routeID, err.Error(), time.Now().UTC()) +} + +func (t *QUICSyntheticTransport) markSyntheticRouteSuccess(routeID string) { + if t == nil || t.Health == nil { + return + } + t.Health.MarkSuccess(routeID) +} + +func (t *QUICSyntheticTransport) Snapshot() QUICSyntheticTransportSnapshot { + if t == nil { + return QUICSyntheticTransportSnapshot{} + } + var pressure FabricRoutePressureSnapshot + if t.Pressure != nil { + pressure = t.Pressure.SnapshotPressure() + } + var health FabricRouteHealthSnapshot + if t.Health != nil { + health = t.Health.Snapshot(time.Now().UTC()) + } + return QUICSyntheticTransportSnapshot{RoutePressure: pressure, RouteHealth: health} +} + +func (t *QUICSyntheticTransport) sendSyntheticOnSession(ctx context.Context, session FabricTransportSession, payload []byte, timeout time.Duration) (fabricproto.Frame, int64, error) { + sequence := t.sequence.Add(1) + if err := session.Send(ctx, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: fabricproto.TrafficClassReliable, + StreamID: SyntheticForwardQUICStreamID, + Sequence: sequence, + Payload: payload, + }); err != nil { + return fabricproto.Frame{}, 0, err + } + waitCtx := ctx + if timeout > 0 { + var cancel context.CancelFunc + waitCtx, cancel = context.WithTimeout(ctx, timeout) + defer cancel() + } + started := time.Now() + for { + select { + case <-waitCtx.Done(): + return fabricproto.Frame{}, 0, waitCtx.Err() + case err, ok := <-session.Errors(): + if !ok { + return fabricproto.Frame{}, 0, ErrSyntheticPeerUnavailable + } + if err != nil { + return fabricproto.Frame{}, 0, err + } + case frame, ok := <-session.Frames(): + if !ok { + return fabricproto.Frame{}, 0, ErrSyntheticPeerUnavailable + } + if frame.Type != fabricproto.FrameData || frame.StreamID != SyntheticForwardQUICStreamID || frame.Sequence != sequence { + continue + } + return frame, time.Since(started).Milliseconds(), nil + } + } +} + +func decodeQUICSyntheticForwardResponse(payload []byte) (SyntheticEnvelope, error) { + var response quicSyntheticForwardResponse + if err := json.Unmarshal(payload, &response); err != nil { + return SyntheticEnvelope{}, err + } + if strings.TrimSpace(response.Error) != "" { + return SyntheticEnvelope{}, fmt.Errorf("%w: %s", ErrSyntheticPeerUnavailable, response.Error) + } + return response.Envelope, nil +} diff --git a/agents/rap-node-agent/internal/mesh/synthetic_quic_transport_test.go b/agents/rap-node-agent/internal/mesh/synthetic_quic_transport_test.go new file mode 100644 index 0000000..62c1bbf --- /dev/null +++ b/agents/rap-node-agent/internal/mesh/synthetic_quic_transport_test.go @@ -0,0 +1,223 @@ +package mesh + +import ( + "context" + "crypto/tls" + "encoding/json" + "sync" + "testing" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" +) + +func TestQUICSyntheticTransportReroutesOnConnectFailure(t *testing.T) { + transport := newFakeSyntheticFabricTransport() + transport.failConnect["quic://dead.example.test:19443"] = true + transport.responses["quic://fast.example.test:19443"] = testSyntheticAckEnvelope("route-1", 1) + forward := NewQUICSyntheticTransportFromRouteSets(map[string]FabricRouteSet{ + "node-b": FabricRouteSetForTransportTargets("cluster-a", "node-a", "node-b", []FabricTransportTarget{ + {EndpointID: "dead", PeerID: "node-b", Endpoint: "quic://dead.example.test:19443", Transport: "quic"}, + {EndpointID: "fast", PeerID: "node-b", Endpoint: "quic://fast.example.test:19443", Transport: "quic"}, + }), + }, transport) + forward.Timeout = time.Second + + ack, err := forward.SendSynthetic(context.Background(), "node-b", testSyntheticEnvelope("route-1", 1)) + if err != nil { + t.Fatalf("send synthetic: %v", err) + } + if ack.RouteID != "route-1" || ack.MessageType != SyntheticMessageRouteHealthAck { + t.Fatalf("ack = %+v", ack) + } + if got := transport.connectCount("quic://dead.example.test:19443"); got != 1 { + t.Fatalf("dead connect count = %d, want 1", got) + } + if got := transport.connectCount("quic://fast.example.test:19443"); got != 1 { + t.Fatalf("fast connect count = %d, want 1", got) + } +} + +func TestQUICFabricServerHandlesSyntheticFrames(t *testing.T) { + server, err := StartQUICFabricServer(context.Background(), QUICFabricServerConfig{ + ListenAddr: "127.0.0.1:0", + TLSConfig: testQUICTLSConfig(t), + SyntheticForwardHandler: func(_ context.Context, envelope SyntheticEnvelope) (SyntheticEnvelope, error) { + return testSyntheticAckEnvelope(envelope.RouteID, envelope.Sequence), 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() + session, err := NewQUICFabricTransport(nil).Connect(ctx, FabricTransportTarget{ + Endpoint: server.Addr().String(), + TLSConfig: &tls.Config{ + InsecureSkipVerify: true, + NextProtos: []string{fabricQUICNextProto}, + }, + Timeout: time.Second, + InboundBuffer: 4, + ErrorBuffer: 4, + }) + if err != nil { + t.Fatalf("connect: %v", err) + } + defer session.Close() + + payload, err := json.Marshal(testSyntheticEnvelope("route-1", 7)) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + if err := session.Send(ctx, fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: fabricproto.TrafficClassReliable, + StreamID: SyntheticForwardQUICStreamID, + Sequence: 42, + Payload: payload, + }); err != nil { + t.Fatalf("send synthetic frame: %v", err) + } + select { + case frame := <-session.Frames(): + if frame.StreamID != SyntheticForwardQUICStreamID || frame.Sequence != 42 { + t.Fatalf("frame = %+v", frame) + } + ack, err := decodeQUICSyntheticForwardResponse(frame.Payload) + if err != nil { + t.Fatalf("decode response: %v", err) + } + if ack.RouteID != "route-1" || ack.MessageType != SyntheticMessageRouteHealthAck || ack.Sequence != 7 { + t.Fatalf("ack = %+v", ack) + } + case err := <-session.Errors(): + t.Fatalf("session error: %v", err) + case <-ctx.Done(): + t.Fatal(ctx.Err()) + } +} + +type fakeSyntheticFabricTransport struct { + mu sync.Mutex + failConnect map[string]bool + responses map[string]SyntheticEnvelope + connects map[string]int +} + +func newFakeSyntheticFabricTransport() *fakeSyntheticFabricTransport { + return &fakeSyntheticFabricTransport{ + failConnect: map[string]bool{}, + responses: map[string]SyntheticEnvelope{}, + connects: map[string]int{}, + } +} + +func (t *fakeSyntheticFabricTransport) Connect(_ context.Context, target FabricTransportTarget) (FabricTransportSession, error) { + endpoint := target.Endpoint + t.mu.Lock() + t.connects[endpoint]++ + fail := t.failConnect[endpoint] + response := t.responses[endpoint] + t.mu.Unlock() + if fail { + return nil, ErrSyntheticPeerUnavailable + } + return &fakeSyntheticFabricSession{ + response: response, + frames: make(chan fabricproto.Frame, 16), + errors: make(chan error, 1), + done: make(chan struct{}), + }, nil +} + +func (t *fakeSyntheticFabricTransport) Close() error { + return nil +} + +func (t *fakeSyntheticFabricTransport) connectCount(endpoint string) int { + t.mu.Lock() + defer t.mu.Unlock() + return t.connects[endpoint] +} + +type fakeSyntheticFabricSession struct { + response SyntheticEnvelope + frames chan fabricproto.Frame + errors chan error + done chan struct{} + once sync.Once +} + +func (s *fakeSyntheticFabricSession) Send(_ context.Context, frame fabricproto.Frame) error { + if frame.Type != fabricproto.FrameData { + return nil + } + responsePayload, _ := json.Marshal(quicSyntheticForwardResponse{Envelope: s.response}) + go func() { + select { + case <-s.done: + case s.frames <- fabricproto.Frame{ + Type: fabricproto.FrameData, + TrafficClass: frame.TrafficClass, + StreamID: frame.StreamID, + Sequence: frame.Sequence, + Payload: responsePayload, + }: + } + }() + return nil +} + +func (s *fakeSyntheticFabricSession) Frames() <-chan fabricproto.Frame { + return s.frames +} + +func (s *fakeSyntheticFabricSession) Errors() <-chan error { + return s.errors +} + +func (s *fakeSyntheticFabricSession) Close() error { + s.once.Do(func() { + close(s.done) + }) + return nil +} + +func (s *fakeSyntheticFabricSession) Closed() bool { + select { + case <-s.done: + return true + default: + return false + } +} + +func testSyntheticEnvelope(routeID string, sequence uint64) SyntheticEnvelope { + now := time.Now().UTC() + return SyntheticEnvelope{ + ProtocolVersion: ProtocolVersion, + RouteID: routeID, + ClusterID: "cluster-a", + From: PeerIdentity{ClusterID: "cluster-a", NodeID: "node-a"}, + To: PeerIdentity{ClusterID: "cluster-a", NodeID: "node-b"}, + Channel: SyntheticChannelFabricControl, + MessageType: SyntheticMessageRouteHealth, + TTL: 8, + HopCount: 1, + Visited: []string{"node-a"}, + Sequence: sequence, + SentAt: now, + } +} + +func testSyntheticAckEnvelope(routeID string, sequence uint64) SyntheticEnvelope { + ack := testSyntheticEnvelope(routeID, sequence) + ack.From = PeerIdentity{ClusterID: "cluster-a", NodeID: "node-b"} + ack.To = PeerIdentity{ClusterID: "cluster-a", NodeID: "node-a"} + ack.MessageType = SyntheticMessageRouteHealthAck + ack.Visited = []string{"node-a", "node-b"} + return ack +} diff --git a/agents/rap-node-agent/internal/state/state.go b/agents/rap-node-agent/internal/state/state.go index 9f92054..8572ce9 100644 --- a/agents/rap-node-agent/internal/state/state.go +++ b/agents/rap-node-agent/internal/state/state.go @@ -13,17 +13,18 @@ import ( const FileName = "identity.json" type Identity struct { - NodeID string `json:"node_id"` - ClusterID string `json:"cluster_id"` - NodeName string `json:"node_name"` - NodeFingerprint string `json:"node_fingerprint"` - PublicKey string `json:"public_key"` - IdentityStatus string `json:"identity_status"` - PendingJoinRequestID string `json:"pending_join_request_id,omitempty"` - ClusterAuthorityPublicKey string `json:"cluster_authority_public_key,omitempty"` - ClusterAuthorityFingerprint string `json:"cluster_authority_fingerprint,omitempty"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + NodeID string `json:"node_id"` + ClusterID string `json:"cluster_id"` + NodeName string `json:"node_name"` + NodeFingerprint string `json:"node_fingerprint"` + PublicKey string `json:"public_key"` + IdentityStatus string `json:"identity_status"` + PendingJoinRequestID string `json:"pending_join_request_id,omitempty"` + ClusterAuthorityPublicKey string `json:"cluster_authority_public_key,omitempty"` + ClusterAuthorityFingerprint string `json:"cluster_authority_fingerprint,omitempty"` + ClusterAuthorityQuorum json.RawMessage `json:"cluster_authority_quorum,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } func LoadOrCreate(dir, clusterID, nodeName string) (Identity, error) { @@ -103,6 +104,10 @@ func MarkApproved(dir string, nodeID, clusterID, status string) (Identity, error } func MarkApprovedWithAuthority(dir string, nodeID, clusterID, status, authorityPublicKey, authorityFingerprint string) (Identity, error) { + return MarkApprovedWithAuthorityAndQuorum(dir, nodeID, clusterID, status, authorityPublicKey, authorityFingerprint, nil) +} + +func MarkApprovedWithAuthorityAndQuorum(dir string, nodeID, clusterID, status, authorityPublicKey, authorityFingerprint string, authorityQuorum json.RawMessage) (Identity, error) { path := filepath.Join(dir, FileName) identity, err := Load(path) if err != nil { @@ -114,6 +119,7 @@ func MarkApprovedWithAuthority(dir string, nodeID, clusterID, status, authorityP identity.PendingJoinRequestID = "" identity.ClusterAuthorityPublicKey = authorityPublicKey identity.ClusterAuthorityFingerprint = authorityFingerprint + identity.ClusterAuthorityQuorum = authorityQuorum if err := Save(path, identity); err != nil { return Identity{}, err } diff --git a/agents/rap-node-agent/internal/supervisor/supervisor.go b/agents/rap-node-agent/internal/supervisor/supervisor.go index 8c91628..f3eba49 100644 --- a/agents/rap-node-agent/internal/supervisor/supervisor.go +++ b/agents/rap-node-agent/internal/supervisor/supervisor.go @@ -2,10 +2,12 @@ package supervisor import ( "context" + "strconv" "strings" "time" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/client" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/webingress" ) type Supervisor interface { @@ -14,6 +16,8 @@ type Supervisor interface { type StubSupervisor struct { Version string + WebIngressRuntimeEnabled bool + WebIngressManager *webingress.Manager RemoteWorkspaceRealAdapter RemoteWorkspaceRealAdapterConfig } @@ -56,6 +60,9 @@ func (s StubSupervisor) applyOne(workload client.DesiredWorkload) client.Workloa } if desiredState != "enabled" { payload["reason"] = "desired_state_not_enabled" + if (serviceType == "public-ingress" || serviceType == "admin-ingress") && s.WebIngressManager != nil { + payload["listener_status"] = s.WebIngressManager.Stop(context.Background()) + } return client.WorkloadStatusRequest{ ReportedState: "stopped", RuntimeMode: runtimeMode, @@ -74,6 +81,57 @@ func (s StubSupervisor) applyOne(workload client.DesiredWorkload) client.Workloa StatusPayload: payload, } } + if serviceType == "public-ingress" || serviceType == "admin-ingress" { + contract := s.webIngressContract(serviceType, workload.Config) + for key, value := range contract { + payload[key] = value + } + if contract["contract_valid"] == true { + payload["reason"] = "web_ingress_contract_ready" + payload["execution_mode"] = "contract_probe" + payload["traffic"] = "https_edge_to_fabric_service_channel" + if contract["real_listener_requested"] == true && contract["real_listener_runtime_enabled"] != true { + payload["reason"] = "web_ingress_real_listener_gate_disabled" + payload["traffic"] = "blocked" + return client.WorkloadStatusRequest{ + ReportedState: "degraded", + RuntimeMode: runtimeMode, + Version: version, + StatusPayload: payload, + } + } + if contract["real_listener_start_allowed"] == true && s.WebIngressManager != nil { + listenerStatus := s.WebIngressManager.Apply(context.Background(), webIngressListenerConfig(serviceType, workload.Config)) + payload["listener_status"] = listenerStatus + payload["ports_opened_by_runtime"] = listenerStatus.Running + payload["ports_opened_by_stub"] = false + if !listenerStatus.HTTPSRunning { + payload["reason"] = "web_ingress_listener_partial" + payload["traffic"] = "blocked" + return client.WorkloadStatusRequest{ + ReportedState: "degraded", + RuntimeMode: runtimeMode, + Version: version, + StatusPayload: payload, + } + } + } + return client.WorkloadStatusRequest{ + ReportedState: "running", + RuntimeMode: runtimeMode, + Version: version, + StatusPayload: payload, + } + } + payload["reason"] = "web_ingress_contract_invalid" + payload["traffic"] = "blocked" + return client.WorkloadStatusRequest{ + ReportedState: "degraded", + RuntimeMode: runtimeMode, + Version: version, + StatusPayload: payload, + } + } if serviceType == "synthetic.echo" && runtimeMode == "native" { payload["reason"] = "internal_synthetic_echo_ready" payload["execution_mode"] = "builtin" @@ -85,6 +143,23 @@ func (s StubSupervisor) applyOne(workload client.DesiredWorkload) client.Workloa StatusPayload: payload, } } + if (serviceType == "vpn-exit" || serviceType == "ipv4-egress" || serviceType == "vpn-client") && runtimeMode == "native" { + for key, value := range vpnFabricOnlyContract(serviceType, workload.Config) { + payload[key] = value + } + payload["execution_mode"] = "contract_probe" + payload["fabric_transport"] = "quic_only" + payload["fabric_service_channel_required"] = true + payload["backend_relay_fallback"] = false + payload["legacy_protocol_compatibility"] = false + payload["traffic"] = "fabric_service_channel_only" + return client.WorkloadStatusRequest{ + ReportedState: "running", + RuntimeMode: runtimeMode, + Version: version, + StatusPayload: payload, + } + } if serviceType == "rdp-worker" && runtimeMode == "native" && boolConfig(workload.Config, "adapter_contract_probe") { payload["reason"] = "remote_workspace_adapter_contract_probe_ready" payload["execution_mode"] = "contract_probe" @@ -126,6 +201,173 @@ func (s StubSupervisor) applyOne(workload client.DesiredWorkload) client.Workloa } } +func vpnFabricOnlyContract(serviceType string, config map[string]any) map[string]any { + role := "vpn-client" + reason := "vpn_client_node_contract_ready" + serviceClass := "vpn_packets" + internetEgress := false + if serviceType == "vpn-exit" || serviceType == "ipv4-egress" { + role = "ipv4-egress" + reason = "ipv4_egress_contract_ready" + internetEgress = true + } + contract := map[string]any{ + "schema_version": "rap.vpn.fabric_node_contract.v1", + "reason": reason, + "role": role, + "service_class": serviceClass, + "internet_egress": internetEgress, + "exit_pool_id": stringConfig(config, "pool_id", ""), + "exit_region": stringConfig(config, "region", ""), + "allowed_cidrs": stringSliceConfig(config, "allowed_cidrs"), + "dns_servers": stringSliceConfig(config, "dns_servers"), + "client_policy_source": stringConfig(config, "client_policy_source", "fabric_access_policy"), + "android_node_supported": serviceType == "vpn-client", + "ipv4_exit_supported": internetEgress, + "fabric_service_channel_required": true, + "packet_runtime_status": "fabric_channel_binding_pending_runtime", + "service_binding": vpnServiceBindingContract(serviceType, config), + } + return contract +} + +func vpnServiceBindingContract(serviceType string, config map[string]any) map[string]any { + if serviceType == "vpn-exit" || serviceType == "ipv4-egress" { + return map[string]any{ + "type": "ipv4_egress", + "accepts_service_class": "vpn_packets", + "accepts_from_fabric_only": true, + "legacy_protocol_listener": false, + "exit_pool_id": stringConfig(config, "pool_id", ""), + "region": stringConfig(config, "region", ""), + "allowed_cidrs": stringSliceConfig(config, "allowed_cidrs"), + "dns_servers": stringSliceConfig(config, "dns_servers"), + "internet_egress": true, + "requires_host_packet_runtime": true, + } + } + return map[string]any{ + "type": "local_ipv4_ingress", + "accepts_from": []string{"android_vpnservice_tun", "linux_tun", "host_service_port"}, + "service_class": "vpn_packets", + "exit_selection": "pool", + "preferred_exit_pool_id": stringConfig(config, "exit_pool_id", ""), + "listen_tcp_ports": intSliceConfig(config, "listen_tcp_ports"), + "listen_udp_ports": intSliceConfig(config, "listen_udp_ports"), + "tun_required": true, + "route_authority": "fabric_farm", + "legacy_protocol_listener": false, + "requires_fabric_node_runtime": true, + } +} + +func webIngressListenerConfig(serviceType string, config map[string]any) webingress.ListenerConfig { + return webingress.ListenerConfig{ + RuntimeConfig: webingress.RuntimeConfig{ + ServiceType: serviceType, + Scope: stringConfig(config, "scope", ""), + ServiceClasses: stringSliceConfig(config, "service_classes"), + TLSMode: stringConfig(config, "tls_mode", "terminate"), + HTTPPort: intConfig(config, "listen_http_port", 80), + HTTPSPort: intConfig(config, "listen_https_port", 443), + }, + HTTPAddr: stringConfig(config, "listen_http_addr", ":80"), + HTTPSAddr: stringConfig(config, "listen_https_addr", ":443"), + TLSCertFile: stringConfig(config, "tls_cert_file", ""), + TLSKeyFile: stringConfig(config, "tls_key_file", ""), + } +} + +func (s StubSupervisor) webIngressContract(serviceType string, config map[string]any) map[string]any { + httpPort := intConfig(config, "listen_http_port", 80) + httpsPort := intConfig(config, "listen_https_port", 443) + tlsMode := strings.TrimSpace(stringConfig(config, "tls_mode", "terminate")) + serviceClasses := stringSliceConfig(config, "service_classes") + scope := strings.TrimSpace(stringConfig(config, "scope", "")) + realListenerRequested := boolConfig(config, "real_listener_enabled") + allowedClasses := webIngressAllowedServiceClasses(serviceType) + missing := []string{} + if httpPort != 80 { + missing = append(missing, "listen_http_port_must_be_80") + } + if httpsPort != 443 { + missing = append(missing, "listen_https_port_must_be_443") + } + if tlsMode != "terminate" && tlsMode != "passthrough-approved-terminator" { + missing = append(missing, "tls_mode_invalid") + } + if scope == "" { + missing = append(missing, "scope_required") + } + if len(serviceClasses) == 0 { + missing = append(missing, "service_classes_required") + } + for _, serviceClass := range serviceClasses { + if !containsString(allowedClasses, serviceClass) { + missing = append(missing, "service_class_not_allowed:"+serviceClass) + } + } + return map[string]any{ + "schema_version": "rap.web_ingress.workload_contract.v1", + "contract_valid": len(missing) == 0, + "missing_checks": missing, + "service_edge_only": true, + "authority_service": false, + "fabric_transport": "quic_only", + "http_between_fabric_nodes": false, + "listen_http_port": httpPort, + "listen_https_port": httpsPort, + "tls_mode": tlsMode, + "scope": scope, + "service_classes": serviceClasses, + "allowed_service_classes": allowedClasses, + "fabric_service_channel_required": true, + "runtime_roles_required": webIngressRuntimeRoles(serviceClasses), + "payload_forwarding": "contract_only", + "real_listener_requested": realListenerRequested, + "real_listener_runtime_enabled": s.WebIngressRuntimeEnabled, + "real_listener_start_allowed": len(missing) == 0 && realListenerRequested && s.WebIngressRuntimeEnabled, + "runtime_handler_ready": len(missing) == 0, + "runtime_handler_contract": "rap.web_ingress.runtime_response.v1", + "runtime_handler_payload_status": "fabric_service_channel_binding_not_implemented", + "fabric_envelope_schema": webingress.FabricServiceChannelEnvelopeSchema, + "fabric_runtime_response_schema": "rap.web_ingress.fabric_runtime_response.v1", + "fabric_envelope_signer": "ed25519_available", + "fabric_envelope_sender": "mesh_request_response_runtime_adapter_available", + "fabric_quic_stream": "web_ingress_forward", + "fabric_quic_stream_id": 2, + "fabric_runtime_receiver": "signed_envelope_receiver_available", + "admin_runtime_dispatcher": "read_only_manifest_and_health_available", + "control_api_binding": "read_only_projection_skeleton_available", + "runtime_receiver_policy": "trusted_keys_and_service_class_allow_list", + "ports_opened_by_stub": false, + } +} + +func webIngressAllowedServiceClasses(serviceType string) []string { + if serviceType == "admin-ingress" { + return []string{"platform_admin", "cluster_admin"} + } + return []string{"organization_portal", "user_portal"} +} + +func webIngressRuntimeRoles(serviceClasses []string) []string { + roles := []string{} + for _, serviceClass := range serviceClasses { + switch serviceClass { + case "platform_admin": + roles = append(roles, "global-admin-runtime", "identity-runtime", "policy-authority", "audit-sink") + case "cluster_admin": + roles = append(roles, "cluster-admin-runtime", "identity-runtime", "policy-authority", "audit-sink") + case "organization_portal": + roles = append(roles, "organization-portal-runtime", "identity-runtime", "policy-authority", "audit-sink") + case "user_portal": + roles = append(roles, "user-portal-runtime", "identity-runtime", "policy-authority", "audit-sink") + } + } + return dedupeStrings(roles) +} + func boolConfig(values map[string]any, key string) bool { if values == nil { return false @@ -144,6 +386,157 @@ func boolConfig(values map[string]any, key string) bool { } } +func intConfig(values map[string]any, key string, fallback int) int { + if values == nil { + return fallback + } + switch value := values[key].(type) { + case int: + return value + case int64: + return int(value) + case float64: + return int(value) + case string: + parsed, err := strconv.Atoi(strings.TrimSpace(value)) + if err != nil { + return fallback + } + return parsed + default: + return fallback + } +} + +func stringConfig(values map[string]any, key string, fallback string) string { + if values == nil { + return fallback + } + value, ok := values[key] + if !ok { + return fallback + } + if text, ok := value.(string); ok { + return text + } + return fallback +} + +func stringSliceConfig(values map[string]any, key string) []string { + if values == nil { + return nil + } + value, ok := values[key] + if !ok { + return nil + } + switch typed := value.(type) { + case []string: + return dedupeStrings(typed) + case []any: + out := []string{} + for _, item := range typed { + if text, ok := item.(string); ok { + out = append(out, strings.TrimSpace(text)) + } + } + return dedupeStrings(out) + case string: + parts := strings.Split(typed, ",") + for index := range parts { + parts[index] = strings.TrimSpace(parts[index]) + } + return dedupeStrings(parts) + default: + return nil + } +} + +func intSliceConfig(values map[string]any, key string) []int { + if values == nil { + return nil + } + value, ok := values[key] + if !ok { + return nil + } + add := func(out []int, item any) []int { + switch typed := item.(type) { + case int: + if typed > 0 { + out = append(out, typed) + } + case int64: + if typed > 0 { + out = append(out, int(typed)) + } + case float64: + if typed > 0 { + out = append(out, int(typed)) + } + case string: + if parsed := intConfig(map[string]any{"value": typed}, "value", 0); parsed > 0 { + out = append(out, parsed) + } + } + return out + } + out := []int{} + switch typed := value.(type) { + case []int: + out = append(out, typed...) + case []any: + for _, item := range typed { + out = add(out, item) + } + case string: + for _, part := range strings.Split(typed, ",") { + out = add(out, strings.TrimSpace(part)) + } + default: + out = add(out, typed) + } + seen := map[int]struct{}{} + cleaned := make([]int, 0, len(out)) + for _, port := range out { + if port <= 0 || port > 65535 { + continue + } + if _, ok := seen[port]; ok { + continue + } + seen[port] = struct{}{} + cleaned = append(cleaned, port) + } + return cleaned +} + +func dedupeStrings(values []string) []string { + out := []string{} + seen := map[string]struct{}{} + for _, value := range values { + normalized := strings.TrimSpace(value) + if normalized == "" { + continue + } + if _, ok := seen[normalized]; ok { + continue + } + seen[normalized] = struct{}{} + out = append(out, normalized) + } + return out +} + +func containsString(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} + func remoteWorkspaceAdapterChannels() []map[string]any { return []map[string]any{ {"name": "input", "direction": "client_to_adapter", "reliability": "reliable_ordered", "priority": "critical", "droppable": true, "may_block_input": false}, diff --git a/agents/rap-node-agent/internal/supervisor/supervisor_test.go b/agents/rap-node-agent/internal/supervisor/supervisor_test.go index 78b7d23..9d89b7e 100644 --- a/agents/rap-node-agent/internal/supervisor/supervisor_test.go +++ b/agents/rap-node-agent/internal/supervisor/supervisor_test.go @@ -5,6 +5,7 @@ import ( "testing" "github.com/example/remote-access-platform/agents/rap-node-agent/internal/client" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/webingress" ) func TestStubSupervisorReportsDegradedForEnabledWorkload(t *testing.T) { @@ -73,6 +74,245 @@ func TestStubSupervisorReportsBuiltinFabricServicesRunning(t *testing.T) { } } +func TestStubSupervisorReportsVPNFabricOnlyContractsRunning(t *testing.T) { + statuses, err := (StubSupervisor{Version: "test"}).Apply(context.Background(), []client.DesiredWorkload{ + { + ServiceType: "ipv4-egress", + DesiredState: "enabled", + RuntimeMode: "native", + Config: map[string]any{ + "pool_id": "us-los-angeles-ipv4", + "region": "us-los-angeles", + "allowed_cidrs": []any{"0.0.0.0/0"}, + "dns_servers": []any{"192.168.200.210"}, + }, + }, + { + ServiceType: "vpn-client", + DesiredState: "enabled", + RuntimeMode: "native", + Config: map[string]any{ + "exit_pool_id": "us-los-angeles-ipv4", + "listen_tcp_ports": []any{443, "8443"}, + "listen_udp_ports": "443,51820", + }, + }, + }) + if err != nil { + t.Fatalf("apply desired workload: %v", err) + } + if len(statuses) != 2 { + t.Fatalf("statuses length = %d", len(statuses)) + } + for _, status := range statuses { + if status.ReportedState != "running" { + t.Fatalf("ReportedState = %q", status.ReportedState) + } + if status.StatusPayload["execution_mode"] != "contract_probe" { + t.Fatalf("execution_mode = %v", status.StatusPayload["execution_mode"]) + } + if status.StatusPayload["fabric_transport"] != "quic_only" { + t.Fatalf("fabric_transport = %v", status.StatusPayload["fabric_transport"]) + } + if status.StatusPayload["backend_relay_fallback"] != false { + t.Fatalf("backend_relay_fallback = %v", status.StatusPayload["backend_relay_fallback"]) + } + if status.StatusPayload["legacy_protocol_compatibility"] != false { + t.Fatalf("legacy_protocol_compatibility = %v", status.StatusPayload["legacy_protocol_compatibility"]) + } + } + if statuses[0].StatusPayload["role"] != "ipv4-egress" || statuses[0].StatusPayload["internet_egress"] != true { + t.Fatalf("ipv4 egress payload = %#v", statuses[0].StatusPayload) + } + if statuses[1].StatusPayload["role"] != "vpn-client" || statuses[1].StatusPayload["android_node_supported"] != true { + t.Fatalf("vpn client payload = %#v", statuses[1].StatusPayload) + } + exitBinding := statuses[0].StatusPayload["service_binding"].(map[string]any) + if exitBinding["type"] != "ipv4_egress" || exitBinding["accepts_from_fabric_only"] != true || exitBinding["exit_pool_id"] != "us-los-angeles-ipv4" { + t.Fatalf("ipv4 egress binding = %#v", exitBinding) + } + clientBinding := statuses[1].StatusPayload["service_binding"].(map[string]any) + if clientBinding["type"] != "local_ipv4_ingress" || clientBinding["preferred_exit_pool_id"] != "us-los-angeles-ipv4" || clientBinding["legacy_protocol_listener"] != false { + t.Fatalf("vpn client binding = %#v", clientBinding) + } + if got := clientBinding["listen_tcp_ports"].([]int); len(got) != 2 || got[0] != 443 || got[1] != 8443 { + t.Fatalf("listen_tcp_ports = %#v", got) + } + if got := clientBinding["listen_udp_ports"].([]int); len(got) != 2 || got[0] != 443 || got[1] != 51820 { + t.Fatalf("listen_udp_ports = %#v", got) + } +} + +func TestStubSupervisorReportsWebIngressContractReady(t *testing.T) { + statuses, err := (StubSupervisor{Version: "test"}).Apply(context.Background(), []client.DesiredWorkload{ + { + ServiceType: "admin-ingress", + DesiredState: "enabled", + RuntimeMode: "native", + Config: map[string]any{ + "listen_http_port": 80, + "listen_https_port": 443, + "tls_mode": "terminate", + "scope": "platform", + "service_classes": []any{"platform_admin", "cluster_admin"}, + }, + }, + }) + if err != nil { + t.Fatalf("apply desired workload: %v", err) + } + if statuses[0].ReportedState != "running" { + t.Fatalf("ReportedState = %q", statuses[0].ReportedState) + } + payload := statuses[0].StatusPayload + if payload["reason"] != "web_ingress_contract_ready" || + payload["fabric_transport"] != "quic_only" || + payload["http_between_fabric_nodes"] != false || + payload["authority_service"] != false || + payload["real_listener_start_allowed"] != false || + payload["runtime_handler_ready"] != true || + payload["runtime_handler_payload_status"] != "fabric_service_channel_binding_not_implemented" || + payload["ports_opened_by_stub"] != false { + t.Fatalf("unexpected payload: %#v", payload) + } + roles, ok := payload["runtime_roles_required"].([]string) + if !ok || !containsString(roles, "global-admin-runtime") || !containsString(roles, "policy-authority") { + t.Fatalf("runtime roles = %#v", payload["runtime_roles_required"]) + } +} + +func TestStubSupervisorBlocksWebIngressRealListenerWithoutRuntimeGate(t *testing.T) { + statuses, err := (StubSupervisor{Version: "test"}).Apply(context.Background(), []client.DesiredWorkload{ + { + ServiceType: "admin-ingress", + DesiredState: "enabled", + RuntimeMode: "native", + Config: map[string]any{ + "listen_http_port": 80, + "listen_https_port": 443, + "tls_mode": "terminate", + "scope": "platform", + "service_classes": []any{"platform_admin"}, + "real_listener_enabled": true, + }, + }, + }) + if err != nil { + t.Fatalf("apply desired workload: %v", err) + } + if statuses[0].ReportedState != "degraded" { + t.Fatalf("ReportedState = %q", statuses[0].ReportedState) + } + payload := statuses[0].StatusPayload + if payload["reason"] != "web_ingress_real_listener_gate_disabled" || + payload["real_listener_requested"] != true || + payload["real_listener_runtime_enabled"] != false || + payload["real_listener_start_allowed"] != false || + payload["ports_opened_by_stub"] != false { + t.Fatalf("unexpected payload: %#v", payload) + } +} + +func TestStubSupervisorAllowsWebIngressRealListenerGateButDoesNotOpenPorts(t *testing.T) { + statuses, err := (StubSupervisor{Version: "test", WebIngressRuntimeEnabled: true}).Apply(context.Background(), []client.DesiredWorkload{ + { + ServiceType: "admin-ingress", + DesiredState: "enabled", + RuntimeMode: "native", + Config: map[string]any{ + "listen_http_port": 80, + "listen_https_port": 443, + "tls_mode": "terminate", + "scope": "platform", + "service_classes": []any{"platform_admin"}, + "real_listener_enabled": true, + }, + }, + }) + if err != nil { + t.Fatalf("apply desired workload: %v", err) + } + if statuses[0].ReportedState != "running" { + t.Fatalf("ReportedState = %q", statuses[0].ReportedState) + } + payload := statuses[0].StatusPayload + if payload["real_listener_requested"] != true || + payload["real_listener_runtime_enabled"] != true || + payload["real_listener_start_allowed"] != true || + payload["ports_opened_by_stub"] != false { + t.Fatalf("unexpected payload: %#v", payload) + } +} + +func TestStubSupervisorStartsWebIngressManagerWhenRealListenerAllowed(t *testing.T) { + manager := webingress.NewManager() + statuses, err := (StubSupervisor{Version: "test", WebIngressRuntimeEnabled: true, WebIngressManager: manager}).Apply(context.Background(), []client.DesiredWorkload{ + { + ServiceType: "admin-ingress", + DesiredState: "enabled", + RuntimeMode: "native", + Config: map[string]any{ + "listen_http_port": 80, + "listen_https_port": 443, + "listen_http_addr": "127.0.0.1:0", + "listen_https_addr": "127.0.0.1:0", + "tls_mode": "terminate", + "scope": "platform", + "service_classes": []any{"platform_admin"}, + "real_listener_enabled": true, + }, + }, + }) + if err != nil { + t.Fatalf("apply desired workload: %v", err) + } + if statuses[0].ReportedState != "degraded" { + t.Fatalf("ReportedState = %q", statuses[0].ReportedState) + } + payload := statuses[0].StatusPayload + listenerStatus, ok := payload["listener_status"].(webingress.ListenerStatus) + if !ok { + t.Fatalf("listener_status = %#v", payload["listener_status"]) + } + if !listenerStatus.HTTPRunning || listenerStatus.HTTPSRunning || listenerStatus.HTTPAddr == "" { + t.Fatalf("listener status = %+v", listenerStatus) + } + if payload["reason"] != "web_ingress_listener_partial" || payload["ports_opened_by_runtime"] != true || payload["ports_opened_by_stub"] != false { + t.Fatalf("payload = %#v", payload) + } + _ = manager.Stop(context.Background()) +} + +func TestStubSupervisorBlocksInvalidWebIngressContract(t *testing.T) { + statuses, err := (StubSupervisor{Version: "test"}).Apply(context.Background(), []client.DesiredWorkload{ + { + ServiceType: "public-ingress", + DesiredState: "enabled", + RuntimeMode: "native", + Config: map[string]any{ + "listen_http_port": 8080, + "listen_https_port": 443, + "scope": "organization", + "service_classes": []any{"platform_admin"}, + }, + }, + }) + if err != nil { + t.Fatalf("apply desired workload: %v", err) + } + if statuses[0].ReportedState != "degraded" { + t.Fatalf("ReportedState = %q", statuses[0].ReportedState) + } + payload := statuses[0].StatusPayload + if payload["reason"] != "web_ingress_contract_invalid" || payload["traffic"] != "blocked" { + t.Fatalf("unexpected payload: %#v", payload) + } + missing, ok := payload["missing_checks"].([]string) + if !ok || !containsString(missing, "listen_http_port_must_be_80") || !containsString(missing, "service_class_not_allowed:platform_admin") { + t.Fatalf("missing checks = %#v", payload["missing_checks"]) + } +} + func TestStubSupervisorKeepsUnsupportedEnabledWorkloadDegraded(t *testing.T) { statuses, err := (StubSupervisor{Version: "test"}).Apply(context.Background(), []client.DesiredWorkload{ {ServiceType: "rdp-worker", DesiredState: "enabled", RuntimeMode: "container"}, diff --git a/agents/rap-node-agent/internal/vpnruntime/fabric_session_registry.go b/agents/rap-node-agent/internal/vpnruntime/fabric_session_registry.go new file mode 100644 index 0000000..924ec17 --- /dev/null +++ b/agents/rap-node-agent/internal/vpnruntime/fabric_session_registry.go @@ -0,0 +1,189 @@ +package vpnruntime + +import ( + "context" + "sync" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh" +) + +type FabricSessionFrameWriter interface { + SendFrame(context.Context, fabricproto.Frame) error +} + +type FabricSessionPacketPeerRegistry struct { + mu sync.RWMutex + peers map[string]FabricSessionPacketPeer +} + +type FabricSessionPacketPeer struct { + VPNConnectionID string + Sender FabricSessionFrameWriter + StreamID uint64 + StreamIDsByTrafficClass map[string][]uint64 + RegisteredAt time.Time + LastPacketAt time.Time +} + +type FabricSessionPacketPeerTransport struct { + Registry *FabricSessionPacketPeerRegistry + Inbox *FabricPacketInbox + VPNConnectionID string +} + +func NewFabricSessionPacketPeerRegistry() *FabricSessionPacketPeerRegistry { + return &FabricSessionPacketPeerRegistry{peers: map[string]FabricSessionPacketPeer{}} +} + +func (r *FabricSessionPacketPeerRegistry) RegisterFrame(ctx context.Context, sender FabricSessionFrameWriter, frame fabricproto.Frame) (bool, error) { + if r == nil || sender == nil || frame.Type != fabricproto.FrameData || frame.StreamID == 0 { + return false, nil + } + payload, err := DecodeFabricVPNPacketDataFrame(frame) + if err != nil { + return false, nil + } + if payload.VPNConnectionID == "" { + return false, nil + } + now := time.Now().UTC() + r.mu.Lock() + if r.peers == nil { + r.peers = map[string]FabricSessionPacketPeer{} + } + peer := r.peers[payload.VPNConnectionID] + if peer.RegisteredAt.IsZero() { + peer.RegisteredAt = now + } + peer.VPNConnectionID = payload.VPNConnectionID + peer.Sender = sender + peer.StreamID = frame.StreamID + peer.LastPacketAt = now + if peer.StreamIDsByTrafficClass == nil { + peer.StreamIDsByTrafficClass = map[string][]uint64{} + } + trafficClass := fabricSessionTrafficClassName(frame.TrafficClass) + if trafficClass != "" && !containsUint64(peer.StreamIDsByTrafficClass[trafficClass], frame.StreamID) { + peer.StreamIDsByTrafficClass[trafficClass] = append(peer.StreamIDsByTrafficClass[trafficClass], frame.StreamID) + } + r.peers[payload.VPNConnectionID] = peer + r.mu.Unlock() + return true, nil +} + +func (r *FabricSessionPacketPeerRegistry) TransportFor(vpnConnectionID string, inbox *FabricPacketInbox) PacketTransport { + if r == nil || inbox == nil || vpnConnectionID == "" { + return nil + } + r.mu.RLock() + peer, ok := r.peers[vpnConnectionID] + r.mu.RUnlock() + if !ok || peer.Sender == nil || peer.StreamID == 0 { + return nil + } + return &FabricSessionPacketTransport{ + Sender: fabricSessionFrameWriterAdapter{writer: peer.Sender}, + Inbox: inbox, + StreamID: peer.StreamID, + StreamIDsByTrafficClass: copyStreamIDsByClass(peer.StreamIDsByTrafficClass), + VPNConnectionID: vpnConnectionID, + SendDirection: FabricDirectionGatewayToClient, + ReceiveDirection: FabricDirectionClientToGateway, + } +} + +func (t *FabricSessionPacketPeerTransport) SendGatewayPacketBatch(ctx context.Context, packets [][]byte) error { + if t == nil || t.Registry == nil || t.Inbox == nil || t.VPNConnectionID == "" { + return mesh.ErrForwardRuntimeUnavailable + } + transport := t.Registry.TransportFor(t.VPNConnectionID, t.Inbox) + if transport == nil { + return mesh.ErrForwardRuntimeUnavailable + } + return transport.SendGatewayPacketBatch(ctx, packets) +} + +func (t *FabricSessionPacketPeerTransport) ReceiveGatewayPacketBatch(ctx context.Context, timeout time.Duration) ([][]byte, error) { + if t == nil || t.Inbox == nil || t.VPNConnectionID == "" { + return nil, mesh.ErrForwardRuntimeUnavailable + } + return t.Inbox.Receive(ctx, t.VPNConnectionID, FabricDirectionClientToGateway, timeout) +} + +func (t *FabricSessionPacketPeerTransport) Snapshot() map[string]any { + if t == nil { + return map[string]any{ + "transport": "fabric_session_peer_dynamic", + "peer_ready": false, + } + } + ready := 0 + if t.Registry != nil { + if transport := t.Registry.TransportFor(t.VPNConnectionID, t.Inbox); transport != nil { + ready = 1 + } + } + return map[string]any{ + "transport": "fabric_session_peer_dynamic", + "vpn_connection_id": t.VPNConnectionID, + "peer_ready": ready == 1, + } +} + +func (r *FabricSessionPacketPeerRegistry) Snapshot() map[string]any { + if r == nil { + return map[string]any{"ready": 0} + } + r.mu.RLock() + defer r.mu.RUnlock() + out := map[string]any{"ready": len(r.peers)} + items := make([]map[string]any, 0, len(r.peers)) + for _, peer := range r.peers { + item := map[string]any{ + "vpn_connection_id": peer.VPNConnectionID, + "stream_id": peer.StreamID, + } + if !peer.RegisteredAt.IsZero() { + item["registered_at"] = peer.RegisteredAt.Format(time.RFC3339Nano) + } + if !peer.LastPacketAt.IsZero() { + item["last_packet_at"] = peer.LastPacketAt.Format(time.RFC3339Nano) + } + items = append(items, item) + } + out["peers"] = items + return out +} + +type fabricSessionFrameWriterAdapter struct { + writer FabricSessionFrameWriter +} + +func (a fabricSessionFrameWriterAdapter) Send(ctx context.Context, frame fabricproto.Frame) error { + if a.writer == nil { + return mesh.ErrForwardRuntimeUnavailable + } + return a.writer.SendFrame(ctx, frame) +} + +func containsUint64(values []uint64, value uint64) bool { + for _, item := range values { + if item == value { + return true + } + } + return false +} + +func copyStreamIDsByClass(values map[string][]uint64) map[string][]uint64 { + if len(values) == 0 { + return nil + } + out := make(map[string][]uint64, len(values)) + for key, ids := range values { + out[key] = append([]uint64(nil), ids...) + } + return out +} diff --git a/agents/rap-node-agent/internal/vpnruntime/fabric_session_transport.go b/agents/rap-node-agent/internal/vpnruntime/fabric_session_transport.go index 04ef41a..a642eaf 100644 --- a/agents/rap-node-agent/internal/vpnruntime/fabric_session_transport.go +++ b/agents/rap-node-agent/internal/vpnruntime/fabric_session_transport.go @@ -130,11 +130,14 @@ func (t *FabricSessionPacketTransport) ReceiveGatewayPacketBatch(ctx context.Con continue } if err != nil { + if packets, receiveErr := t.Inbox.Receive(ctx, t.VPNConnectionID, direction, 100*time.Millisecond); receiveErr != nil || len(packets) > 0 { + return packets, receiveErr + } return nil, err } case frame, ok := <-frames: if !ok { - return t.Inbox.Receive(ctx, t.VPNConnectionID, direction, 5*time.Millisecond) + return t.Inbox.Receive(ctx, t.VPNConnectionID, direction, 100*time.Millisecond) } if frame.Type != fabricproto.FrameData || !t.acceptsStream(frame.StreamID) { continue diff --git a/agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go b/agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go index cb2d4aa..a43b728 100644 --- a/agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go +++ b/agents/rap-node-agent/internal/vpnruntime/fabric_transport_test.go @@ -426,6 +426,59 @@ func TestFabricSessionPacketTransportRunFrameIngressDeliversInbox(t *testing.T) } } +func TestFabricSessionPacketPeerTransportSendsReplyToLatestRegisteredPeer(t *testing.T) { + inbox := NewFabricPacketInbox(4) + registry := NewFabricSessionPacketPeerRegistry() + sender := &recordingFrameSender{} + frame, err := NewFabricVPNPacketDataFrame(FabricVPNPacketFrameInput{ + StreamID: 7, + VPNConnectionID: "vpn-1", + Direction: FabricDirectionClientToGateway, + Packets: [][]byte{[]byte("request")}, + }) + if err != nil { + t.Fatalf("frame: %v", err) + } + handled, err := registry.RegisterFrame(context.Background(), sender, frame) + if err != nil || !handled { + t.Fatalf("register frame handled=%v err=%v", handled, err) + } + if err := inbox.DeliverFabricSessionFrame(context.Background(), frame); err != nil { + t.Fatalf("deliver frame: %v", err) + } + transport := &FabricSessionPacketPeerTransport{ + Registry: registry, + Inbox: inbox, + VPNConnectionID: "vpn-1", + } + requests, err := transport.ReceiveGatewayPacketBatch(context.Background(), time.Second) + if err != nil || len(requests) != 1 || string(requests[0]) != "request" { + t.Fatalf("requests=%q err=%v", requests, err) + } + if err := transport.SendGatewayPacketBatch(context.Background(), [][]byte{[]byte("reply")}); err != nil { + t.Fatalf("send reply: %v", err) + } + if len(sender.frames) != 1 { + t.Fatalf("sent frames = %d, want 1", len(sender.frames)) + } + payload, err := DecodeFabricVPNPacketDataFrame(sender.frames[0]) + if err != nil { + t.Fatalf("decode reply: %v", err) + } + if payload.Direction != FabricDirectionGatewayToClient || string(payload.Packets[0]) != "reply" { + t.Fatalf("reply payload = %+v", payload) + } +} + +type recordingFrameSender struct { + frames []fabricproto.Frame +} + +func (s *recordingFrameSender) SendFrame(_ context.Context, frame fabricproto.Frame) error { + s.frames = append(s.frames, frame) + return nil +} + func TestFabricSessionPacketTransportReceiveReadsPumpFrames(t *testing.T) { inbox := NewFabricPacketInbox(4) receiver := memoryFabricSessionReceiver{ diff --git a/agents/rap-node-agent/internal/vpnruntime/gateway.go b/agents/rap-node-agent/internal/vpnruntime/gateway.go index 36eb217..5a058e7 100644 --- a/agents/rap-node-agent/internal/vpnruntime/gateway.go +++ b/agents/rap-node-agent/internal/vpnruntime/gateway.go @@ -169,6 +169,9 @@ func (g *Gateway) Snapshot() map[string]any { out := map[string]any{ "running": running, + "service_role": "ipv4-egress", + "service_class": "vpn_packets", + "adapter_contract": "fabric_channel_to_ipv4_nat", "transport": g.transportName(), "poll_timeout_ms": g.PollTimeout.Milliseconds(), "client_to_gateway_batches": g.clientToGatewayBatches.Load(), @@ -234,14 +237,7 @@ func (g *Gateway) setStopped(err error) { func (g *Gateway) normalize() error { if g.Transport == nil { - if g.API == nil { - return fmt.Errorf("api client or packet transport is required") - } - g.Transport = BackendPacketTransport{ - API: g.API, - ClusterID: g.ClusterID, - VPNConnectionID: g.VPNConnectionID, - } + return fmt.Errorf("fabric packet transport is required; backend packet relay fallback is disabled") } if g.ClusterID == "" || g.VPNConnectionID == "" { return fmt.Errorf("cluster id and vpn connection id are required") diff --git a/agents/rap-node-agent/internal/vpnruntime/gateway_test.go b/agents/rap-node-agent/internal/vpnruntime/gateway_test.go index 36c6e00..8e257cc 100644 --- a/agents/rap-node-agent/internal/vpnruntime/gateway_test.go +++ b/agents/rap-node-agent/internal/vpnruntime/gateway_test.go @@ -95,6 +95,30 @@ func TestGatewayRunClosesPacketTransportOnRuntimeError(t *testing.T) { } } +func TestGatewayNormalizeRejectsBackendPacketRelayFallback(t *testing.T) { + gateway := &Gateway{ + API: nil, + ClusterID: "cluster-1", + VPNConnectionID: "vpn-1", + } + + err := gateway.normalize() + if err == nil { + t.Fatal("normalize succeeded without a fabric packet transport") + } + if got, want := err.Error(), "fabric packet transport is required; backend packet relay fallback is disabled"; got != want { + t.Fatalf("normalize error = %q, want %q", got, want) + } +} + +func TestGatewaySnapshotReportsIPv4EgressServiceAdapter(t *testing.T) { + gateway := &Gateway{Transport: &recordingGatewayTransport{}, VPNConnectionID: "vpn-1"} + snapshot := gateway.Snapshot() + if snapshot["service_role"] != "ipv4-egress" || snapshot["service_class"] != "vpn_packets" || snapshot["adapter_contract"] != "fabric_channel_to_ipv4_nat" { + t.Fatalf("unexpected gateway service snapshot: %#v", snapshot) + } +} + func TestGatewayUploadPrioritizesTCPControlPackets(t *testing.T) { transport := &recordingGatewayTransport{} gateway := &Gateway{Transport: transport, VPNConnectionID: "vpn-1"} diff --git a/agents/rap-node-agent/internal/webingress/admin_runtime.go b/agents/rap-node-agent/internal/webingress/admin_runtime.go new file mode 100644 index 0000000..8c619cb --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/admin_runtime.go @@ -0,0 +1,190 @@ +package webingress + +import ( + "context" + "encoding/json" + "net/http" + "strings" + "time" +) + +const AdminRuntimeResponseSchema = "rap.web_ingress.admin_runtime_response.v1" +const ControlAPIProjectionRequestSchema = "rap.web_ingress.control_api_projection_request.v1" +const ControlAPIProjectionResponseSchema = "rap.web_ingress.control_api_projection_response.v1" + +type AdminRuntimeDispatcher struct { + ProjectionClient ControlAPIProjectionClient + Now func() time.Time +} + +type ControlAPIProjectionClient interface { + Project(ctx context.Context, request ControlAPIProjectionRequest) (ControlAPIProjectionResponse, error) +} + +type ControlAPIProjectionRequest struct { + SchemaVersion string `json:"schema_version"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query,omitempty"` + Host string `json:"host,omitempty"` + Scope string `json:"scope"` + ServiceClass string `json:"service_class"` + ObservedAt string `json:"observed_at"` +} + +type ControlAPIProjectionResponse struct { + SchemaVersion string `json:"schema_version"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + StatusCode int `json:"status_code"` + Headers map[string]string `json:"headers,omitempty"` + Body json.RawMessage `json:"body,omitempty"` +} + +type AdminRuntimeJSONResponse struct { + SchemaVersion string `json:"schema_version"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + Scope string `json:"scope,omitempty"` + ServiceClass string `json:"service_class,omitempty"` + Path string `json:"path,omitempty"` + Manifest map[string]any `json:"manifest,omitempty"` + ObservedAt string `json:"observed_at"` +} + +func (d AdminRuntimeDispatcher) HandleFabricRequest(ctx context.Context, request FabricRequest) (FabricResponse, error) { + method := strings.ToUpper(strings.TrimSpace(request.Method)) + path := normalizeRuntimePath(request.Path) + if method == "" { + method = http.MethodGet + } + if !allowedAdminRuntimeScope(strings.TrimSpace(request.Scope), strings.TrimSpace(request.ServiceClass)) { + return d.json(http.StatusForbidden, request, "blocked", "admin_runtime_scope_rejected", nil), nil + } + switch { + case method == http.MethodGet && (path == "/healthz" || path == "/readyz"): + return d.json(http.StatusOK, request, "ready", "admin_runtime_ready", nil), nil + case d.ProjectionClient != nil && (method == http.MethodGet || method == http.MethodHead): + return d.project(ctx, request) + case method == http.MethodGet && (path == "/ui-manifest" || strings.HasSuffix(path, "/ui-manifest")): + return d.json(http.StatusOK, request, "ready", "ui_manifest_ready", d.manifest(request)), nil + case method != http.MethodGet && method != http.MethodHead: + return d.json(http.StatusForbidden, request, "blocked", "control_api_mutation_binding_not_implemented", nil), nil + default: + return d.json(http.StatusNotImplemented, request, "blocked", "control_api_projection_binding_not_implemented", nil), nil + } +} + +func allowedAdminRuntimeScope(scope string, serviceClass string) bool { + switch serviceClass { + case "platform_admin": + return scope == "platform" + case "cluster_admin": + return scope == "cluster" + case "organization_portal": + return scope == "organization" + case "user_portal": + return scope == "user" || scope == "organization" + default: + return false + } +} + +func (d AdminRuntimeDispatcher) project(ctx context.Context, request FabricRequest) (FabricResponse, error) { + response, err := d.ProjectionClient.Project(ctx, ControlAPIProjectionRequest{ + SchemaVersion: ControlAPIProjectionRequestSchema, + Method: strings.ToUpper(strings.TrimSpace(request.Method)), + Path: normalizeRuntimePath(request.Path), + Query: request.Query, + Host: request.Host, + Scope: request.Scope, + ServiceClass: request.ServiceClass, + ObservedAt: d.observedAt(), + }) + if err != nil { + return d.json(http.StatusBadGateway, request, "blocked", "control_api_projection_failed", nil), nil + } + if response.SchemaVersion != ControlAPIProjectionResponseSchema { + return d.json(http.StatusBadGateway, request, "blocked", "control_api_projection_invalid_response", nil), nil + } + headers := http.Header{"Content-Type": []string{"application/json"}} + for key, value := range response.Headers { + if safeResponseHeader(key) && strings.TrimSpace(value) != "" { + headers.Set(key, value) + } + } + statusCode := response.StatusCode + if statusCode < 100 || statusCode > 599 { + statusCode = http.StatusOK + } + return FabricResponse{StatusCode: statusCode, Headers: headers, Body: append([]byte(nil), response.Body...)}, nil +} + +func (d AdminRuntimeDispatcher) json(statusCode int, request FabricRequest, status string, reason string, manifest map[string]any) FabricResponse { + payload, _ := json.Marshal(AdminRuntimeJSONResponse{ + SchemaVersion: AdminRuntimeResponseSchema, + Status: status, + Reason: reason, + Scope: request.Scope, + ServiceClass: request.ServiceClass, + Path: request.Path, + Manifest: manifest, + ObservedAt: d.observedAt(), + }) + return FabricResponse{ + StatusCode: statusCode, + Headers: http.Header{"Content-Type": []string{"application/json"}}, + Body: payload, + } +} + +func (d AdminRuntimeDispatcher) manifest(request FabricRequest) map[string]any { + serviceClass := strings.TrimSpace(request.ServiceClass) + sections := []string{} + actions := []string{} + switch serviceClass { + case "platform_admin": + sections = []string{"clusters", "nodes", "roles", "fabric", "workloads", "audit"} + actions = []string{"read_platform_summary", "read_cluster_summaries", "read_node_status"} + case "cluster_admin": + sections = []string{"cluster", "nodes", "fabric", "workloads", "audit"} + actions = []string{"read_cluster_summary", "read_node_status"} + case "organization_portal": + sections = []string{"organization", "sessions", "resources", "audit"} + actions = []string{"read_organization_summary", "read_sessions"} + case "user_portal": + sections = []string{"profile", "sessions", "resources"} + actions = []string{"read_profile", "read_sessions"} + default: + sections = []string{"status"} + actions = []string{"read_status"} + } + return map[string]any{ + "schema_version": "rap.web_ingress.ui_manifest.v1", + "scope": request.Scope, + "service_class": serviceClass, + "sections": sections, + "allowed_actions": actions, + "mutation_enabled": false, + "projection_binding": "control_api_not_bound", + } +} + +func (d AdminRuntimeDispatcher) observedAt() string { + now := time.Now().UTC() + if d.Now != nil { + now = d.Now().UTC() + } + return now.Format(time.RFC3339Nano) +} + +func normalizeRuntimePath(path string) string { + path = strings.TrimSpace(path) + if path == "" { + return "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + return path +} diff --git a/agents/rap-node-agent/internal/webingress/admin_runtime_test.go b/agents/rap-node-agent/internal/webingress/admin_runtime_test.go new file mode 100644 index 0000000..e53f9ad --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/admin_runtime_test.go @@ -0,0 +1,212 @@ +package webingress + +import ( + "context" + "encoding/json" + "net/http" + "testing" +) + +func TestAdminRuntimeDispatcherReturnsHealthAndManifest(t *testing.T) { + dispatcher := AdminRuntimeDispatcher{Now: fixedEnvelopeNow} + + health, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{ + Method: http.MethodGet, + Path: "/readyz", + Scope: "platform", + ServiceClass: "platform_admin", + }) + if err != nil { + t.Fatalf("health: %v", err) + } + if health.StatusCode != http.StatusOK { + t.Fatalf("health = %+v", health) + } + + manifest, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{ + Method: http.MethodGet, + Path: "/platform-admin/ui-manifest", + Scope: "platform", + ServiceClass: "platform_admin", + }) + if err != nil { + t.Fatalf("manifest: %v", err) + } + var payload AdminRuntimeJSONResponse + if err := json.Unmarshal(manifest.Body, &payload); err != nil { + t.Fatalf("decode manifest: %v", err) + } + if manifest.StatusCode != http.StatusOK || + payload.SchemaVersion != AdminRuntimeResponseSchema || + payload.Status != "ready" || + payload.Reason != "ui_manifest_ready" || + payload.Manifest["schema_version"] != "rap.web_ingress.ui_manifest.v1" || + payload.Manifest["mutation_enabled"] != false { + t.Fatalf("payload = %+v status=%d", payload, manifest.StatusCode) + } +} + +func TestAdminRuntimeDispatcherBlocksMutationsAndUnknownProjection(t *testing.T) { + dispatcher := AdminRuntimeDispatcher{Now: fixedEnvelopeNow} + + mutation, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{ + Method: http.MethodPost, + Path: "/platform-admin/nodes", + Scope: "platform", + ServiceClass: "platform_admin", + }) + if err != nil { + t.Fatalf("mutation: %v", err) + } + var mutationPayload AdminRuntimeJSONResponse + if err := json.Unmarshal(mutation.Body, &mutationPayload); err != nil { + t.Fatalf("decode mutation: %v", err) + } + if mutation.StatusCode != http.StatusForbidden || mutationPayload.Reason != "control_api_mutation_binding_not_implemented" { + t.Fatalf("mutation payload = %+v status=%d", mutationPayload, mutation.StatusCode) + } + + projection, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{ + Method: http.MethodGet, + Path: "/platform-admin/nodes", + Scope: "platform", + ServiceClass: "platform_admin", + }) + if err != nil { + t.Fatalf("projection: %v", err) + } + var projectionPayload AdminRuntimeJSONResponse + if err := json.Unmarshal(projection.Body, &projectionPayload); err != nil { + t.Fatalf("decode projection: %v", err) + } + if projection.StatusCode != http.StatusNotImplemented || projectionPayload.Reason != "control_api_projection_binding_not_implemented" { + t.Fatalf("projection payload = %+v status=%d", projectionPayload, projection.StatusCode) + } +} + +func TestAdminRuntimeDispatcherRejectsInvalidScopeClassPair(t *testing.T) { + dispatcher := AdminRuntimeDispatcher{ProjectionClient: &recordingProjectionClient{}, Now: fixedEnvelopeNow} + response, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{ + Method: http.MethodGet, + Path: "/platform-admin/ui-manifest", + Scope: "organization", + ServiceClass: "platform_admin", + }) + if err != nil { + t.Fatalf("projection: %v", err) + } + var payload AdminRuntimeJSONResponse + if err := json.Unmarshal(response.Body, &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.StatusCode != http.StatusForbidden || payload.Reason != "admin_runtime_scope_rejected" { + t.Fatalf("payload = %+v status=%d", payload, response.StatusCode) + } +} + +func TestAdminRuntimeDispatcherUsesControlAPIProjectionClientForReadRequests(t *testing.T) { + client := &recordingProjectionClient{ + response: ControlAPIProjectionResponse{ + SchemaVersion: ControlAPIProjectionResponseSchema, + Status: "ready", + StatusCode: http.StatusOK, + Headers: map[string]string{"X-RAP-Projection": "control-api", "Set-Cookie": "blocked"}, + Body: json.RawMessage(`{"schema_version":"control.projection.v1","ok":true}`), + }, + } + dispatcher := AdminRuntimeDispatcher{ProjectionClient: client, Now: fixedEnvelopeNow} + + response, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{ + Method: http.MethodGet, + Path: "/platform-admin/nodes", + Query: "limit=10", + Host: "admin.example.test", + Scope: "platform", + ServiceClass: "platform_admin", + }) + if err != nil { + t.Fatalf("projection: %v", err) + } + if response.StatusCode != http.StatusOK || + response.Headers.Get("X-RAP-Projection") != "control-api" || + response.Headers.Get("Set-Cookie") != "" || + string(response.Body) != `{"schema_version":"control.projection.v1","ok":true}` { + t.Fatalf("response = %+v body=%s", response, string(response.Body)) + } + if client.request.Path != "/platform-admin/nodes" || + client.request.Query != "limit=10" || + client.request.Scope != "platform" || + client.request.ServiceClass != "platform_admin" { + t.Fatalf("request = %+v", client.request) + } +} + +func TestAdminRuntimeDispatcherReportsProjectionClientFailure(t *testing.T) { + dispatcher := AdminRuntimeDispatcher{ProjectionClient: failingProjectionClient{}, Now: fixedEnvelopeNow} + response, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{ + Method: http.MethodGet, + Path: "/platform-admin/nodes", + Scope: "platform", + ServiceClass: "platform_admin", + }) + if err != nil { + t.Fatalf("projection: %v", err) + } + var payload AdminRuntimeJSONResponse + if err := json.Unmarshal(response.Body, &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.StatusCode != http.StatusBadGateway || payload.Reason != "control_api_projection_failed" { + t.Fatalf("payload = %+v status=%d", payload, response.StatusCode) + } +} + +func TestAdminRuntimeDispatcherRejectsInvalidProjectionResponseSchema(t *testing.T) { + dispatcher := AdminRuntimeDispatcher{ + ProjectionClient: &recordingProjectionClient{ + response: ControlAPIProjectionResponse{ + SchemaVersion: "wrong.schema", + Status: "ready", + StatusCode: http.StatusOK, + Body: json.RawMessage(`{"ok":true}`), + }, + }, + Now: fixedEnvelopeNow, + } + response, err := dispatcher.HandleFabricRequest(context.Background(), FabricRequest{ + Method: http.MethodGet, + Path: "/platform-admin/nodes", + Scope: "platform", + ServiceClass: "platform_admin", + }) + if err != nil { + t.Fatalf("projection: %v", err) + } + var payload AdminRuntimeJSONResponse + if err := json.Unmarshal(response.Body, &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.StatusCode != http.StatusBadGateway || payload.Reason != "control_api_projection_invalid_response" { + t.Fatalf("payload = %+v status=%d", payload, response.StatusCode) + } +} + +type recordingProjectionClient struct { + request ControlAPIProjectionRequest + response ControlAPIProjectionResponse +} + +func (c *recordingProjectionClient) Project(_ context.Context, request ControlAPIProjectionRequest) (ControlAPIProjectionResponse, error) { + c.request = request + return c.response, nil +} + +type failingProjectionClient struct{} + +func (failingProjectionClient) Project(context.Context, ControlAPIProjectionRequest) (ControlAPIProjectionResponse, error) { + return ControlAPIProjectionResponse{}, errTestProjectionFailure{} +} + +type errTestProjectionFailure struct{} + +func (errTestProjectionFailure) Error() string { return "projection failed" } diff --git a/agents/rap-node-agent/internal/webingress/binder.go b/agents/rap-node-agent/internal/webingress/binder.go new file mode 100644 index 0000000..b077411 --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/binder.go @@ -0,0 +1,151 @@ +package webingress + +import ( + "context" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "sort" + "strings" + "time" +) + +const FabricServiceChannelEnvelopeSchema = "rap.web_ingress.fabric_service_channel_envelope.v1" + +var ( + ErrFabricEnvelopeSignerRequired = errors.New("web ingress fabric envelope signer required") + ErrFabricEnvelopeSenderRequired = errors.New("web ingress fabric envelope sender required") + ErrFabricEnvelopeScopeRequired = errors.New("web ingress fabric envelope scope required") + ErrFabricEnvelopeClassRequired = errors.New("web ingress fabric envelope service class required") +) + +type EnvelopeSigner interface { + Sign(ctx context.Context, canonical []byte) (FabricEnvelopeSignature, error) +} + +type EnvelopeSender interface { + Send(ctx context.Context, envelope SignedFabricServiceChannelEnvelope) (FabricResponse, error) +} + +type DefaultFabricBinder struct { + Signer EnvelopeSigner + Sender EnvelopeSender + Now func() time.Time +} + +type FabricServiceChannelEnvelope struct { + SchemaVersion string `json:"schema_version"` + RequestSchema string `json:"request_schema"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query,omitempty"` + Host string `json:"host"` + ServiceType string `json:"service_type"` + Scope string `json:"scope"` + ServiceClass string `json:"service_class"` + Headers map[string][]string `json:"headers,omitempty"` + BodyBase64 string `json:"body_b64,omitempty"` + ObservedAt string `json:"observed_at"` + EnvelopedAt string `json:"enveloped_at"` +} + +type FabricEnvelopeSignature struct { + KeyID string `json:"key_id"` + Alg string `json:"alg"` + Signature string `json:"signature"` + SignedAt string `json:"signed_at,omitempty"` +} + +type SignedFabricServiceChannelEnvelope struct { + SchemaVersion string `json:"schema_version"` + Envelope FabricServiceChannelEnvelope `json:"envelope"` + Signature FabricEnvelopeSignature `json:"signature"` + Canonical []byte `json:"-"` +} + +func (b DefaultFabricBinder) Forward(ctx context.Context, request FabricRequest) (FabricResponse, error) { + if b.Signer == nil { + return FabricResponse{}, ErrFabricEnvelopeSignerRequired + } + if b.Sender == nil { + return FabricResponse{}, ErrFabricEnvelopeSenderRequired + } + if strings.TrimSpace(request.Scope) == "" { + return FabricResponse{}, ErrFabricEnvelopeScopeRequired + } + if strings.TrimSpace(request.ServiceClass) == "" { + return FabricResponse{}, ErrFabricEnvelopeClassRequired + } + + envelope := b.envelope(request) + canonical, err := json.Marshal(envelope) + if err != nil { + return FabricResponse{}, err + } + signature, err := b.Signer.Sign(ctx, canonical) + if err != nil { + return FabricResponse{}, err + } + return b.Sender.Send(ctx, SignedFabricServiceChannelEnvelope{ + SchemaVersion: SignedFabricServiceChannelEnvelopeSchema, + Envelope: envelope, + Signature: signature, + Canonical: canonical, + }) +} + +func (b DefaultFabricBinder) envelope(request FabricRequest) FabricServiceChannelEnvelope { + now := time.Now().UTC() + if b.Now != nil { + now = b.Now().UTC() + } + observedAt := request.ObservedAt.UTC() + if observedAt.IsZero() { + observedAt = now + } + return FabricServiceChannelEnvelope{ + SchemaVersion: FabricServiceChannelEnvelopeSchema, + RequestSchema: strings.TrimSpace(request.SchemaVersion), + Method: strings.ToUpper(strings.TrimSpace(request.Method)), + Path: request.Path, + Query: request.Query, + Host: strings.TrimSpace(request.Host), + ServiceType: strings.TrimSpace(request.ServiceType), + Scope: strings.TrimSpace(request.Scope), + ServiceClass: strings.TrimSpace(request.ServiceClass), + Headers: canonicalHeaders(request.Headers), + BodyBase64: base64.StdEncoding.EncodeToString(request.Body), + ObservedAt: observedAt.Format(time.RFC3339Nano), + EnvelopedAt: now.Format(time.RFC3339Nano), + } +} + +func canonicalHeaders(headers http.Header) map[string][]string { + if len(headers) == 0 { + return nil + } + out := map[string][]string{} + for key, values := range headers { + canonicalKey := http.CanonicalHeaderKey(strings.TrimSpace(key)) + if canonicalKey == "" || !safeRequestHeader(canonicalKey) { + continue + } + copied := make([]string, 0, len(values)) + for _, value := range values { + value = strings.TrimSpace(value) + if value != "" { + copied = append(copied, value) + } + } + if len(copied) == 0 { + continue + } + sort.Strings(copied) + out[canonicalKey] = copied + } + if len(out) == 0 { + return nil + } + return out +} diff --git a/agents/rap-node-agent/internal/webingress/binder_test.go b/agents/rap-node-agent/internal/webingress/binder_test.go new file mode 100644 index 0000000..b8882b3 --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/binder_test.go @@ -0,0 +1,163 @@ +package webingress + +import ( + "bytes" + "context" + "encoding/json" + "errors" + "net/http" + "testing" + "time" +) + +func TestDefaultFabricBinderBuildsSignedEnvelopeAndSendsIt(t *testing.T) { + signer := &recordingEnvelopeSigner{ + signature: FabricEnvelopeSignature{KeyID: "node-key-1", Alg: "ed25519", Signature: "sig-1", SignedAt: "2026-05-17T00:00:02Z"}, + } + sender := &recordingEnvelopeSender{ + response: FabricResponse{StatusCode: http.StatusAccepted, Body: []byte(`{"accepted":true}`)}, + } + binder := DefaultFabricBinder{Signer: signer, Sender: sender, Now: fixedEnvelopeNow} + + response, err := binder.Forward(context.Background(), FabricRequest{ + SchemaVersion: "rap.web_ingress.fabric_request.v1", + Method: "post", + Path: "/platform-admin/root", + Query: "tab=nodes", + Host: "admin.example.test", + ServiceType: "admin-ingress", + Scope: "platform", + ServiceClass: "platform_admin", + Headers: http.Header{ + "X-Trace-Id": []string{"trace-b", "trace-a"}, + "Authorization": []string{"Bearer should-not-forward"}, + "X-Empty-Header": []string{" "}, + }, + Body: []byte(`{"hello":"world"}`), + ObservedAt: fixedNow(), + }) + if err != nil { + t.Fatalf("Forward failed: %v", err) + } + if response.StatusCode != http.StatusAccepted { + t.Fatalf("response = %+v", response) + } + if len(signer.canonical) == 0 { + t.Fatal("signer did not receive canonical envelope") + } + if !bytes.Equal(sender.envelope.Canonical, signer.canonical) { + t.Fatalf("sender canonical does not match signer canonical") + } + if sender.envelope.SchemaVersion != "rap.web_ingress.signed_fabric_service_channel_envelope.v1" { + t.Fatalf("signed schema = %q", sender.envelope.SchemaVersion) + } + if sender.envelope.Signature.KeyID != "node-key-1" || sender.envelope.Signature.Signature != "sig-1" { + t.Fatalf("signature = %+v", sender.envelope.Signature) + } + + var canonical FabricServiceChannelEnvelope + if err := json.Unmarshal(signer.canonical, &canonical); err != nil { + t.Fatalf("decode canonical: %v", err) + } + if canonical.SchemaVersion != FabricServiceChannelEnvelopeSchema || + canonical.RequestSchema != "rap.web_ingress.fabric_request.v1" || + canonical.Method != http.MethodPost || + canonical.Scope != "platform" || + canonical.ServiceClass != "platform_admin" || + canonical.BodyBase64 != "eyJoZWxsbyI6IndvcmxkIn0=" || + canonical.ObservedAt != "2026-05-17T00:00:00Z" || + canonical.EnvelopedAt != "2026-05-17T00:00:01Z" { + t.Fatalf("canonical envelope = %+v", canonical) + } + if got := canonical.Headers["X-Trace-Id"]; len(got) != 2 || got[0] != "trace-a" || got[1] != "trace-b" { + t.Fatalf("canonical headers = %#v", canonical.Headers) + } + if canonical.Headers["Authorization"] != nil || canonical.Headers["X-Empty-Header"] != nil { + t.Fatalf("unsafe/empty headers leaked: %#v", canonical.Headers) + } +} + +func TestDefaultFabricBinderRequiresSignerAndSender(t *testing.T) { + request := FabricRequest{Scope: "platform", ServiceClass: "platform_admin"} + + _, err := (DefaultFabricBinder{Sender: &recordingEnvelopeSender{}}).Forward(context.Background(), request) + if !errors.Is(err, ErrFabricEnvelopeSignerRequired) { + t.Fatalf("signer error = %v", err) + } + + _, err = (DefaultFabricBinder{Signer: &recordingEnvelopeSigner{}}).Forward(context.Background(), request) + if !errors.Is(err, ErrFabricEnvelopeSenderRequired) { + t.Fatalf("sender error = %v", err) + } +} + +func TestDefaultFabricBinderRequiresScopeAndServiceClass(t *testing.T) { + binder := DefaultFabricBinder{Signer: &recordingEnvelopeSigner{}, Sender: &recordingEnvelopeSender{}} + + _, err := binder.Forward(context.Background(), FabricRequest{ServiceClass: "platform_admin"}) + if !errors.Is(err, ErrFabricEnvelopeScopeRequired) { + t.Fatalf("scope error = %v", err) + } + + _, err = binder.Forward(context.Background(), FabricRequest{Scope: "platform"}) + if !errors.Is(err, ErrFabricEnvelopeClassRequired) { + t.Fatalf("class error = %v", err) + } +} + +func TestDefaultFabricBinderPropagatesSignerAndSenderFailures(t *testing.T) { + signerErr := errors.New("sign failed") + senderErr := errors.New("send failed") + request := FabricRequest{Scope: "platform", ServiceClass: "platform_admin"} + + _, err := (DefaultFabricBinder{ + Signer: &recordingEnvelopeSigner{err: signerErr}, + Sender: &recordingEnvelopeSender{}, + }).Forward(context.Background(), request) + if !errors.Is(err, signerErr) { + t.Fatalf("signer error = %v", err) + } + + _, err = (DefaultFabricBinder{ + Signer: &recordingEnvelopeSigner{}, + Sender: &recordingEnvelopeSender{err: senderErr}, + }).Forward(context.Background(), request) + if !errors.Is(err, senderErr) { + t.Fatalf("sender error = %v", err) + } +} + +func fixedEnvelopeNow() time.Time { + return time.Date(2026, 5, 17, 0, 0, 1, 0, time.UTC) +} + +type recordingEnvelopeSigner struct { + canonical []byte + signature FabricEnvelopeSignature + err error +} + +func (s *recordingEnvelopeSigner) Sign(_ context.Context, canonical []byte) (FabricEnvelopeSignature, error) { + s.canonical = append([]byte{}, canonical...) + if s.err != nil { + return FabricEnvelopeSignature{}, s.err + } + if s.signature.KeyID == "" { + s.signature = FabricEnvelopeSignature{KeyID: "test-key", Alg: "ed25519", Signature: "test-signature"} + } + return s.signature, nil +} + +type recordingEnvelopeSender struct { + envelope SignedFabricServiceChannelEnvelope + response FabricResponse + err error +} + +func (s *recordingEnvelopeSender) Send(_ context.Context, envelope SignedFabricServiceChannelEnvelope) (FabricResponse, error) { + s.envelope = envelope + if s.err != nil { + return FabricResponse{}, s.err + } + return s.response, nil +} diff --git a/agents/rap-node-agent/internal/webingress/keys.go b/agents/rap-node-agent/internal/webingress/keys.go new file mode 100644 index 0000000..b9c12cd --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/keys.go @@ -0,0 +1,64 @@ +package webingress + +import ( + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "fmt" + "strings" +) + +type TrustedKeyConfig struct { + KeyID string `json:"key_id"` + PublicKey string `json:"public_key"` +} + +func ParseTrustedKeysJSON(value string) (StaticEnvelopeKeyResolver, error) { + value = strings.TrimSpace(value) + if value == "" { + return nil, nil + } + resolver := StaticEnvelopeKeyResolver{} + var byID map[string]string + if err := json.Unmarshal([]byte(value), &byID); err == nil && len(byID) > 0 { + for keyID, publicKeyB64 := range byID { + if err := resolver.addBase64(keyID, publicKeyB64); err != nil { + return nil, err + } + } + return resolver, nil + } + var list []TrustedKeyConfig + if err := json.Unmarshal([]byte(value), &list); err != nil { + return nil, fmt.Errorf("%w: trusted keys json must be object or array", ErrFabricEnvelopeSignatureInvalid) + } + for _, item := range list { + if err := resolver.addBase64(item.KeyID, item.PublicKey); err != nil { + return nil, err + } + } + return resolver, nil +} + +func (r StaticEnvelopeKeyResolver) addBase64(keyID string, publicKeyB64 string) error { + keyID = strings.TrimSpace(keyID) + if keyID == "" { + return fmt.Errorf("%w: trusted key id required", ErrFabricEnvelopeSignatureInvalid) + } + decoded, err := decodeEnvelopeBase64(strings.TrimSpace(publicKeyB64)) + if err != nil { + return fmt.Errorf("%w: trusted public key must be base64 encoded", ErrFabricEnvelopeSignatureInvalid) + } + if len(decoded) != ed25519.PublicKeySize { + return fmt.Errorf("%w: trusted public key must decode to %d bytes", ErrFabricEnvelopeSignatureInvalid, ed25519.PublicKeySize) + } + r[keyID] = append(ed25519.PublicKey(nil), decoded...) + return nil +} + +func TrustedKeysJSONForPublicKey(keyID string, publicKey ed25519.PublicKey) string { + payload, _ := json.Marshal(map[string]string{ + strings.TrimSpace(keyID): base64.StdEncoding.EncodeToString(publicKey), + }) + return string(payload) +} diff --git a/agents/rap-node-agent/internal/webingress/keys_test.go b/agents/rap-node-agent/internal/webingress/keys_test.go new file mode 100644 index 0000000..af3124d --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/keys_test.go @@ -0,0 +1,64 @@ +package webingress + +import ( + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "testing" +) + +func TestParseTrustedKeysJSONAcceptsMapAndArray(t *testing.T) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + keyB64 := base64.StdEncoding.EncodeToString(publicKey) + + resolver, err := ParseTrustedKeysJSON(`{"key-1":"` + keyB64 + `"}`) + if err != nil { + t.Fatalf("parse map: %v", err) + } + if got, ok, err := resolver.PublicKey(nil, "key-1"); err != nil || !ok || string(got) != string(publicKey) { + t.Fatalf("map resolver got=%x ok=%t err=%v", got, ok, err) + } + + resolver, err = ParseTrustedKeysJSON(`[{"key_id":"key-2","public_key":"` + keyB64 + `"}]`) + if err != nil { + t.Fatalf("parse array: %v", err) + } + if _, ok, err := resolver.PublicKey(nil, "key-2"); err != nil || !ok { + t.Fatalf("array resolver ok=%t err=%v", ok, err) + } +} + +func TestParseTrustedKeysJSONRejectsInvalidKeys(t *testing.T) { + _, err := ParseTrustedKeysJSON(`{"":"abc"}`) + if !errors.Is(err, ErrFabricEnvelopeSignatureInvalid) { + t.Fatalf("empty key err = %v", err) + } + + _, err = ParseTrustedKeysJSON(`{"key-1":"abc"}`) + if !errors.Is(err, ErrFabricEnvelopeSignatureInvalid) { + t.Fatalf("bad public key err = %v", err) + } + + _, err = ParseTrustedKeysJSON(`not-json`) + if !errors.Is(err, ErrFabricEnvelopeSignatureInvalid) { + t.Fatalf("bad json err = %v", err) + } +} + +func TestTrustedKeysJSONForPublicKey(t *testing.T) { + publicKey, _, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + resolver, err := ParseTrustedKeysJSON(TrustedKeysJSONForPublicKey("key-1", publicKey)) + if err != nil { + t.Fatalf("parse generated json: %v", err) + } + if _, ok, err := resolver.PublicKey(nil, "key-1"); err != nil || !ok { + t.Fatalf("generated resolver ok=%t err=%v", ok, err) + } +} diff --git a/agents/rap-node-agent/internal/webingress/manager.go b/agents/rap-node-agent/internal/webingress/manager.go new file mode 100644 index 0000000..ca277ed --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/manager.go @@ -0,0 +1,182 @@ +package webingress + +import ( + "context" + "crypto/tls" + "errors" + "net" + "net/http" + "strings" + "sync" + "time" +) + +type ListenerConfig struct { + RuntimeConfig + HTTPAddr string + HTTPSAddr string + TLSCertFile string + TLSKeyFile string + Binder FabricBinder +} + +type ListenerStatus struct { + SchemaVersion string `json:"schema_version"` + Running bool `json:"running"` + HTTPRunning bool `json:"http_running"` + HTTPSRunning bool `json:"https_running"` + HTTPAddr string `json:"http_addr,omitempty"` + HTTPSAddr string `json:"https_addr,omitempty"` + Reason string `json:"reason,omitempty"` + Errors []string `json:"errors,omitempty"` + ObservedAt string `json:"observed_at"` +} + +type Manager struct { + mu sync.Mutex + http *http.Server + https *http.Server + status ListenerStatus + now func() time.Time +} + +func NewManager() *Manager { + return &Manager{now: time.Now} +} + +func (m *Manager) Apply(ctx context.Context, cfg ListenerConfig) ListenerStatus { + m.mu.Lock() + defer m.mu.Unlock() + _ = m.stopLocked(ctx) + + runtime := Runtime{Config: cfg.RuntimeConfig, Binder: cfg.Binder, Now: m.now} + status := ListenerStatus{ + SchemaVersion: "rap.web_ingress.listener_status.v1", + Reason: "started", + ObservedAt: m.observedAt(), + } + errorsOut := []string{} + if strings.TrimSpace(cfg.HTTPAddr) == "" { + cfg.HTTPAddr = ":80" + } + if strings.TrimSpace(cfg.HTTPSAddr) == "" { + cfg.HTTPSAddr = ":443" + } + if server, addr, err := startHTTPServer(ctx, cfg.HTTPAddr, runtime.HTTPHandler()); err == nil { + m.http = server + status.HTTPRunning = true + status.HTTPAddr = addr + } else { + errorsOut = append(errorsOut, "http:"+err.Error()) + } + if cfg.TLSCertFile == "" || cfg.TLSKeyFile == "" { + errorsOut = append(errorsOut, "https:tls_cert_file_and_key_file_required") + } else if server, addr, err := startHTTPSServer(ctx, cfg.HTTPSAddr, cfg.TLSCertFile, cfg.TLSKeyFile, runtime.HTTPSHandler()); err == nil { + m.https = server + status.HTTPSRunning = true + status.HTTPSAddr = addr + } else { + errorsOut = append(errorsOut, "https:"+err.Error()) + } + status.Running = status.HTTPRunning || status.HTTPSRunning + if len(errorsOut) > 0 { + status.Errors = errorsOut + if status.Running { + status.Reason = "partial" + } else { + status.Reason = "blocked" + } + } + m.status = status + return status +} + +func (m *Manager) Stop(ctx context.Context) ListenerStatus { + m.mu.Lock() + defer m.mu.Unlock() + _ = m.stopLocked(ctx) + m.status = ListenerStatus{ + SchemaVersion: "rap.web_ingress.listener_status.v1", + Reason: "stopped", + ObservedAt: m.observedAt(), + } + return m.status +} + +func (m *Manager) Status() ListenerStatus { + m.mu.Lock() + defer m.mu.Unlock() + if m.status.SchemaVersion == "" { + return ListenerStatus{ + SchemaVersion: "rap.web_ingress.listener_status.v1", + Reason: "not_started", + ObservedAt: m.observedAt(), + } + } + return m.status +} + +func (m *Manager) stopLocked(ctx context.Context) error { + var out error + if m.http != nil { + out = errors.Join(out, m.http.Shutdown(ctx)) + m.http = nil + } + if m.https != nil { + out = errors.Join(out, m.https.Shutdown(ctx)) + m.https = nil + } + return out +} + +func (m *Manager) observedAt() string { + now := time.Now().UTC() + if m.now != nil { + now = m.now().UTC() + } + return now.Format(time.RFC3339Nano) +} + +func startHTTPServer(ctx context.Context, addr string, handler http.Handler) (*http.Server, string, error) { + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, "", err + } + server := &http.Server{Handler: handler, ReadHeaderTimeout: 5 * time.Second} + go func() { + <-ctx.Done() + _ = server.Shutdown(context.Background()) + }() + go func() { + if err := server.Serve(listener); err != nil && !errors.Is(err, http.ErrServerClosed) { + _ = server.Close() + } + }() + return server, listener.Addr().String(), nil +} + +func startHTTPSServer(ctx context.Context, addr, certFile, keyFile string, handler http.Handler) (*http.Server, string, error) { + cert, err := tls.LoadX509KeyPair(certFile, keyFile) + if err != nil { + return nil, "", err + } + listener, err := net.Listen("tcp", addr) + if err != nil { + return nil, "", err + } + server := &http.Server{ + Handler: handler, + ReadHeaderTimeout: 5 * time.Second, + TLSConfig: &tls.Config{MinVersion: tls.VersionTLS12, Certificates: []tls.Certificate{cert}}, + } + go func() { + <-ctx.Done() + _ = server.Shutdown(context.Background()) + }() + go func() { + if err := server.ServeTLS(listener, "", ""); err != nil && !errors.Is(err, http.ErrServerClosed) { + _ = server.Close() + } + }() + return server, listener.Addr().String(), nil +} diff --git a/agents/rap-node-agent/internal/webingress/manager_test.go b/agents/rap-node-agent/internal/webingress/manager_test.go new file mode 100644 index 0000000..b1fc70a --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/manager_test.go @@ -0,0 +1,105 @@ +package webingress + +import ( + "context" + "crypto/rand" + "crypto/rsa" + "crypto/x509" + "crypto/x509/pkix" + "encoding/pem" + "math/big" + "net/http" + "os" + "path/filepath" + "strings" + "testing" + "time" +) + +func TestManagerStartsHTTPRedirectAndStops(t *testing.T) { + manager := NewManager() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + status := manager.Apply(ctx, ListenerConfig{ + RuntimeConfig: RuntimeConfig{ServiceType: "admin-ingress", Scope: "platform", ServiceClasses: []string{"platform_admin"}}, + HTTPAddr: "127.0.0.1:0", + HTTPSAddr: "127.0.0.1:0", + }) + if !status.HTTPRunning || status.HTTPSRunning || !status.Running || status.HTTPAddr == "" { + t.Fatalf("status = %+v", status) + } + if status.Reason != "partial" || !containsError(status.Errors, "https:tls_cert_file_and_key_file_required") { + t.Fatalf("status = %+v", status) + } + client := &http.Client{CheckRedirect: func(*http.Request, []*http.Request) error { return http.ErrUseLastResponse }} + resp, err := client.Get("http://" + status.HTTPAddr + "/cluster-admin") + if err != nil { + t.Fatalf("http get: %v", err) + } + _ = resp.Body.Close() + if resp.StatusCode != http.StatusPermanentRedirect { + t.Fatalf("status = %d", resp.StatusCode) + } + stopped := manager.Stop(context.Background()) + if stopped.Running || stopped.Reason != "stopped" { + t.Fatalf("stopped = %+v", stopped) + } +} + +func TestManagerStartsHTTPSWhenCertificateProvided(t *testing.T) { + dir := t.TempDir() + certFile, keyFile := writeSelfSignedCert(t, dir) + manager := NewManager() + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + + status := manager.Apply(ctx, ListenerConfig{ + RuntimeConfig: RuntimeConfig{ServiceType: "admin-ingress", Scope: "platform", ServiceClasses: []string{"platform_admin"}}, + HTTPAddr: "127.0.0.1:0", + HTTPSAddr: "127.0.0.1:0", + TLSCertFile: certFile, + TLSKeyFile: keyFile, + }) + if !status.HTTPRunning || !status.HTTPSRunning || status.HTTPAddr == "" || status.HTTPSAddr == "" || len(status.Errors) != 0 { + t.Fatalf("status = %+v", status) + } +} + +func writeSelfSignedCert(t *testing.T, dir string) (string, string) { + t.Helper() + key, err := rsa.GenerateKey(rand.Reader, 2048) + if err != nil { + t.Fatalf("generate key: %v", err) + } + template := x509.Certificate{ + SerialNumber: big.NewInt(1), + Subject: pkix.Name{CommonName: "localhost"}, + NotBefore: time.Now().Add(-time.Hour), + NotAfter: time.Now().Add(time.Hour), + KeyUsage: x509.KeyUsageKeyEncipherment | x509.KeyUsageDigitalSignature, + DNSNames: []string{"localhost"}, + } + der, err := x509.CreateCertificate(rand.Reader, &template, &template, &key.PublicKey, key) + if err != nil { + t.Fatalf("create cert: %v", err) + } + certFile := filepath.Join(dir, "cert.pem") + keyFile := filepath.Join(dir, "key.pem") + if err := os.WriteFile(certFile, pem.EncodeToMemory(&pem.Block{Type: "CERTIFICATE", Bytes: der}), 0o600); err != nil { + t.Fatalf("write cert: %v", err) + } + if err := os.WriteFile(keyFile, pem.EncodeToMemory(&pem.Block{Type: "RSA PRIVATE KEY", Bytes: x509.MarshalPKCS1PrivateKey(key)}), 0o600); err != nil { + t.Fatalf("write key: %v", err) + } + return certFile, keyFile +} + +func containsError(values []string, needle string) bool { + for _, value := range values { + if value == needle || strings.Contains(value, needle) { + return true + } + } + return false +} diff --git a/agents/rap-node-agent/internal/webingress/mesh_sender.go b/agents/rap-node-agent/internal/webingress/mesh_sender.go new file mode 100644 index 0000000..b37532e --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/mesh_sender.go @@ -0,0 +1,217 @@ +package webingress + +import ( + "context" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh" +) + +var ( + ErrMeshEnvelopeRuntimeRequired = errors.New("web ingress mesh envelope runtime required") + ErrMeshEnvelopeRouteRequired = errors.New("web ingress mesh envelope route set required") + ErrMeshEnvelopeIdentityInvalid = errors.New("web ingress mesh envelope identity invalid") +) + +type FabricChannelReliableRuntime interface { + SendReliable(ctx context.Context, spec mesh.FabricChannelSpec, routeSet mesh.FabricRouteSet, payloads [][]byte) (mesh.FabricChannelRuntimeResult, error) +} + +type FabricChannelRequestResponseRuntime interface { + SendRequestResponse(ctx context.Context, spec mesh.FabricChannelSpec, routeSet mesh.FabricRouteSet, payload []byte) (mesh.FabricChannelRequestResponseResult, error) +} + +type MeshEnvelopeSender struct { + Runtime FabricChannelReliableRuntime + ResponseRuntime FabricChannelRequestResponseRuntime + RouteSet mesh.FabricRouteSet + ClusterID string + SourceNodeID string + TargetKind mesh.FabricChannelTargetKind + TargetID string + ChannelID string + Now func() time.Time +} + +type MeshEnvelopeDeliveryResponse struct { + SchemaVersion string `json:"schema_version"` + Status string `json:"status"` + ChannelID string `json:"channel_id"` + RouteID string `json:"route_id,omitempty"` + TargetNode string `json:"target_node,omitempty"` + BytesSent uint64 `json:"bytes_sent"` + FramesSent uint64 `json:"frames_sent"` + AcksReceived uint64 `json:"acks_received"` + MigrationEvents int `json:"migration_events"` +} + +func (s MeshEnvelopeSender) Send(ctx context.Context, envelope SignedFabricServiceChannelEnvelope) (FabricResponse, error) { + if s.Runtime == nil && s.ResponseRuntime == nil { + return FabricResponse{}, ErrMeshEnvelopeRuntimeRequired + } + if strings.TrimSpace(s.RouteSet.Primary.RouteID) == "" && len(s.RouteSet.WarmStandby) == 0 && len(s.RouteSet.ColdFallbacks) == 0 { + return FabricResponse{}, ErrMeshEnvelopeRouteRequired + } + spec, err := s.channelSpec(envelope) + if err != nil { + return FabricResponse{}, err + } + payload, err := json.Marshal(envelope) + if err != nil { + return FabricResponse{}, err + } + if s.ResponseRuntime != nil { + result, err := s.ResponseRuntime.SendRequestResponse(ctx, spec, s.routeSet(spec), payload) + if err != nil { + return FabricResponse{}, err + } + responsePayload, err := unwrapWebIngressForwardResponse(result.ResponsePayload) + if err != nil { + return FabricResponse{}, err + } + if response, ok := decodeRuntimeHTTPResponse(responsePayload); ok { + return response, nil + } + return acceptedDeliveryResponse(spec.ChannelID, result.FabricChannelRuntimeResult) + } + result, err := s.Runtime.SendReliable(ctx, spec, s.routeSet(spec), [][]byte{payload}) + if err != nil { + return FabricResponse{}, err + } + return acceptedDeliveryResponse(spec.ChannelID, result) +} + +func unwrapWebIngressForwardResponse(payload []byte) ([]byte, error) { + var response struct { + Payload json.RawMessage `json:"payload,omitempty"` + Error string `json:"error,omitempty"` + } + if len(payload) == 0 || json.Unmarshal(payload, &response) != nil { + return payload, nil + } + if strings.TrimSpace(response.Error) != "" { + return nil, fmt.Errorf("%w: %s", ErrMeshEnvelopeRuntimeRequired, response.Error) + } + if len(response.Payload) == 0 { + return payload, nil + } + return append([]byte(nil), response.Payload...), nil +} + +func acceptedDeliveryResponse(channelID string, result mesh.FabricChannelRuntimeResult) (FabricResponse, error) { + response, err := json.Marshal(MeshEnvelopeDeliveryResponse{ + SchemaVersion: "rap.web_ingress.mesh_envelope_delivery_response.v1", + Status: "accepted", + ChannelID: channelID, + RouteID: result.Channel.RouteID, + TargetNode: result.Channel.TargetNode, + BytesSent: result.BytesSent, + FramesSent: result.FramesSent, + AcksReceived: result.AcksReceived, + MigrationEvents: result.MigrationEvents, + }) + if err != nil { + return FabricResponse{}, err + } + return FabricResponse{ + StatusCode: http.StatusAccepted, + Headers: http.Header{"Content-Type": []string{"application/json"}}, + Body: response, + }, nil +} + +func decodeRuntimeHTTPResponse(payload []byte) (FabricResponse, bool) { + var response struct { + SchemaVersion string `json:"schema_version"` + StatusCode int `json:"status_code"` + Headers map[string][]string `json:"headers,omitempty"` + BodyBase64 string `json:"body_b64,omitempty"` + Body string `json:"body,omitempty"` + } + if len(payload) == 0 || json.Unmarshal(payload, &response) != nil { + return FabricResponse{}, false + } + if response.SchemaVersion != FabricRuntimeResponseSchema { + return FabricResponse{}, false + } + body := []byte(response.Body) + if response.BodyBase64 != "" { + decoded, err := decodeEnvelopeBase64(response.BodyBase64) + if err != nil { + return FabricResponse{}, false + } + body = decoded + } + headers := http.Header{} + for key, values := range response.Headers { + if !safeResponseHeader(key) { + continue + } + for _, value := range values { + headers.Add(key, value) + } + } + return FabricResponse{StatusCode: response.StatusCode, Headers: headers, Body: body}, true +} + +func (s MeshEnvelopeSender) channelSpec(envelope SignedFabricServiceChannelEnvelope) (mesh.FabricChannelSpec, error) { + clusterID := strings.TrimSpace(s.ClusterID) + sourceNodeID := strings.TrimSpace(s.SourceNodeID) + targetID := strings.TrimSpace(s.TargetID) + if clusterID == "" || sourceNodeID == "" || targetID == "" { + return mesh.FabricChannelSpec{}, ErrMeshEnvelopeIdentityInvalid + } + targetKind := s.TargetKind + if targetKind == "" { + targetKind = mesh.FabricChannelTargetPool + } + channelID := strings.TrimSpace(s.ChannelID) + if channelID == "" { + channelID = defaultMeshEnvelopeChannelID(envelope, s.now()) + } + spec := mesh.FabricChannelSpec{ + ChannelID: channelID, + ClusterID: clusterID, + SourceNodeID: sourceNodeID, + TargetKind: targetKind, + TargetID: targetID, + TrafficClass: "control", + StickyKey: envelope.Envelope.Scope + ":" + envelope.Envelope.ServiceClass, + CreatedAt: s.now(), + } + if err := mesh.ValidateFabricChannelSpec(spec); err != nil { + return mesh.FabricChannelSpec{}, err + } + return spec, nil +} + +func (s MeshEnvelopeSender) routeSet(spec mesh.FabricChannelSpec) mesh.FabricRouteSet { + routeSet := s.RouteSet + if routeSet.TargetKind == "" { + routeSet.TargetKind = spec.TargetKind + } + if strings.TrimSpace(routeSet.TargetID) == "" { + routeSet.TargetID = spec.TargetID + } + return routeSet +} + +func (s MeshEnvelopeSender) now() time.Time { + if s.Now != nil { + return s.Now().UTC() + } + return time.Now().UTC() +} + +func defaultMeshEnvelopeChannelID(envelope SignedFabricServiceChannelEnvelope, now time.Time) string { + serviceClass := strings.ReplaceAll(strings.TrimSpace(envelope.Envelope.ServiceClass), "_", "-") + if serviceClass == "" { + serviceClass = "web-ingress" + } + return fmt.Sprintf("web-ingress-%s-%d", serviceClass, now.UnixNano()) +} diff --git a/agents/rap-node-agent/internal/webingress/mesh_sender_test.go b/agents/rap-node-agent/internal/webingress/mesh_sender_test.go new file mode 100644 index 0000000..b8186b6 --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/mesh_sender_test.go @@ -0,0 +1,267 @@ +package webingress + +import ( + "context" + "encoding/json" + "errors" + "net/http" + "testing" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh" +) + +func TestMeshEnvelopeSenderSendsSignedEnvelopeOverReliableFabricRuntime(t *testing.T) { + runtime := &recordingReliableRuntime{ + result: mesh.FabricChannelRuntimeResult{ + Channel: mesh.FabricChannel{RouteID: "route-fast", TargetNode: "node-runtime"}, + BytesSent: 123, + FramesSent: 1, + AcksReceived: 1, + }, + } + sender := MeshEnvelopeSender{ + Runtime: runtime, + RouteSet: testWebIngressRouteSet(), + ClusterID: "cluster-1", + SourceNodeID: "node-ingress", + TargetKind: mesh.FabricChannelTargetPool, + TargetID: "pool-admin-runtime", + ChannelID: "channel-web-1", + Now: fixedEnvelopeNow, + } + envelope := SignedFabricServiceChannelEnvelope{ + SchemaVersion: "rap.web_ingress.signed_fabric_service_channel_envelope.v1", + Envelope: FabricServiceChannelEnvelope{ + SchemaVersion: FabricServiceChannelEnvelopeSchema, + Scope: "platform", + ServiceClass: "platform_admin", + }, + Signature: FabricEnvelopeSignature{KeyID: "node-key", Alg: "ed25519", Signature: "sig"}, + } + + response, err := sender.Send(context.Background(), envelope) + if err != nil { + t.Fatalf("send: %v", err) + } + if response.StatusCode != http.StatusAccepted || response.Headers.Get("Content-Type") != "application/json" { + t.Fatalf("response = %+v", response) + } + if runtime.spec.ChannelID != "channel-web-1" || + runtime.spec.ClusterID != "cluster-1" || + runtime.spec.SourceNodeID != "node-ingress" || + runtime.spec.TargetID != "pool-admin-runtime" || + runtime.spec.TargetKind != mesh.FabricChannelTargetPool || + runtime.spec.TrafficClass != "control" || + runtime.spec.StickyKey != "platform:platform_admin" { + t.Fatalf("spec = %+v", runtime.spec) + } + if runtime.routeSet.TargetID != "pool-admin-runtime" || len(runtime.payloads) != 1 { + t.Fatalf("route/payload = %+v payloads=%d", runtime.routeSet, len(runtime.payloads)) + } + var delivered SignedFabricServiceChannelEnvelope + if err := json.Unmarshal(runtime.payloads[0], &delivered); err != nil { + t.Fatalf("decode delivered envelope: %v", err) + } + if delivered.Signature.Signature != "sig" || delivered.Envelope.ServiceClass != "platform_admin" { + t.Fatalf("delivered = %+v", delivered) + } + var body MeshEnvelopeDeliveryResponse + if err := json.Unmarshal(response.Body, &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.SchemaVersion != "rap.web_ingress.mesh_envelope_delivery_response.v1" || + body.Status != "accepted" || + body.RouteID != "route-fast" || + body.AcksReceived != 1 { + t.Fatalf("body = %+v", body) + } +} + +func TestMeshEnvelopeSenderReturnsRuntimeHTTPResponse(t *testing.T) { + runtime := &recordingRequestResponseRuntime{ + result: mesh.FabricChannelRequestResponseResult{ + FabricChannelRuntimeResult: mesh.FabricChannelRuntimeResult{ + Channel: mesh.FabricChannel{RouteID: "route-runtime", TargetNode: "node-runtime"}, + BytesSent: 123, + BytesRecv: 16, + FramesSent: 1, + FramesRecv: 1, + AcksReceived: 1, + }, + ResponsePayload: []byte(`{"payload":{"schema_version":"rap.web_ingress.fabric_runtime_response.v1","status_code":201,"headers":{"X-RAP-Runtime":["ok"],"Set-Cookie":["blocked"]},"body_b64":"eyJvayI6dHJ1ZX0="}}`), + }, + } + sender := MeshEnvelopeSender{ + ResponseRuntime: runtime, + RouteSet: testWebIngressRouteSet(), + ClusterID: "cluster-1", + SourceNodeID: "node-ingress", + TargetKind: mesh.FabricChannelTargetPool, + TargetID: "pool-admin-runtime", + ChannelID: "channel-web-1", + Now: fixedEnvelopeNow, + } + + response, err := sender.Send(context.Background(), SignedFabricServiceChannelEnvelope{ + SchemaVersion: "rap.web_ingress.signed_fabric_service_channel_envelope.v1", + Envelope: FabricServiceChannelEnvelope{SchemaVersion: FabricServiceChannelEnvelopeSchema, Scope: "platform", ServiceClass: "platform_admin"}, + Signature: FabricEnvelopeSignature{KeyID: "node-key", Alg: "ed25519", Signature: "sig"}, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + if response.StatusCode != http.StatusCreated || response.Headers.Get("X-RAP-Runtime") != "ok" || response.Headers.Get("Set-Cookie") != "" || string(response.Body) != `{"ok":true}` { + t.Fatalf("response = %+v body=%s", response, string(response.Body)) + } + if runtime.spec.ChannelID != "channel-web-1" || len(runtime.payload) == 0 { + t.Fatalf("runtime spec=%+v payload=%s", runtime.spec, string(runtime.payload)) + } +} + +func TestMeshEnvelopeSenderReportsWrappedRuntimeError(t *testing.T) { + sender := MeshEnvelopeSender{ + ResponseRuntime: &recordingRequestResponseRuntime{ + result: mesh.FabricChannelRequestResponseResult{ResponsePayload: []byte(`{"error":"runtime unavailable"}`)}, + }, + RouteSet: testWebIngressRouteSet(), + ClusterID: "cluster-1", + SourceNodeID: "node-ingress", + TargetID: "pool-admin-runtime", + ChannelID: "channel-web-1", + } + + _, err := sender.Send(context.Background(), SignedFabricServiceChannelEnvelope{ + Envelope: FabricServiceChannelEnvelope{Scope: "platform", ServiceClass: "platform_admin"}, + }) + if !errors.Is(err, ErrMeshEnvelopeRuntimeRequired) { + t.Fatalf("err = %v", err) + } +} + +func TestMeshEnvelopeSenderFallsBackToDeliveryAckForNonHTTPRuntimePayload(t *testing.T) { + runtime := &recordingRequestResponseRuntime{ + result: mesh.FabricChannelRequestResponseResult{ + FabricChannelRuntimeResult: mesh.FabricChannelRuntimeResult{ + Channel: mesh.FabricChannel{RouteID: "route-runtime", TargetNode: "node-runtime"}, + BytesSent: 123, + FramesSent: 1, + AcksReceived: 1, + }, + ResponsePayload: []byte(`{"not":"http"}`), + }, + } + sender := MeshEnvelopeSender{ + ResponseRuntime: runtime, + RouteSet: testWebIngressRouteSet(), + ClusterID: "cluster-1", + SourceNodeID: "node-ingress", + TargetID: "pool-admin-runtime", + ChannelID: "channel-web-1", + } + + response, err := sender.Send(context.Background(), SignedFabricServiceChannelEnvelope{ + Envelope: FabricServiceChannelEnvelope{Scope: "platform", ServiceClass: "platform_admin"}, + }) + if err != nil { + t.Fatalf("send: %v", err) + } + if response.StatusCode != http.StatusAccepted { + t.Fatalf("response = %+v", response) + } + var body MeshEnvelopeDeliveryResponse + if err := json.Unmarshal(response.Body, &body); err != nil { + t.Fatalf("decode response: %v", err) + } + if body.Status != "accepted" || body.RouteID != "route-runtime" { + t.Fatalf("body = %+v", body) + } +} + +func TestMeshEnvelopeSenderReportsRuntimeRouteAndIdentityErrors(t *testing.T) { + _, err := (MeshEnvelopeSender{}).Send(context.Background(), SignedFabricServiceChannelEnvelope{}) + if !errors.Is(err, ErrMeshEnvelopeRuntimeRequired) { + t.Fatalf("runtime error = %v", err) + } + + _, err = (MeshEnvelopeSender{ + Runtime: &recordingReliableRuntime{}, + ClusterID: "cluster-1", + SourceNodeID: "node-ingress", + TargetID: "pool-admin-runtime", + }).Send(context.Background(), SignedFabricServiceChannelEnvelope{}) + if !errors.Is(err, ErrMeshEnvelopeRouteRequired) { + t.Fatalf("route error = %v", err) + } + + _, err = (MeshEnvelopeSender{ + Runtime: &recordingReliableRuntime{}, + RouteSet: testWebIngressRouteSet(), + }).Send(context.Background(), SignedFabricServiceChannelEnvelope{}) + if !errors.Is(err, ErrMeshEnvelopeIdentityInvalid) { + t.Fatalf("identity error = %v", err) + } +} + +func TestMeshEnvelopeSenderPropagatesReliableRuntimeFailure(t *testing.T) { + sendErr := errors.New("send failed") + _, err := (MeshEnvelopeSender{ + Runtime: &recordingReliableRuntime{err: sendErr}, + RouteSet: testWebIngressRouteSet(), + ClusterID: "cluster-1", + SourceNodeID: "node-ingress", + TargetID: "pool-admin-runtime", + }).Send(context.Background(), SignedFabricServiceChannelEnvelope{}) + if !errors.Is(err, sendErr) { + t.Fatalf("send error = %v", err) + } +} + +type recordingReliableRuntime struct { + spec mesh.FabricChannelSpec + routeSet mesh.FabricRouteSet + payloads [][]byte + result mesh.FabricChannelRuntimeResult + err error +} + +type recordingRequestResponseRuntime struct { + spec mesh.FabricChannelSpec + routeSet mesh.FabricRouteSet + payload []byte + result mesh.FabricChannelRequestResponseResult + err error +} + +func (r *recordingRequestResponseRuntime) SendRequestResponse(_ context.Context, spec mesh.FabricChannelSpec, routeSet mesh.FabricRouteSet, payload []byte) (mesh.FabricChannelRequestResponseResult, error) { + r.spec = spec + r.routeSet = routeSet + r.payload = payload + if r.err != nil { + return mesh.FabricChannelRequestResponseResult{}, r.err + } + return r.result, nil +} + +func (r *recordingReliableRuntime) SendReliable(_ context.Context, spec mesh.FabricChannelSpec, routeSet mesh.FabricRouteSet, payloads [][]byte) (mesh.FabricChannelRuntimeResult, error) { + r.spec = spec + r.routeSet = routeSet + r.payloads = payloads + if r.err != nil { + return mesh.FabricChannelRuntimeResult{}, r.err + } + return r.result, nil +} + +func testWebIngressRouteSet() mesh.FabricRouteSet { + return mesh.FabricRouteSet{ + Primary: mesh.FabricRoute{ + RouteID: "route-fast", + ClusterID: "cluster-1", + SourceNodeID: "node-ingress", + DestinationNodeID: "node-runtime", + PoolID: "pool-admin-runtime", + Healthy: true, + Capacity: 100, + }, + } +} diff --git a/agents/rap-node-agent/internal/webingress/receiver.go b/agents/rap-node-agent/internal/webingress/receiver.go new file mode 100644 index 0000000..8e9c44e --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/receiver.go @@ -0,0 +1,219 @@ +package webingress + +import ( + "context" + "crypto/ed25519" + "encoding/base64" + "encoding/json" + "errors" + "fmt" + "net/http" + "strings" + "time" +) + +const ( + SignedFabricServiceChannelEnvelopeSchema = "rap.web_ingress.signed_fabric_service_channel_envelope.v1" + FabricRuntimeResponseSchema = "rap.web_ingress.fabric_runtime_response.v1" +) + +var ( + ErrFabricEnvelopeSignatureInvalid = errors.New("web ingress fabric envelope signature invalid") + ErrFabricEnvelopeUnauthorized = errors.New("web ingress fabric envelope unauthorized") + ErrFabricEnvelopeRuntimeRequired = errors.New("web ingress fabric runtime handler required") +) + +type EnvelopeKeyResolver interface { + PublicKey(ctx context.Context, keyID string) (ed25519.PublicKey, bool, error) +} + +type EnvelopeRuntimeHandler interface { + HandleFabricRequest(ctx context.Context, request FabricRequest) (FabricResponse, error) +} + +type RuntimeHandlerFunc func(ctx context.Context, request FabricRequest) (FabricResponse, error) + +func (f RuntimeHandlerFunc) HandleFabricRequest(ctx context.Context, request FabricRequest) (FabricResponse, error) { + return f(ctx, request) +} + +type ReceiverConfig struct { + ServiceType string + Scope string + ServiceClasses []string + MaxClockSkew time.Duration +} + +type FabricRuntimeReceiver struct { + Config ReceiverConfig + Keys EnvelopeKeyResolver + Handler EnvelopeRuntimeHandler + Now func() time.Time +} + +type StaticEnvelopeKeyResolver map[string]ed25519.PublicKey + +func (r StaticEnvelopeKeyResolver) PublicKey(_ context.Context, keyID string) (ed25519.PublicKey, bool, error) { + key, ok := r[strings.TrimSpace(keyID)] + if !ok { + return nil, false, nil + } + return append(ed25519.PublicKey(nil), key...), true, nil +} + +func (r FabricRuntimeReceiver) Receive(ctx context.Context, payload []byte) ([]byte, error) { + response, err := r.ReceiveResponse(ctx, payload) + if err != nil { + return nil, err + } + return encodeFabricRuntimeResponse(response) +} + +func (r FabricRuntimeReceiver) ReceiveResponse(ctx context.Context, payload []byte) (FabricResponse, error) { + if r.Handler == nil { + return FabricResponse{}, ErrFabricEnvelopeRuntimeRequired + } + var signed SignedFabricServiceChannelEnvelope + if err := json.Unmarshal(payload, &signed); err != nil { + return FabricResponse{}, fmt.Errorf("%w: invalid signed envelope json", ErrFabricEnvelopeSignatureInvalid) + } + if err := r.verify(ctx, signed); err != nil { + return FabricResponse{}, err + } + request, err := requestFromEnvelope(signed.Envelope) + if err != nil { + return FabricResponse{}, err + } + return r.Handler.HandleFabricRequest(ctx, request) +} + +func (r FabricRuntimeReceiver) verify(ctx context.Context, signed SignedFabricServiceChannelEnvelope) error { + if signed.SchemaVersion != SignedFabricServiceChannelEnvelopeSchema { + return fmt.Errorf("%w: signed schema mismatch", ErrFabricEnvelopeSignatureInvalid) + } + if signed.Envelope.SchemaVersion != FabricServiceChannelEnvelopeSchema || + strings.TrimSpace(signed.Envelope.Scope) == "" || + strings.TrimSpace(signed.Envelope.ServiceClass) == "" { + return fmt.Errorf("%w: envelope contract invalid", ErrFabricEnvelopeSignatureInvalid) + } + if scope := strings.TrimSpace(r.Config.Scope); scope != "" && signed.Envelope.Scope != scope { + return fmt.Errorf("%w: scope mismatch", ErrFabricEnvelopeUnauthorized) + } + if len(r.Config.ServiceClasses) > 0 && !contains(r.Config.ServiceClasses, signed.Envelope.ServiceClass) { + return fmt.Errorf("%w: service class not allowed", ErrFabricEnvelopeUnauthorized) + } + if err := r.verifyClock(signed.Envelope); err != nil { + return err + } + if r.Keys == nil { + return fmt.Errorf("%w: key resolver required", ErrFabricEnvelopeSignatureInvalid) + } + keyID := strings.TrimSpace(signed.Signature.KeyID) + publicKey, ok, err := r.Keys.PublicKey(ctx, keyID) + if err != nil { + return err + } + if !ok || len(publicKey) != ed25519.PublicKeySize { + return fmt.Errorf("%w: signing key not trusted", ErrFabricEnvelopeUnauthorized) + } + if signed.Signature.Alg != "ed25519" { + return fmt.Errorf("%w: algorithm mismatch", ErrFabricEnvelopeSignatureInvalid) + } + signature, err := decodeEnvelopeBase64(signed.Signature.Signature) + if err != nil || len(signature) != ed25519.SignatureSize { + return fmt.Errorf("%w: signature must be base64 ed25519", ErrFabricEnvelopeSignatureInvalid) + } + canonical, err := json.Marshal(signed.Envelope) + if err != nil { + return err + } + if !ed25519.Verify(publicKey, canonical, signature) { + return ErrFabricEnvelopeSignatureInvalid + } + return nil +} + +func (r FabricRuntimeReceiver) verifyClock(envelope FabricServiceChannelEnvelope) error { + maxSkew := r.Config.MaxClockSkew + if maxSkew <= 0 { + maxSkew = 5 * time.Minute + } + now := time.Now().UTC() + if r.Now != nil { + now = r.Now().UTC() + } + for _, value := range []string{envelope.ObservedAt, envelope.EnvelopedAt} { + if strings.TrimSpace(value) == "" { + continue + } + parsed, err := time.Parse(time.RFC3339Nano, value) + if err != nil { + return fmt.Errorf("%w: invalid envelope timestamp", ErrFabricEnvelopeSignatureInvalid) + } + if parsed.After(now.Add(maxSkew)) || parsed.Before(now.Add(-maxSkew)) { + return fmt.Errorf("%w: envelope timestamp outside skew", ErrFabricEnvelopeUnauthorized) + } + } + return nil +} + +func requestFromEnvelope(envelope FabricServiceChannelEnvelope) (FabricRequest, error) { + body, err := base64.StdEncoding.DecodeString(envelope.BodyBase64) + if err != nil && envelope.BodyBase64 != "" { + return FabricRequest{}, fmt.Errorf("%w: invalid body_b64", ErrFabricEnvelopeSignatureInvalid) + } + observedAt, _ := time.Parse(time.RFC3339Nano, envelope.ObservedAt) + headers := http.Header{} + for key, values := range envelope.Headers { + if !safeRequestHeader(key) { + continue + } + for _, value := range values { + headers.Add(key, value) + } + } + return FabricRequest{ + SchemaVersion: envelope.RequestSchema, + Method: envelope.Method, + Path: envelope.Path, + Query: envelope.Query, + Host: envelope.Host, + ServiceType: envelope.ServiceType, + Scope: envelope.Scope, + ServiceClass: envelope.ServiceClass, + Headers: headers, + Body: body, + ObservedAt: observedAt, + }, nil +} + +func encodeFabricRuntimeResponse(response FabricResponse) ([]byte, error) { + headers := map[string][]string{} + for key, values := range response.Headers { + if !safeResponseHeader(key) { + continue + } + copied := append([]string(nil), values...) + if len(copied) > 0 { + headers[http.CanonicalHeaderKey(key)] = copied + } + } + payload := struct { + SchemaVersion string `json:"schema_version"` + StatusCode int `json:"status_code"` + Headers map[string][]string `json:"headers,omitempty"` + BodyBase64 string `json:"body_b64,omitempty"` + }{ + SchemaVersion: FabricRuntimeResponseSchema, + StatusCode: response.StatusCode, + Headers: headers, + BodyBase64: base64.StdEncoding.EncodeToString(response.Body), + } + if payload.StatusCode < 100 || payload.StatusCode > 599 { + payload.StatusCode = http.StatusOK + } + if len(payload.Headers) == 0 { + payload.Headers = nil + } + return json.Marshal(payload) +} diff --git a/agents/rap-node-agent/internal/webingress/receiver_test.go b/agents/rap-node-agent/internal/webingress/receiver_test.go new file mode 100644 index 0000000..b243c96 --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/receiver_test.go @@ -0,0 +1,194 @@ +package webingress + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "encoding/json" + "errors" + "net/http" + "testing" +) + +func TestFabricRuntimeReceiverVerifiesEnvelopeAndReturnsRuntimeResponse(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + keyID := ed25519EnvelopeKeyID(publicKey) + receiver := FabricRuntimeReceiver{ + Config: ReceiverConfig{ServiceType: "global-admin-runtime", Scope: "platform", ServiceClasses: []string{"platform_admin"}}, + Keys: StaticEnvelopeKeyResolver{keyID: publicKey}, + Handler: recordingRuntimeHandler{response: FabricResponse{ + StatusCode: http.StatusCreated, + Headers: http.Header{"X-RAP-Runtime": []string{"ok"}, "Set-Cookie": []string{"blocked"}}, + Body: []byte(`{"ok":true}`), + }}, + Now: fixedEnvelopeNow, + } + payload := signedReceiverEnvelope(t, privateKey, keyID, FabricServiceChannelEnvelope{ + SchemaVersion: FabricServiceChannelEnvelopeSchema, + RequestSchema: "rap.web_ingress.fabric_request.v1", + Method: http.MethodPost, + Path: "/platform-admin/root", + Query: "tab=nodes", + Host: "admin.example.test", + ServiceType: "admin-ingress", + Scope: "platform", + ServiceClass: "platform_admin", + Headers: map[string][]string{"X-Trace-Id": {"trace-1"}}, + BodyBase64: base64.StdEncoding.EncodeToString([]byte(`{"hello":"world"}`)), + ObservedAt: "2026-05-17T00:00:00Z", + EnvelopedAt: "2026-05-17T00:00:01Z", + }) + + responsePayload, err := receiver.Receive(context.Background(), payload) + if err != nil { + t.Fatalf("receive: %v", err) + } + var response struct { + SchemaVersion string `json:"schema_version"` + StatusCode int `json:"status_code"` + Headers map[string][]string `json:"headers"` + BodyBase64 string `json:"body_b64"` + } + if err := json.Unmarshal(responsePayload, &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.SchemaVersion != FabricRuntimeResponseSchema || + response.StatusCode != http.StatusCreated || + response.Headers["X-Rap-Runtime"][0] != "ok" || + response.Headers["Set-Cookie"] != nil || + response.BodyBase64 != "eyJvayI6dHJ1ZX0=" { + t.Fatalf("response = %+v", response) + } +} + +func TestFabricRuntimeReceiverRejectsBadSignatureScopeClassAndStaleEnvelope(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + keyID := ed25519EnvelopeKeyID(publicKey) + receiver := FabricRuntimeReceiver{ + Config: ReceiverConfig{Scope: "platform", ServiceClasses: []string{"platform_admin"}}, + Keys: StaticEnvelopeKeyResolver{keyID: publicKey}, + Handler: recordingRuntimeHandler{}, + Now: fixedEnvelopeNow, + } + + base := FabricServiceChannelEnvelope{ + SchemaVersion: FabricServiceChannelEnvelopeSchema, + RequestSchema: "rap.web_ingress.fabric_request.v1", + Method: http.MethodGet, + Path: "/platform-admin/root", + Scope: "platform", + ServiceClass: "platform_admin", + ObservedAt: "2026-05-17T00:00:00Z", + EnvelopedAt: "2026-05-17T00:00:01Z", + } + badSignature := signedReceiverEnvelope(t, privateKey, keyID, base) + badSignature[len(badSignature)-2] = 'x' + if _, err := receiver.Receive(context.Background(), badSignature); !errors.Is(err, ErrFabricEnvelopeSignatureInvalid) { + t.Fatalf("bad signature err = %v", err) + } + + wrongScope := base + wrongScope.Scope = "organization" + if _, err := receiver.Receive(context.Background(), signedReceiverEnvelope(t, privateKey, keyID, wrongScope)); !errors.Is(err, ErrFabricEnvelopeUnauthorized) { + t.Fatalf("wrong scope err = %v", err) + } + + wrongClass := base + wrongClass.ServiceClass = "cluster_admin" + if _, err := receiver.Receive(context.Background(), signedReceiverEnvelope(t, privateKey, keyID, wrongClass)); !errors.Is(err, ErrFabricEnvelopeUnauthorized) { + t.Fatalf("wrong class err = %v", err) + } + + stale := base + stale.EnvelopedAt = "2026-05-16T00:00:00Z" + if _, err := receiver.Receive(context.Background(), signedReceiverEnvelope(t, privateKey, keyID, stale)); !errors.Is(err, ErrFabricEnvelopeUnauthorized) { + t.Fatalf("stale err = %v", err) + } +} + +func TestFabricRuntimeReceiverRequiresTrustedKeyAndHandler(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + keyID := ed25519EnvelopeKeyID(publicKey) + payload := signedReceiverEnvelope(t, privateKey, keyID, FabricServiceChannelEnvelope{ + SchemaVersion: FabricServiceChannelEnvelopeSchema, + Scope: "platform", + ServiceClass: "platform_admin", + ObservedAt: "2026-05-17T00:00:00Z", + EnvelopedAt: "2026-05-17T00:00:01Z", + }) + + _, err = (FabricRuntimeReceiver{Keys: StaticEnvelopeKeyResolver{keyID: publicKey}, Now: fixedEnvelopeNow}).Receive(context.Background(), payload) + if !errors.Is(err, ErrFabricEnvelopeRuntimeRequired) { + t.Fatalf("handler err = %v", err) + } + + _, err = (FabricRuntimeReceiver{Handler: recordingRuntimeHandler{}, Now: fixedEnvelopeNow}).Receive(context.Background(), payload) + if !errors.Is(err, ErrFabricEnvelopeSignatureInvalid) { + t.Fatalf("key resolver err = %v", err) + } + + _, otherPrivateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate other key: %v", err) + } + untrusted := signedReceiverEnvelope(t, otherPrivateKey, "other-key", FabricServiceChannelEnvelope{ + SchemaVersion: FabricServiceChannelEnvelopeSchema, + Scope: "platform", + ServiceClass: "platform_admin", + ObservedAt: "2026-05-17T00:00:00Z", + EnvelopedAt: "2026-05-17T00:00:01Z", + }) + _, err = (FabricRuntimeReceiver{Keys: StaticEnvelopeKeyResolver{keyID: publicKey}, Handler: recordingRuntimeHandler{}, Now: fixedEnvelopeNow}).Receive(context.Background(), untrusted) + if !errors.Is(err, ErrFabricEnvelopeUnauthorized) { + t.Fatalf("untrusted key err = %v", err) + } +} + +func signedReceiverEnvelope(t *testing.T, privateKey ed25519.PrivateKey, keyID string, envelope FabricServiceChannelEnvelope) []byte { + t.Helper() + canonical, err := json.Marshal(envelope) + if err != nil { + t.Fatalf("marshal envelope: %v", err) + } + payload, err := json.Marshal(SignedFabricServiceChannelEnvelope{ + SchemaVersion: SignedFabricServiceChannelEnvelopeSchema, + Envelope: envelope, + Signature: FabricEnvelopeSignature{ + KeyID: keyID, + Alg: "ed25519", + Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(privateKey, canonical)), + SignedAt: "2026-05-17T00:00:01Z", + }, + }) + if err != nil { + t.Fatalf("marshal signed envelope: %v", err) + } + return payload +} + +type recordingRuntimeHandler struct { + request FabricRequest + response FabricResponse + err error +} + +func (h recordingRuntimeHandler) HandleFabricRequest(_ context.Context, request FabricRequest) (FabricResponse, error) { + h.request = request + if h.err != nil { + return FabricResponse{}, h.err + } + if h.response.StatusCode == 0 { + h.response = FabricResponse{StatusCode: http.StatusOK, Body: []byte(`{"ready":true}`)} + } + return h.response, nil +} diff --git a/agents/rap-node-agent/internal/webingress/runtime.go b/agents/rap-node-agent/internal/webingress/runtime.go new file mode 100644 index 0000000..7e5e2ce --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/runtime.go @@ -0,0 +1,243 @@ +package webingress + +import ( + "context" + "encoding/json" + "io" + "net/http" + "strings" + "time" +) + +type RuntimeConfig struct { + ServiceType string + Scope string + ServiceClasses []string + TLSMode string + HTTPPort int + HTTPSPort int +} + +type Runtime struct { + Config RuntimeConfig + Binder FabricBinder + Now func() time.Time +} + +type FabricBinder interface { + Forward(ctx context.Context, request FabricRequest) (FabricResponse, error) +} + +type FabricRequest struct { + SchemaVersion string + Method string + Path string + Query string + Host string + ServiceType string + Scope string + ServiceClass string + Headers http.Header + Body []byte + ObservedAt time.Time +} + +type FabricResponse struct { + StatusCode int + Headers http.Header + Body []byte +} + +type Response struct { + SchemaVersion string `json:"schema_version"` + Status string `json:"status"` + Reason string `json:"reason,omitempty"` + ServiceType string `json:"service_type,omitempty"` + Scope string `json:"scope,omitempty"` + ServiceClass string `json:"service_class,omitempty"` + Allowed []string `json:"allowed_service_classes,omitempty"` + ObservedAt string `json:"observed_at"` +} + +func (r Runtime) HTTPHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if strings.HasPrefix(req.URL.Path, "/.well-known/acme-challenge/") { + writeJSON(w, http.StatusNotFound, r.response("not_found", "acme_challenge_backend_not_configured", "")) + return + } + if req.URL.Path == "/healthz" || req.URL.Path == "/readyz" { + writeJSON(w, http.StatusOK, r.response("ready", "http_redirect_runtime_ready", "")) + return + } + target := "https://" + req.Host + req.URL.RequestURI() + w.Header().Set("Location", target) + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(http.StatusPermanentRedirect) + }) +} + +func (r Runtime) HTTPSHandler() http.Handler { + return http.HandlerFunc(func(w http.ResponseWriter, req *http.Request) { + if req.URL.Path == "/healthz" || req.URL.Path == "/readyz" { + writeJSON(w, http.StatusOK, r.response("ready", "https_runtime_ready", "")) + return + } + serviceClass := strings.TrimSpace(req.Header.Get("X-RAP-Service-Class")) + if serviceClass == "" { + serviceClass = serviceClassFromPath(req.URL.Path) + } + if serviceClass == "" { + writeJSON(w, http.StatusBadRequest, r.response("blocked", "service_class_required", "")) + return + } + if !contains(r.Config.ServiceClasses, serviceClass) { + writeJSON(w, http.StatusForbidden, r.response("blocked", "service_class_not_allowed", serviceClass)) + return + } + if r.Binder == nil { + writeJSON(w, http.StatusNotImplemented, r.response("blocked", "fabric_service_channel_binding_not_implemented", serviceClass)) + return + } + scope := scopeForServiceClass(serviceClass, r.Config.Scope) + body, err := io.ReadAll(http.MaxBytesReader(w, req.Body, 1<<20)) + if err != nil { + writeJSON(w, http.StatusRequestEntityTooLarge, r.response("blocked", "request_body_too_large", serviceClass)) + return + } + now := time.Now().UTC() + if r.Now != nil { + now = r.Now().UTC() + } + fabricResponse, err := r.Binder.Forward(req.Context(), FabricRequest{ + SchemaVersion: "rap.web_ingress.fabric_request.v1", + Method: req.Method, + Path: req.URL.Path, + Query: req.URL.RawQuery, + Host: req.Host, + ServiceType: strings.TrimSpace(r.Config.ServiceType), + Scope: scope, + ServiceClass: serviceClass, + Headers: cloneSafeHeaders(req.Header), + Body: body, + ObservedAt: now, + }) + if err != nil { + writeJSON(w, http.StatusBadGateway, r.response("blocked", "fabric_service_channel_forward_failed", serviceClass)) + return + } + writeFabricResponse(w, fabricResponse) + }) +} + +func (r Runtime) response(status, reason, serviceClass string) Response { + now := time.Now().UTC() + if r.Now != nil { + now = r.Now().UTC() + } + return Response{ + SchemaVersion: "rap.web_ingress.runtime_response.v1", + Status: status, + Reason: reason, + ServiceType: strings.TrimSpace(r.Config.ServiceType), + Scope: strings.TrimSpace(r.Config.Scope), + ServiceClass: serviceClass, + Allowed: append([]string{}, r.Config.ServiceClasses...), + ObservedAt: now.Format(time.RFC3339Nano), + } +} + +func scopeForServiceClass(serviceClass string, fallback string) string { + switch strings.TrimSpace(serviceClass) { + case "platform_admin": + return "platform" + case "cluster_admin": + return "cluster" + case "organization_portal": + return "organization" + case "user_portal": + return "user" + default: + return strings.TrimSpace(fallback) + } +} + +func serviceClassFromPath(path string) string { + path = strings.Trim(strings.ToLower(path), "/") + switch { + case strings.HasPrefix(path, "platform-admin"): + return "platform_admin" + case strings.HasPrefix(path, "cluster-admin"): + return "cluster_admin" + case strings.HasPrefix(path, "organizations/"): + return "organization_portal" + case strings.HasPrefix(path, "users/"): + return "user_portal" + default: + return "" + } +} + +func writeJSON(w http.ResponseWriter, status int, payload Response) { + w.Header().Set("Content-Type", "application/json") + w.Header().Set("Cache-Control", "no-store") + w.WriteHeader(status) + _ = json.NewEncoder(w).Encode(payload) +} + +func writeFabricResponse(w http.ResponseWriter, payload FabricResponse) { + for key, values := range payload.Headers { + if !safeResponseHeader(key) { + continue + } + for _, value := range values { + w.Header().Add(key, value) + } + } + w.Header().Set("Cache-Control", "no-store") + status := payload.StatusCode + if status < 100 || status > 599 { + status = http.StatusOK + } + w.WriteHeader(status) + _, _ = w.Write(payload.Body) +} + +func cloneSafeHeaders(headers http.Header) http.Header { + out := http.Header{} + for key, values := range headers { + if !safeRequestHeader(key) { + continue + } + for _, value := range values { + out.Add(key, value) + } + } + return out +} + +func safeRequestHeader(key string) bool { + switch strings.ToLower(strings.TrimSpace(key)) { + case "authorization", "cookie", "set-cookie", "x-rap-service-channel-token": + return false + default: + return true + } +} + +func safeResponseHeader(key string) bool { + switch strings.ToLower(strings.TrimSpace(key)) { + case "set-cookie", "transfer-encoding", "connection": + return false + default: + return true + } +} + +func contains(values []string, needle string) bool { + for _, value := range values { + if value == needle { + return true + } + } + return false +} diff --git a/agents/rap-node-agent/internal/webingress/runtime_test.go b/agents/rap-node-agent/internal/webingress/runtime_test.go new file mode 100644 index 0000000..06e2e0c --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/runtime_test.go @@ -0,0 +1,206 @@ +package webingress + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "strings" + "testing" + "time" +) + +func TestHTTPHandlerRedirectsToHTTPS(t *testing.T) { + runtime := Runtime{Config: RuntimeConfig{ServiceType: "admin-ingress", Scope: "platform"}} + req := httptest.NewRequest(http.MethodGet, "http://admin.example.test/cluster-admin/dashboard?x=1", nil) + rec := httptest.NewRecorder() + + runtime.HTTPHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusPermanentRedirect { + t.Fatalf("status = %d", rec.Code) + } + if rec.Header().Get("Location") != "https://admin.example.test/cluster-admin/dashboard?x=1" { + t.Fatalf("Location = %q", rec.Header().Get("Location")) + } +} + +func TestHTTPSHandlerBlocksUnknownServiceClass(t *testing.T) { + runtime := Runtime{ + Config: RuntimeConfig{ + ServiceType: "public-ingress", + Scope: "organization", + ServiceClasses: []string{"organization_portal", "user_portal"}, + }, + Now: fixedNow, + } + req := httptest.NewRequest(http.MethodGet, "https://org.example.test/platform-admin/root", nil) + rec := httptest.NewRecorder() + + runtime.HTTPSHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusForbidden { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var payload Response + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Reason != "service_class_not_allowed" || payload.ServiceClass != "platform_admin" || payload.Scope != "organization" { + t.Fatalf("payload = %+v", payload) + } +} + +func TestHTTPSHandlerRequiresFabricServiceChannelBinding(t *testing.T) { + runtime := Runtime{ + Config: RuntimeConfig{ + ServiceType: "admin-ingress", + Scope: "platform", + ServiceClasses: []string{"platform_admin", "cluster_admin"}, + }, + Now: fixedNow, + } + req := httptest.NewRequest(http.MethodPost, "https://admin.example.test/platform-admin/root", nil) + rec := httptest.NewRecorder() + + runtime.HTTPSHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusNotImplemented { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var payload Response + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Reason != "fabric_service_channel_binding_not_implemented" || + payload.ServiceClass != "platform_admin" || + payload.ObservedAt != "2026-05-17T00:00:00Z" { + t.Fatalf("payload = %+v", payload) + } +} + +func TestHTTPSHandlerForwardsAllowedRequestToBinder(t *testing.T) { + binder := &recordingBinder{ + response: FabricResponse{ + StatusCode: http.StatusAccepted, + Headers: http.Header{"X-RAP-Result": []string{"accepted"}}, + Body: []byte(`{"ok":true}`), + }, + } + runtime := Runtime{ + Config: RuntimeConfig{ + ServiceType: "admin-ingress", + Scope: "platform", + ServiceClasses: []string{"platform_admin", "cluster_admin"}, + }, + Binder: binder, + Now: fixedNow, + } + req := httptest.NewRequest(http.MethodPost, "https://admin.example.test/platform-admin/root?tab=nodes", strings.NewReader(`{"hello":"world"}`)) + req.Header.Set("X-RAP-Service-Class", "platform_admin") + req.Header.Set("Authorization", "Bearer secret") + req.Header.Set("X-Trace-ID", "trace-1") + rec := httptest.NewRecorder() + + runtime.HTTPSHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusAccepted { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + if rec.Header().Get("X-RAP-Result") != "accepted" || rec.Body.String() != `{"ok":true}` { + t.Fatalf("unexpected response headers=%v body=%s", rec.Header(), rec.Body.String()) + } + if binder.request.ServiceClass != "platform_admin" || + binder.request.Scope != "platform" || + binder.request.Path != "/platform-admin/root" || + binder.request.Query != "tab=nodes" || + string(binder.request.Body) != `{"hello":"world"}` { + t.Fatalf("request = %+v", binder.request) + } + if binder.request.Headers.Get("Authorization") != "" || binder.request.Headers.Get("X-Trace-ID") != "trace-1" { + t.Fatalf("headers = %#v", binder.request.Headers) + } +} + +func TestHTTPSHandlerDerivesFabricScopeFromServiceClass(t *testing.T) { + binder := &recordingBinder{response: FabricResponse{StatusCode: http.StatusOK}} + runtime := Runtime{ + Config: RuntimeConfig{ + ServiceType: "admin-ingress", + Scope: "platform", + ServiceClasses: []string{"platform_admin", "cluster_admin"}, + }, + Binder: binder, + Now: fixedNow, + } + req := httptest.NewRequest(http.MethodGet, "https://admin.example.test/cluster-admin/ui-manifest", nil) + rec := httptest.NewRecorder() + + runtime.HTTPSHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + if binder.request.ServiceClass != "cluster_admin" || binder.request.Scope != "cluster" { + t.Fatalf("request = %+v", binder.request) + } +} + +func TestHTTPSHandlerReportsBinderFailure(t *testing.T) { + runtime := Runtime{ + Config: RuntimeConfig{ServiceType: "admin-ingress", Scope: "platform", ServiceClasses: []string{"platform_admin"}}, + Binder: failingBinder{}, + Now: fixedNow, + } + req := httptest.NewRequest(http.MethodPost, "https://admin.example.test/platform-admin/root", nil) + rec := httptest.NewRecorder() + + runtime.HTTPSHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusBadGateway { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var payload Response + if err := json.Unmarshal(rec.Body.Bytes(), &payload); err != nil { + t.Fatalf("decode response: %v", err) + } + if payload.Reason != "fabric_service_channel_forward_failed" { + t.Fatalf("payload = %+v", payload) + } +} + +func TestHTTPSHandlerHealth(t *testing.T) { + runtime := Runtime{Config: RuntimeConfig{ServiceType: "admin-ingress", Scope: "platform"}, Now: fixedNow} + req := httptest.NewRequest(http.MethodGet, "https://admin.example.test/healthz", nil) + rec := httptest.NewRecorder() + + runtime.HTTPSHandler().ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } +} + +func fixedNow() time.Time { + return time.Date(2026, 5, 17, 0, 0, 0, 0, time.UTC) +} + +type recordingBinder struct { + request FabricRequest + response FabricResponse +} + +func (b *recordingBinder) Forward(_ context.Context, request FabricRequest) (FabricResponse, error) { + b.request = request + return b.response, nil +} + +type failingBinder struct{} + +func (failingBinder) Forward(context.Context, FabricRequest) (FabricResponse, error) { + return FabricResponse{}, errTestBinderFailure{} +} + +type errTestBinderFailure struct{} + +func (errTestBinderFailure) Error() string { return "binder failed" } diff --git a/agents/rap-node-agent/internal/webingress/signer.go b/agents/rap-node-agent/internal/webingress/signer.go new file mode 100644 index 0000000..d738802 --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/signer.go @@ -0,0 +1,95 @@ +package webingress + +import ( + "context" + "crypto/ed25519" + "crypto/sha256" + "encoding/base64" + "encoding/hex" + "errors" + "fmt" + "strings" + "time" +) + +var ErrFabricEnvelopeSigningKeyInvalid = errors.New("web ingress fabric envelope signing key invalid") + +type Ed25519EnvelopeSigner struct { + PrivateKey ed25519.PrivateKey + KeyID string + Now func() time.Time +} + +func NewEd25519EnvelopeSigner(privateKeyB64, keyID string) (Ed25519EnvelopeSigner, error) { + privateKey, err := decodeEd25519PrivateKey(privateKeyB64) + if err != nil { + return Ed25519EnvelopeSigner{}, err + } + keyID = strings.TrimSpace(keyID) + if keyID == "" { + publicKey, ok := privateKey.Public().(ed25519.PublicKey) + if !ok { + return Ed25519EnvelopeSigner{}, ErrFabricEnvelopeSigningKeyInvalid + } + keyID = ed25519EnvelopeKeyID(publicKey) + } + return Ed25519EnvelopeSigner{PrivateKey: privateKey, KeyID: keyID}, nil +} + +func (s Ed25519EnvelopeSigner) Sign(_ context.Context, canonical []byte) (FabricEnvelopeSignature, error) { + if len(s.PrivateKey) != ed25519.PrivateKeySize { + return FabricEnvelopeSignature{}, ErrFabricEnvelopeSigningKeyInvalid + } + if len(canonical) == 0 { + return FabricEnvelopeSignature{}, fmt.Errorf("%w: canonical envelope empty", ErrFabricEnvelopeSigningKeyInvalid) + } + keyID := strings.TrimSpace(s.KeyID) + if keyID == "" { + publicKey, ok := s.PrivateKey.Public().(ed25519.PublicKey) + if !ok { + return FabricEnvelopeSignature{}, ErrFabricEnvelopeSigningKeyInvalid + } + keyID = ed25519EnvelopeKeyID(publicKey) + } + now := time.Now().UTC() + if s.Now != nil { + now = s.Now().UTC() + } + return FabricEnvelopeSignature{ + KeyID: keyID, + Alg: "ed25519", + Signature: base64.StdEncoding.EncodeToString(ed25519.Sign(s.PrivateKey, canonical)), + SignedAt: now.Format(time.RFC3339Nano), + }, nil +} + +func decodeEd25519PrivateKey(value string) (ed25519.PrivateKey, error) { + decoded, err := decodeEnvelopeBase64(strings.TrimSpace(value)) + if err != nil { + return nil, fmt.Errorf("%w: private key must be base64 encoded", ErrFabricEnvelopeSigningKeyInvalid) + } + if len(decoded) != ed25519.PrivateKeySize { + return nil, fmt.Errorf("%w: private key must decode to %d bytes", ErrFabricEnvelopeSigningKeyInvalid, ed25519.PrivateKeySize) + } + return ed25519.PrivateKey(decoded), nil +} + +func decodeEnvelopeBase64(value string) ([]byte, error) { + if value == "" { + return nil, errors.New("empty base64 value") + } + decoded, err := base64.StdEncoding.DecodeString(value) + if err == nil { + return decoded, nil + } + decoded, err = base64.RawStdEncoding.DecodeString(value) + if err == nil { + return decoded, nil + } + return base64.RawURLEncoding.DecodeString(value) +} + +func ed25519EnvelopeKeyID(publicKey ed25519.PublicKey) string { + sum := sha256.Sum256(publicKey) + return "rap-node-ed25519-" + hex.EncodeToString(sum[:16]) +} diff --git a/agents/rap-node-agent/internal/webingress/signer_test.go b/agents/rap-node-agent/internal/webingress/signer_test.go new file mode 100644 index 0000000..fbf14e9 --- /dev/null +++ b/agents/rap-node-agent/internal/webingress/signer_test.go @@ -0,0 +1,80 @@ +package webingress + +import ( + "context" + "crypto/ed25519" + "crypto/rand" + "encoding/base64" + "errors" + "testing" +) + +func TestEd25519EnvelopeSignerSignsCanonicalEnvelope(t *testing.T) { + publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + signer, err := NewEd25519EnvelopeSigner(base64.StdEncoding.EncodeToString(privateKey), "") + if err != nil { + t.Fatalf("new signer: %v", err) + } + signer.Now = fixedEnvelopeNow + + signature, err := signer.Sign(context.Background(), []byte(`{"schema_version":"test"}`)) + if err != nil { + t.Fatalf("sign: %v", err) + } + decoded, err := base64.StdEncoding.DecodeString(signature.Signature) + if err != nil { + t.Fatalf("decode signature: %v", err) + } + if !ed25519.Verify(publicKey, []byte(`{"schema_version":"test"}`), decoded) { + t.Fatal("signature did not verify") + } + if signature.KeyID != ed25519EnvelopeKeyID(publicKey) || + signature.Alg != "ed25519" || + signature.SignedAt != "2026-05-17T00:00:01Z" { + t.Fatalf("signature metadata = %+v", signature) + } +} + +func TestEd25519EnvelopeSignerUsesExplicitKeyID(t *testing.T) { + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + signer, err := NewEd25519EnvelopeSigner(base64.RawStdEncoding.EncodeToString(privateKey), "node-explicit") + if err != nil { + t.Fatalf("new signer: %v", err) + } + signature, err := signer.Sign(context.Background(), []byte(`{}`)) + if err != nil { + t.Fatalf("sign: %v", err) + } + if signature.KeyID != "node-explicit" { + t.Fatalf("key id = %q", signature.KeyID) + } +} + +func TestEd25519EnvelopeSignerRejectsInvalidKeyAndPayload(t *testing.T) { + _, err := NewEd25519EnvelopeSigner("not-base64", "") + if !errors.Is(err, ErrFabricEnvelopeSigningKeyInvalid) { + t.Fatalf("invalid key error = %v", err) + } + + signer := Ed25519EnvelopeSigner{} + _, err = signer.Sign(context.Background(), []byte(`{}`)) + if !errors.Is(err, ErrFabricEnvelopeSigningKeyInvalid) { + t.Fatalf("missing key error = %v", err) + } + + _, privateKey, err := ed25519.GenerateKey(rand.Reader) + if err != nil { + t.Fatalf("generate key: %v", err) + } + signer = Ed25519EnvelopeSigner{PrivateKey: privateKey} + _, err = signer.Sign(context.Background(), nil) + if !errors.Is(err, ErrFabricEnvelopeSigningKeyInvalid) { + t.Fatalf("empty canonical error = %v", err) + } +} diff --git a/agents/rap-node-agent/mobile/fabricvpn/fabricvpn.go b/agents/rap-node-agent/mobile/fabricvpn/fabricvpn.go new file mode 100644 index 0000000..d4c4b0e --- /dev/null +++ b/agents/rap-node-agent/mobile/fabricvpn/fabricvpn.go @@ -0,0 +1,504 @@ +package fabricvpn + +import ( + "context" + "crypto/tls" + "encoding/json" + "fmt" + "net" + "strings" + "sync" + "sync/atomic" + "syscall" + "time" + + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/fabricproto" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/mesh" + "github.com/example/remote-access-platform/agents/rap-node-agent/internal/vpnruntime" + "github.com/quic-go/quic-go" +) + +type endpointConfig struct { + EndpointID string `json:"endpoint_id"` + NodeID string `json:"node_id"` + Transport string `json:"transport"` + Address string `json:"address"` + PeerCertSHA256 string `json:"peer_cert_sha256"` + TLSCertSHA256 string `json:"tls_cert_sha256"` + Priority int `json:"priority"` +} + +type runtimeConfig struct { + ClusterID string `json:"cluster_id"` + LocalNodeID string `json:"local_node_id"` + ExitNodeID string `json:"exit_node_id"` + VPNConnectionID string `json:"vpn_connection_id"` + Endpoints []endpointConfig `json:"endpoints"` + RouteBundle routeBundleConfig `json:"route_bundle"` + ServiceChannelRequest serviceChannelRequest `json:"service_channel_request"` + StreamShards int `json:"stream_shards"` +} + +type routeBundleConfig struct { + SchemaVersion string `json:"schema_version"` + RouteAuthority string `json:"route_authority"` + SelectedTargetNode string `json:"selected_target_node_id"` + EndpointCandidates []endpointConfig `json:"endpoint_candidates"` + TargetCandidates []endpointConfig `json:"target_candidates"` + RouteLease routeLeaseConfig `json:"route_lease"` +} + +type routeLeaseConfig struct { + SchemaVersion string `json:"schema_version"` + LeaseID string `json:"lease_id"` + SelectedTargetNode string `json:"selected_target_node"` + PrimaryPath routeLeasePath `json:"primary_path"` + WarmStandbyPaths []routeLeasePath `json:"warm_standby_paths"` + Multipath map[string]any `json:"multipath"` + RebuildPolicy map[string]any `json:"rebuild_policy"` +} + +type routeLeasePath struct { + PathID string `json:"path_id"` + TargetNodeID string `json:"target_node_id"` + Status string `json:"status"` + EndpointCandidates []endpointConfig `json:"endpoint_candidates"` +} + +type serviceChannelRequest struct { + SchemaVersion string `json:"schema_version"` + ChannelID string `json:"channel_id"` + ServiceClass string `json:"service_class"` + SourceRole string `json:"source_role"` +} + +type SocketProtector interface { + Protect(fd int64) bool +} + +type Manager struct { + opMu sync.Mutex + mu sync.Mutex + cancel context.CancelFunc + transport *mesh.QUICFabricTransport + session mesh.FabricTransportSession + packet *vpnruntime.FabricSessionPacketTransport + inbox *vpnruntime.FabricPacketInbox + cfg runtimeConfig + lastErr string + endpoint string + protector SocketProtector + + uplinkPackets atomic.Uint64 + uplinkBytes atomic.Uint64 + downlinkPackets atomic.Uint64 + downlinkBytes atomic.Uint64 +} + +func NewManager() *Manager { + return &Manager{} +} + +func (m *Manager) SetSocketProtector(protector SocketProtector) { + m.mu.Lock() + m.protector = protector + m.mu.Unlock() +} + +func (m *Manager) Start(configJSON string) error { + var cfg runtimeConfig + if err := json.Unmarshal([]byte(configJSON), &cfg); err != nil { + return err + } + cfg.ClusterID = strings.TrimSpace(cfg.ClusterID) + cfg.LocalNodeID = strings.TrimSpace(cfg.LocalNodeID) + cfg.ExitNodeID = strings.TrimSpace(cfg.ExitNodeID) + cfg.VPNConnectionID = strings.TrimSpace(cfg.VPNConnectionID) + cfg.Endpoints = fabricRuntimeEndpoints(cfg) + cfg.ExitNodeID = firstNonEmpty(cfg.ExitNodeID, fabricRuntimeTargetNodeID(cfg)) + if cfg.ClusterID == "" || cfg.LocalNodeID == "" || cfg.VPNConnectionID == "" { + return fmt.Errorf("cluster, local node and vpn connection id are required") + } + if strings.TrimSpace(cfg.ServiceChannelRequest.SchemaVersion) == "" { + return fmt.Errorf("fabric service channel request is required") + } + if len(cfg.Endpoints) == 0 { + return fmt.Errorf("fabric route lease has no QUIC candidates") + } + if cfg.StreamShards <= 0 { + cfg.StreamShards = 4 + } + if cfg.StreamShards > 32 { + cfg.StreamShards = 32 + } + + m.Stop() + ctx, cancel := context.WithCancel(context.Background()) + if err := m.connect(ctx, cfg, cancel); err != nil { + cancel() + m.setErr(err) + return err + } + return nil +} + +func fabricRuntimeEndpoints(cfg runtimeConfig) []endpointConfig { + if len(cfg.RouteBundle.RouteLease.PrimaryPath.EndpointCandidates) > 0 { + return cfg.RouteBundle.RouteLease.PrimaryPath.EndpointCandidates + } + for _, path := range cfg.RouteBundle.RouteLease.WarmStandbyPaths { + if len(path.EndpointCandidates) > 0 { + return path.EndpointCandidates + } + } + if len(cfg.RouteBundle.EndpointCandidates) > 0 { + return cfg.RouteBundle.EndpointCandidates + } + if len(cfg.RouteBundle.TargetCandidates) > 0 { + return cfg.RouteBundle.TargetCandidates + } + return cfg.Endpoints +} + +func fabricRuntimeTargetNodeID(cfg runtimeConfig) string { + if cfg.RouteBundle.RouteLease.PrimaryPath.TargetNodeID != "" { + return cfg.RouteBundle.RouteLease.PrimaryPath.TargetNodeID + } + if cfg.RouteBundle.RouteLease.SelectedTargetNode != "" { + return cfg.RouteBundle.RouteLease.SelectedTargetNode + } + return cfg.RouteBundle.SelectedTargetNode +} + +func (m *Manager) connect(ctx context.Context, cfg runtimeConfig, cancel context.CancelFunc) error { + quicTransport := mesh.NewQUICFabricTransport(nil) + quicTransport.SetLocalPeerID(cfg.LocalNodeID) + quicTransport.DialAddr = m.protectedQUICDialer() + inbox := vpnruntime.NewFabricPacketInbox(4096) + quicTransport.SetInboundHandlers(func(ctx context.Context, envelope mesh.ProductionEnvelope) (mesh.ProductionForwardResult, error) { + if err := inbox.DeliverProductionEnvelope(ctx, envelope); err != nil { + return mesh.ProductionForwardResult{}, err + } + return mesh.ProductionForwardResult{Delivered: true, MessageID: envelope.MessageID}, nil + }, nil, nil) + + var lastErr error + for _, endpoint := range cfg.Endpoints { + target := mesh.FabricTransportTarget{ + EndpointID: firstNonEmpty(endpoint.EndpointID, endpoint.Address), + PeerID: firstNonEmpty(endpoint.NodeID, cfg.ExitNodeID), + Endpoint: endpoint.Address, + Transport: firstNonEmpty(endpoint.Transport, "direct_quic"), + PeerCertSHA256: firstNonEmpty(endpoint.PeerCertSHA256, endpoint.TLSCertSHA256), + Timeout: 5 * time.Second, + OutboundBuffer: 512, + InboundBuffer: 512, + ErrorBuffer: 32, + } + carrier, selected, err := mesh.FabricTransportForTarget(target, quicTransport) + if err != nil { + lastErr = err + continue + } + dialCtx, dialCancel := context.WithTimeout(ctx, 5*time.Second) + session, err := carrier.Connect(dialCtx, selected) + if err != nil { + dialCancel() + lastErr = err + continue + } + streamIDs, streamID, err := openStreams(dialCtx, session, cfg.StreamShards) + dialCancel() + if err != nil { + _ = session.Close() + lastErr = err + continue + } + m.mu.Lock() + m.cancel = cancel + m.transport = quicTransport + m.session = session + m.inbox = inbox + m.cfg = cfg + m.endpoint = endpoint.Address + m.lastErr = "" + m.packet = &vpnruntime.FabricSessionPacketTransport{ + Sender: session, + Receiver: session, + Inbox: inbox, + StreamID: streamID, + StreamIDsByTrafficClass: streamIDs, + VPNConnectionID: cfg.VPNConnectionID, + SendDirection: vpnruntime.FabricDirectionClientToGateway, + ReceiveDirection: vpnruntime.FabricDirectionGatewayToClient, + } + m.mu.Unlock() + return nil + } + if lastErr == nil { + lastErr = fmt.Errorf("no QUIC exit endpoints available") + } + return lastErr +} + +func (m *Manager) protectedQUICDialer() func(context.Context, string, *tls.Config, *quic.Config) (*quic.Conn, error) { + m.mu.Lock() + protector := m.protector + m.mu.Unlock() + if protector == nil { + return nil + } + return func(ctx context.Context, endpoint string, tlsConfig *tls.Config, config *quic.Config) (*quic.Conn, error) { + network := "udp4" + if strings.Contains(endpoint, "[") { + network = "udp6" + } + conn, err := net.ListenPacket(network, ":0") + if err != nil { + return nil, err + } + raw, ok := conn.(interface { + SyscallConn() (syscall.RawConn, error) + }) + if !ok { + _ = conn.Close() + return nil, fmt.Errorf("udp socket does not expose raw connection for vpn protection") + } + rawConn, err := raw.SyscallConn() + if err != nil { + _ = conn.Close() + return nil, err + } + var protectErr error + if err := rawConn.Control(func(fd uintptr) { + if !protector.Protect(int64(fd)) { + protectErr = fmt.Errorf("android vpn socket protect failed") + } + }); err != nil { + _ = conn.Close() + return nil, err + } + if protectErr != nil { + _ = conn.Close() + return nil, protectErr + } + return mesh.DialQUICAddrWithPacketConn(ctx, endpoint, conn, tlsConfig, config) + } +} + +func (m *Manager) Stop() { + m.opMu.Lock() + defer m.opMu.Unlock() + m.stopLocked() +} + +func (m *Manager) stopLocked() { + m.mu.Lock() + cancel := m.cancel + session := m.session + transport := m.transport + m.cancel = nil + m.session = nil + m.transport = nil + m.packet = nil + m.mu.Unlock() + if cancel != nil { + cancel() + } + if session != nil { + _ = session.Close() + } + if transport != nil { + _ = transport.Close() + } +} + +func (m *Manager) SendPacket(packet []byte) error { + if len(packet) == 0 { + return nil + } + m.opMu.Lock() + defer m.opMu.Unlock() + if err := m.ensureConnectedLocked(); err != nil { + return err + } + m.mu.Lock() + transport := m.packet + m.mu.Unlock() + if transport == nil { + return fmt.Errorf("fabric vpn runtime is not connected") + } + ctx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := transport.SendGatewayPacketBatch(ctx, [][]byte{append([]byte(nil), packet...)}); err != nil { + m.setErr(err) + if reconnectErr := m.reconnectLocked(); reconnectErr != nil { + return err + } + m.mu.Lock() + transport = m.packet + m.mu.Unlock() + if transport == nil { + return err + } + retryCtx, retryCancel := context.WithTimeout(context.Background(), 5*time.Second) + defer retryCancel() + if retryErr := transport.SendGatewayPacketBatch(retryCtx, [][]byte{append([]byte(nil), packet...)}); retryErr != nil { + m.setErr(retryErr) + return retryErr + } + } + m.uplinkPackets.Add(1) + m.uplinkBytes.Add(uint64(len(packet))) + return nil +} + +func (m *Manager) ReceivePacket(timeoutMillis int) ([]byte, error) { + m.opMu.Lock() + defer m.opMu.Unlock() + if err := m.ensureConnectedLocked(); err != nil { + return nil, err + } + m.mu.Lock() + transport := m.packet + m.mu.Unlock() + if transport == nil { + return nil, fmt.Errorf("fabric vpn runtime is not connected") + } + timeout := time.Duration(timeoutMillis) * time.Millisecond + if timeout <= 0 { + timeout = 100 * time.Millisecond + } + ctx, cancel := context.WithTimeout(context.Background(), timeout+time.Second) + defer cancel() + packets, err := transport.ReceiveGatewayPacketBatch(ctx, timeout) + if err != nil { + m.setErr(err) + _ = m.reconnectLocked() + return nil, err + } + if len(packets) == 0 { + return nil, nil + } + packet := append([]byte(nil), packets[0]...) + m.downlinkPackets.Add(1) + m.downlinkBytes.Add(uint64(len(packet))) + return packet, nil +} + +func (m *Manager) Reconnect() error { + m.opMu.Lock() + defer m.opMu.Unlock() + return m.reconnectLocked() +} + +func (m *Manager) ensureConnectedLocked() error { + m.mu.Lock() + connected := m.packet != nil + cancel := m.cancel + m.mu.Unlock() + if connected { + return nil + } + if cancel == nil { + return fmt.Errorf("fabric vpn runtime is stopped") + } + return m.reconnectLocked() +} + +func (m *Manager) reconnectLocked() error { + m.mu.Lock() + cfg := m.cfg + oldSession := m.session + oldTransport := m.transport + cancel := m.cancel + m.session = nil + m.transport = nil + m.packet = nil + m.mu.Unlock() + if oldSession != nil { + _ = oldSession.Close() + } + if oldTransport != nil { + _ = oldTransport.Close() + } + if cancel == nil { + return fmt.Errorf("fabric vpn runtime is stopped") + } + ctx, ctxCancel := context.WithTimeout(context.Background(), 8*time.Second) + defer ctxCancel() + if err := m.connect(ctx, cfg, cancel); err != nil { + m.setErr(err) + return err + } + return nil +} + +func (m *Manager) SnapshotJSON() string { + m.mu.Lock() + connected := m.packet != nil + endpoint := m.endpoint + lastErr := m.lastErr + vpnConnectionID := m.cfg.VPNConnectionID + localNodeID := m.cfg.LocalNodeID + exitNodeID := m.cfg.ExitNodeID + m.mu.Unlock() + payload, _ := json.Marshal(map[string]any{ + "schema_version": "rap.android_fabric_vpn_runtime.v1", + "connected": connected, + "endpoint": endpoint, + "last_error": lastErr, + "vpn_connection": vpnConnectionID, + "local_node_id": localNodeID, + "exit_node_id": exitNodeID, + "uplink_packets": m.uplinkPackets.Load(), + "uplink_bytes": m.uplinkBytes.Load(), + "downlink_packets": m.downlinkPackets.Load(), + "downlink_bytes": m.downlinkBytes.Load(), + }) + return string(payload) +} + +func (m *Manager) setErr(err error) { + if err == nil { + return + } + m.mu.Lock() + m.lastErr = err.Error() + m.mu.Unlock() +} + +func openStreams(ctx context.Context, session mesh.FabricTransportSession, shards int) (map[string][]uint64, uint64, error) { + base := uint64(time.Now().UnixNano()) + classes := []struct { + name string + trafficClass fabricproto.TrafficClass + }{ + {name: vpnruntime.FabricTrafficClassInteractive, trafficClass: fabricproto.TrafficClassInteractive}, + {name: vpnruntime.FabricTrafficClassBulk, trafficClass: fabricproto.TrafficClassBulk}, + } + out := make(map[string][]uint64, len(classes)) + var primary uint64 + for classIndex, class := range classes { + for shard := 0; shard < shards; shard++ { + streamID := base + uint64(classIndex*shards+shard) + if err := session.Send(ctx, fabricproto.Frame{Type: fabricproto.FrameOpenStream, StreamID: streamID, TrafficClass: class.trafficClass}); err != nil { + return nil, 0, err + } + if primary == 0 { + primary = streamID + } + out[class.name] = append(out[class.name], streamID) + } + } + return out, primary, nil +} + +func firstNonEmpty(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} diff --git a/agents/rap-node-agent/mobile/fabricvpn/fabricvpn_test.go b/agents/rap-node-agent/mobile/fabricvpn/fabricvpn_test.go new file mode 100644 index 0000000..cbe744a --- /dev/null +++ b/agents/rap-node-agent/mobile/fabricvpn/fabricvpn_test.go @@ -0,0 +1,137 @@ +package fabricvpn + +import ( + "os" + "testing" +) + +func TestFabricRuntimeEndpointsPreferRouteBundle(t *testing.T) { + cfg := runtimeConfig{ + Endpoints: []endpointConfig{{EndpointID: "legacy", Address: "quic://legacy.example:19131"}}, + RouteBundle: routeBundleConfig{ + EndpointCandidates: []endpointConfig{{EndpointID: "bundle", Address: "quic://bundle.example:19131"}}, + }, + } + got := fabricRuntimeEndpoints(cfg) + if len(got) != 1 || got[0].EndpointID != "bundle" { + t.Fatalf("endpoints = %+v, want route bundle endpoint", got) + } +} + +func TestFabricRuntimeEndpointsPreferRouteLease(t *testing.T) { + cfg := runtimeConfig{ + Endpoints: []endpointConfig{{EndpointID: "legacy", Address: "quic://legacy.example:19131"}}, + RouteBundle: routeBundleConfig{ + EndpointCandidates: []endpointConfig{{EndpointID: "bundle", Address: "quic://bundle.example:19131"}}, + RouteLease: routeLeaseConfig{ + SelectedTargetNode: "exit-1", + PrimaryPath: routeLeasePath{ + TargetNodeID: "exit-1", + EndpointCandidates: []endpointConfig{{EndpointID: "lease-primary", Address: "quic://lease.example:19131"}}, + }, + }, + }, + } + got := fabricRuntimeEndpoints(cfg) + if len(got) != 1 || got[0].EndpointID != "lease-primary" { + t.Fatalf("endpoints = %+v, want route lease primary endpoint", got) + } + if target := fabricRuntimeTargetNodeID(cfg); target != "exit-1" { + t.Fatalf("target = %q, want exit-1", target) + } +} + +func TestFabricRuntimeEndpointsFallbackToLegacyEndpoints(t *testing.T) { + cfg := runtimeConfig{ + Endpoints: []endpointConfig{{EndpointID: "legacy", Address: "quic://legacy.example:19131"}}, + } + got := fabricRuntimeEndpoints(cfg) + if len(got) != 1 || got[0].EndpointID != "legacy" { + t.Fatalf("endpoints = %+v, want legacy endpoint fallback", got) + } +} + +func TestLiveFabricVPNRuntimeStartsFromRouteLease(t *testing.T) { + raw := os.Getenv("RAP_LIVE_FABRICVPN_CONFIG") + if raw == "" { + t.Skip("RAP_LIVE_FABRICVPN_CONFIG is not set") + } + manager := NewManager() + if err := manager.Start(raw); err != nil { + t.Fatalf("start live fabric vpn runtime: %v", err) + } + defer manager.Stop() + if snapshot := manager.SnapshotJSON(); snapshot == "" { + t.Fatal("empty live fabric vpn snapshot") + } + if os.Getenv("RAP_LIVE_FABRICVPN_PACKET_PROBE") == "" { + return + } + if err := manager.SendPacket(testDNSIPv4Packet()); err != nil { + t.Fatalf("send live dns packet: %v", err) + } + for i := 0; i < 20; i++ { + packet, err := manager.ReceivePacket(500) + if err != nil { + t.Fatalf("receive live dns packet: %v", err) + } + if len(packet) > 0 { + if packet[9] != 17 || packet[12] != 1 || packet[13] != 1 || packet[14] != 1 || packet[15] != 1 { + t.Fatalf("unexpected response packet header: %v", packet[:min(20, len(packet))]) + } + return + } + } + t.Fatal("timed out waiting for live dns response through fabric vpn") +} + +func testDNSIPv4Packet() []byte { + dns := []byte{ + 0x12, 0x34, 0x01, 0x00, 0x00, 0x01, 0x00, 0x00, + 0x00, 0x00, 0x00, 0x00, 0x07, 'e', 'x', 'a', + 'm', 'p', 'l', 'e', 0x03, 'c', 'o', 'm', 0x00, + 0x00, 0x01, 0x00, 0x01, + } + udpLen := 8 + len(dns) + totalLen := 20 + udpLen + packet := make([]byte, totalLen) + packet[0] = 0x45 + packet[2] = byte(totalLen >> 8) + packet[3] = byte(totalLen) + packet[8] = 64 + packet[9] = 17 + copy(packet[12:16], []byte{10, 77, 0, 2}) + copy(packet[16:20], []byte{1, 1, 1, 1}) + packet[20] = 0xcf + packet[21] = 0x08 + packet[22] = 0x00 + packet[23] = 0x35 + packet[24] = byte(udpLen >> 8) + packet[25] = byte(udpLen) + copy(packet[28:], dns) + sum := ipv4HeaderChecksum(packet[:20]) + packet[10] = byte(sum >> 8) + packet[11] = byte(sum) + return packet +} + +func ipv4HeaderChecksum(header []byte) uint16 { + var sum uint32 + for i := 0; i+1 < len(header); i += 2 { + if i == 10 { + continue + } + sum += uint32(header[i])<<8 | uint32(header[i+1]) + } + for sum > 0xffff { + sum = (sum & 0xffff) + (sum >> 16) + } + return ^uint16(sum) +} + +func min(a, b int) int { + if a < b { + return a + } + return b +} diff --git a/backend/internal/modules/cluster/models.go b/backend/internal/modules/cluster/models.go index 22bbd08..fec4e5f 100644 --- a/backend/internal/modules/cluster/models.go +++ b/backend/internal/modules/cluster/models.go @@ -53,6 +53,10 @@ const ( FabricServiceClassRemoteWorkspace = "remote_workspace" FabricServiceClassFileTransfer = "file_transfer" FabricServiceClassVideo = "video" + FabricServiceClassPlatformAdmin = "platform_admin" + FabricServiceClassClusterAdmin = "cluster_admin" + FabricServiceClassOrganization = "organization_portal" + FabricServiceClassUserPortal = "user_portal" FabricChannelControl = "control" FabricChannelInteractive = "interactive" @@ -62,16 +66,27 @@ const ( ) var allowedNodeRoles = map[string]struct{}{ - "entry-node": {}, - "relay-node": {}, - "core-mesh": {}, - "rdp-worker": {}, - "vnc-worker": {}, - "vpn-exit": {}, - "vpn-connector": {}, - "file-storage-cache": {}, - "update-cache": {}, - "video-relay": {}, + "public-ingress": {}, + "admin-ingress": {}, + "global-admin-runtime": {}, + "cluster-admin-runtime": {}, + "organization-portal-runtime": {}, + "user-portal-runtime": {}, + "identity-runtime": {}, + "policy-authority": {}, + "audit-sink": {}, + "entry-node": {}, + "relay-node": {}, + "core-mesh": {}, + "rdp-worker": {}, + "vnc-worker": {}, + "vpn-exit": {}, + "vpn-connector": {}, + "vpn-client": {}, + "ipv4-egress": {}, + "file-storage-cache": {}, + "update-cache": {}, + "video-relay": {}, } type Cluster struct { @@ -353,6 +368,7 @@ type NodeUpdatePlan struct { Artifact *ReleaseArtifact `json:"artifact,omitempty"` AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"` AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"` + AuthorityQuorum *QuorumEnvelope `json:"authority_quorum,omitempty"` ProductionForwarding bool `json:"production_forwarding"` } @@ -373,14 +389,15 @@ type NodeUpdateStatus struct { } type NodeBootstrap struct { - NodeID string `json:"node_id"` - ClusterID string `json:"cluster_id"` - IdentityStatus string `json:"identity_status"` - Certificate map[string]any `json:"certificate"` - HeartbeatEndpoint string `json:"heartbeat_endpoint"` - ClusterAuthority *ClusterAuthorityDescriptor `json:"cluster_authority,omitempty"` - AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"` - AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"` + NodeID string `json:"node_id"` + ClusterID string `json:"cluster_id"` + IdentityStatus string `json:"identity_status"` + Certificate map[string]any `json:"certificate"` + HeartbeatEndpoint string `json:"heartbeat_endpoint"` + ClusterAuthority *ClusterAuthorityDescriptor `json:"cluster_authority,omitempty"` + ClusterAuthorityQuorum *QuorumDescriptor `json:"cluster_authority_quorum,omitempty"` + AuthorityPayload json.RawMessage `json:"authority_payload,omitempty"` + AuthoritySignature *ClusterSignature `json:"authority_signature,omitempty"` } type NodeJoinRequest struct { @@ -1531,6 +1548,8 @@ type ClusterAuthorityState struct { } type ClusterSignature = clusterauth.Signature +type QuorumEnvelope = clusterauth.QuorumEnvelope +type QuorumDescriptor = clusterauth.QuorumDescriptor type ClusterAuthorityDescriptor struct { SchemaVersion string `json:"schema_version"` @@ -1545,7 +1564,9 @@ type ClusterAuthorityDescriptor struct { type ClusterAuthorityKey struct { ClusterAuthorityDescriptor - PrivateKey string `json:"-"` + PrivateKey string `json:"-"` + Metadata json.RawMessage `json:"metadata,omitempty"` + QuorumDescriptor *QuorumDescriptor `json:"quorum_descriptor,omitempty"` } type ClusterAdminSummary struct { @@ -1808,6 +1829,8 @@ type VPNClientConnection struct { AllowedNodeIDs []string `json:"allowed_node_ids"` EntryNodeIDs []string `json:"entry_node_ids"` ExitNodeID string `json:"exit_node_id,omitempty"` + ExitPoolID string `json:"exit_pool_id,omitempty"` + ExitPoolName string `json:"exit_pool_name,omitempty"` ActiveLease *NodeVPNAssignmentLease `json:"active_lease,omitempty"` RoutePolicies json.RawMessage `json:"route_policies"` ClientConfig json.RawMessage `json:"client_config"` diff --git a/backend/internal/modules/cluster/module.go b/backend/internal/modules/cluster/module.go index 50d5e13..69cf5c9 100644 --- a/backend/internal/modules/cluster/module.go +++ b/backend/internal/modules/cluster/module.go @@ -11,6 +11,7 @@ import ( "io" "net/http" "os" + "path/filepath" "reflect" "sort" "strconv" @@ -33,6 +34,13 @@ type Module struct { vpnClientDiagnosticHub *vpnClientDiagnosticHub } +const ( + adminRuntimeProjectionRequestSchema = "rap.web_ingress.control_api_projection_request.v1" + adminRuntimeProjectionResponseSchema = "rap.web_ingress.control_api_projection_response.v1" + adminRuntimeProjectionBodySchema = "rap.control_api.admin_runtime_projection.v1" + adminRuntimeManifestSchema = "rap.web_ingress.ui_manifest.v1" +) + func NewModule(deps module.Dependencies, verifiers ...*authority.Verifier) *Module { store := NewPostgresStore(deps.Infra.DB, verifiers...) if deps.Config.Secret.EncryptionKeyBase64 != "" { @@ -52,6 +60,7 @@ func (m *Module) Name() string { } func (m *Module) RegisterRoutes(router chi.Router) { + router.Get("/downloads/{fileName}", m.downloadReleaseFile) router.Route("/clusters", func(r chi.Router) { r.Get("/", m.listClusters) r.Post("/", m.createCluster) @@ -90,6 +99,7 @@ func (m *Module) RegisterRoutes(router chi.Router) { r.Put("/{clusterID}/nodes/{nodeID}/workloads/{serviceType}/desired", m.setDesiredWorkload) r.Post("/{clusterID}/nodes/{nodeID}/workloads/{serviceType}/status", m.reportWorkloadStatus) r.Get("/{clusterID}/nodes/{nodeID}/workloads/status", m.listWorkloadStatuses) + r.Post("/{clusterID}/nodes/{nodeID}/admin-runtime/projection", m.projectAdminRuntime) r.Get("/{clusterID}/mesh/links", m.listMeshLinks) r.Post("/{clusterID}/mesh/links", m.reportMeshLink) r.Get("/{clusterID}/mesh/route-intents", m.listRouteIntents) @@ -97,14 +107,6 @@ func (m *Module) RegisterRoutes(router chi.Router) { r.Post("/{clusterID}/mesh/route-intents/{routeIntentID}/expire", m.expireRouteIntent) r.Post("/{clusterID}/mesh/route-intents/{routeIntentID}/disable", m.disableRouteIntent) r.Get("/{clusterID}/mesh/qos-policies", m.listQoSPolicies) - r.Get("/{clusterID}/fabric/entry-points", m.listFabricEntryPoints) - r.Post("/{clusterID}/fabric/entry-points", m.createFabricEntryPoint) - r.Get("/{clusterID}/fabric/entry-points/{entryPointID}/nodes", m.listFabricEntryPointNodes) - r.Put("/{clusterID}/fabric/entry-points/{entryPointID}/nodes/{nodeID}", m.setFabricEntryPointNode) - r.Get("/{clusterID}/fabric/egress-pools", m.listFabricEgressPools) - r.Post("/{clusterID}/fabric/egress-pools", m.createFabricEgressPool) - r.Get("/{clusterID}/fabric/egress-pools/{egressPoolID}/nodes", m.listFabricEgressPoolNodes) - r.Put("/{clusterID}/fabric/egress-pools/{egressPoolID}/nodes/{nodeID}", m.setFabricEgressPoolNode) r.Get("/{clusterID}/fabric/service-channels/route-feedback", m.listFabricServiceChannelRouteFeedback) r.Post("/{clusterID}/fabric/service-channels/route-feedback/expire", m.expireFabricServiceChannelRouteFeedback) r.Get("/{clusterID}/fabric/service-channels/rebuild-attempts", m.listFabricServiceChannelRouteRebuildAttempts) @@ -172,6 +174,24 @@ func (m *Module) RegisterRoutes(router chi.Router) { router.Put("/fabric/testing-flags", m.upsertFabricTestingFlag) } +func (m *Module) downloadReleaseFile(w http.ResponseWriter, r *http.Request) { + fileName := filepath.Base(strings.TrimSpace(chi.URLParam(r, "fileName"))) + if fileName == "" || fileName == "." || fileName != strings.TrimSpace(chi.URLParam(r, "fileName")) { + http.NotFound(w, r) + return + } + releaseDir := strings.TrimSpace(os.Getenv("RAP_RELEASE_DIR")) + if releaseDir == "" { + releaseDir = "/tmp/rap-release" + } + path := filepath.Join(releaseDir, fileName) + if _, err := os.Stat(path); err != nil { + http.NotFound(w, r) + return + } + http.ServeFile(w, r, path) +} + func (m *Module) listClusters(w http.ResponseWriter, r *http.Request) { items, err := m.service.ListClusters(r.Context(), r.URL.Query().Get("actor_user_id")) if writeServiceError(w, err) { @@ -239,6 +259,126 @@ func (m *Module) updateCluster(w http.ResponseWriter, r *http.Request) { httpx.WriteJSON(w, http.StatusOK, map[string]any{"cluster": item}) } +func (m *Module) projectAdminRuntime(w http.ResponseWriter, r *http.Request) { + clusterID := chi.URLParam(r, "clusterID") + nodeID := chi.URLParam(r, "nodeID") + var payload struct { + SchemaVersion string `json:"schema_version"` + Method string `json:"method"` + Path string `json:"path"` + Query string `json:"query"` + Host string `json:"host"` + Scope string `json:"scope"` + ServiceClass string `json:"service_class"` + ObservedAt string `json:"observed_at"` + } + if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { + httpx.WriteError(w, http.StatusBadRequest, "invalid admin runtime projection payload") + return + } + if strings.TrimSpace(payload.SchemaVersion) != adminRuntimeProjectionRequestSchema { + httpx.WriteError(w, http.StatusBadRequest, "invalid admin runtime projection schema") + return + } + method := strings.ToUpper(strings.TrimSpace(payload.Method)) + path := strings.TrimSpace(payload.Path) + if method != http.MethodGet && method != http.MethodHead { + httpx.WriteJSON(w, http.StatusOK, adminRuntimeProjectionResponse(http.StatusForbidden, "blocked", "control_api_mutation_rejected", nil)) + return + } + if path == "" { + path = "/" + } + if !strings.HasPrefix(path, "/") { + path = "/" + path + } + scope := strings.TrimSpace(payload.Scope) + serviceClass := normalizeFabricServiceClass(payload.ServiceClass) + if !isAllowedAdminRuntimeProjectionScope(scope, serviceClass) { + httpx.WriteJSON(w, http.StatusOK, adminRuntimeProjectionResponse(http.StatusForbidden, "blocked", "control_api_projection_scope_rejected", nil)) + return + } + body := map[string]any{ + "schema_version": adminRuntimeProjectionBodySchema, + "cluster_id": clusterID, + "node_id": nodeID, + "scope": scope, + "service_class": serviceClass, + "path": path, + "query": payload.Query, + "host": payload.Host, + "projection": "read_only", + "audit_required": true, + } + if path == "/ui-manifest" || strings.HasSuffix(path, "/ui-manifest") { + body["manifest"] = adminRuntimeManifest(scope, serviceClass) + httpx.WriteJSON(w, http.StatusOK, adminRuntimeProjectionResponse(http.StatusOK, "ready", "ui_manifest_ready", body)) + return + } + if path == "/healthz" || path == "/readyz" { + httpx.WriteJSON(w, http.StatusOK, adminRuntimeProjectionResponse(http.StatusOK, "ready", "admin_runtime_projection_ready", body)) + return + } + httpx.WriteJSON(w, http.StatusOK, adminRuntimeProjectionResponse(http.StatusNotImplemented, "blocked", "control_api_projection_not_implemented", body)) +} + +func adminRuntimeProjectionResponse(statusCode int, status string, reason string, body map[string]any) map[string]any { + raw, _ := json.Marshal(body) + return map[string]any{ + "schema_version": adminRuntimeProjectionResponseSchema, + "status": status, + "reason": reason, + "status_code": statusCode, + "headers": map[string]string{ + "Content-Type": "application/json", + }, + "body": json.RawMessage(raw), + } +} + +func isAllowedAdminRuntimeProjectionScope(scope string, serviceClass string) bool { + switch serviceClass { + case FabricServiceClassPlatformAdmin: + return scope == "platform" + case FabricServiceClassClusterAdmin: + return scope == "cluster" + case FabricServiceClassOrganization: + return scope == "organization" + case FabricServiceClassUserPortal: + return scope == "user" || scope == "organization" + default: + return false + } +} + +func adminRuntimeManifest(scope string, serviceClass string) map[string]any { + sections := []string{"status"} + actions := []string{"read_status"} + switch strings.TrimSpace(serviceClass) { + case FabricServiceClassPlatformAdmin: + sections = []string{"clusters", "nodes", "roles", "fabric", "workloads", "audit"} + actions = []string{"read_platform_summary", "read_cluster_summaries", "read_node_status"} + case FabricServiceClassClusterAdmin: + sections = []string{"cluster", "nodes", "fabric", "workloads", "audit"} + actions = []string{"read_cluster_summary", "read_node_status"} + case FabricServiceClassOrganization: + sections = []string{"organization", "sessions", "resources", "audit"} + actions = []string{"read_organization_summary", "read_sessions"} + case FabricServiceClassUserPortal: + sections = []string{"profile", "sessions", "resources"} + actions = []string{"read_profile", "read_sessions"} + } + return map[string]any{ + "schema_version": adminRuntimeManifestSchema, + "scope": scope, + "service_class": serviceClass, + "sections": sections, + "allowed_actions": actions, + "mutation_enabled": false, + "projection_binding": "control_api_read_only", + } +} + func (m *Module) listClusterNodes(w http.ResponseWriter, r *http.Request) { items, err := m.service.ListClusterNodes(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID")) if writeServiceError(w, err) { @@ -1073,160 +1213,6 @@ func (m *Module) listQoSPolicies(w http.ResponseWriter, r *http.Request) { httpx.WriteJSON(w, http.StatusOK, map[string]any{"qos_policies": items}) } -func (m *Module) listFabricEntryPoints(w http.ResponseWriter, r *http.Request) { - items, err := m.service.ListFabricEntryPoints(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID")) - if writeServiceError(w, err) { - return - } - httpx.WriteJSON(w, http.StatusOK, map[string]any{"entry_points": items}) -} - -func (m *Module) createFabricEntryPoint(w http.ResponseWriter, r *http.Request) { - var payload struct { - ActorUserID string `json:"actor_user_id"` - Name string `json:"name"` - Status string `json:"status"` - EndpointType string `json:"endpoint_type"` - PublicEndpoint *string `json:"public_endpoint"` - Policy json.RawMessage `json:"policy"` - Metadata json.RawMessage `json:"metadata"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - httpx.WriteError(w, http.StatusBadRequest, "invalid fabric entry point payload") - return - } - item, err := m.service.CreateFabricEntryPoint(r.Context(), CreateFabricEntryPointInput{ - ActorUserID: payload.ActorUserID, - ClusterID: chi.URLParam(r, "clusterID"), - Name: payload.Name, - Status: payload.Status, - EndpointType: payload.EndpointType, - PublicEndpoint: payload.PublicEndpoint, - Policy: payload.Policy, - Metadata: payload.Metadata, - }) - if writeServiceError(w, err) { - return - } - httpx.WriteJSON(w, http.StatusCreated, map[string]any{"entry_point": item}) -} - -func (m *Module) setFabricEntryPointNode(w http.ResponseWriter, r *http.Request) { - var payload struct { - ActorUserID string `json:"actor_user_id"` - Status string `json:"status"` - Priority int `json:"priority"` - Metadata json.RawMessage `json:"metadata"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - httpx.WriteError(w, http.StatusBadRequest, "invalid fabric entry point node payload") - return - } - item, err := m.service.SetFabricEntryPointNode(r.Context(), SetFabricEntryPointNodeInput{ - ActorUserID: payload.ActorUserID, - ClusterID: chi.URLParam(r, "clusterID"), - EntryPointID: chi.URLParam(r, "entryPointID"), - NodeID: chi.URLParam(r, "nodeID"), - Status: payload.Status, - Priority: payload.Priority, - Metadata: payload.Metadata, - }) - if writeServiceError(w, err) { - return - } - httpx.WriteJSON(w, http.StatusOK, map[string]any{"entry_point_node": item}) -} - -func (m *Module) listFabricEntryPointNodes(w http.ResponseWriter, r *http.Request) { - items, err := m.service.ListFabricEntryPointNodes( - r.Context(), - r.URL.Query().Get("actor_user_id"), - chi.URLParam(r, "clusterID"), - chi.URLParam(r, "entryPointID"), - ) - if writeServiceError(w, err) { - return - } - httpx.WriteJSON(w, http.StatusOK, map[string]any{"entry_point_nodes": items}) -} - -func (m *Module) listFabricEgressPools(w http.ResponseWriter, r *http.Request) { - items, err := m.service.ListFabricEgressPools(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID")) - if writeServiceError(w, err) { - return - } - httpx.WriteJSON(w, http.StatusOK, map[string]any{"egress_pools": items}) -} - -func (m *Module) createFabricEgressPool(w http.ResponseWriter, r *http.Request) { - var payload struct { - ActorUserID string `json:"actor_user_id"` - Name string `json:"name"` - Status string `json:"status"` - Description *string `json:"description"` - RouteScope json.RawMessage `json:"route_scope"` - Policy json.RawMessage `json:"policy"` - Metadata json.RawMessage `json:"metadata"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - httpx.WriteError(w, http.StatusBadRequest, "invalid fabric egress pool payload") - return - } - item, err := m.service.CreateFabricEgressPool(r.Context(), CreateFabricEgressPoolInput{ - ActorUserID: payload.ActorUserID, - ClusterID: chi.URLParam(r, "clusterID"), - Name: payload.Name, - Status: payload.Status, - Description: payload.Description, - RouteScope: payload.RouteScope, - Policy: payload.Policy, - Metadata: payload.Metadata, - }) - if writeServiceError(w, err) { - return - } - httpx.WriteJSON(w, http.StatusCreated, map[string]any{"egress_pool": item}) -} - -func (m *Module) setFabricEgressPoolNode(w http.ResponseWriter, r *http.Request) { - var payload struct { - ActorUserID string `json:"actor_user_id"` - Status string `json:"status"` - Priority int `json:"priority"` - Metadata json.RawMessage `json:"metadata"` - } - if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { - httpx.WriteError(w, http.StatusBadRequest, "invalid fabric egress pool node payload") - return - } - item, err := m.service.SetFabricEgressPoolNode(r.Context(), SetFabricEgressPoolNodeInput{ - ActorUserID: payload.ActorUserID, - ClusterID: chi.URLParam(r, "clusterID"), - EgressPoolID: chi.URLParam(r, "egressPoolID"), - NodeID: chi.URLParam(r, "nodeID"), - Status: payload.Status, - Priority: payload.Priority, - Metadata: payload.Metadata, - }) - if writeServiceError(w, err) { - return - } - httpx.WriteJSON(w, http.StatusOK, map[string]any{"egress_pool_node": item}) -} - -func (m *Module) listFabricEgressPoolNodes(w http.ResponseWriter, r *http.Request) { - items, err := m.service.ListFabricEgressPoolNodes( - r.Context(), - r.URL.Query().Get("actor_user_id"), - chi.URLParam(r, "clusterID"), - chi.URLParam(r, "egressPoolID"), - ) - if writeServiceError(w, err) { - return - } - httpx.WriteJSON(w, http.StatusOK, map[string]any{"egress_pool_nodes": items}) -} - func (m *Module) issueFabricServiceChannelLease(w http.ResponseWriter, r *http.Request) { var payload struct { ActorUserID string `json:"actor_user_id"` diff --git a/backend/internal/modules/cluster/module_admin_runtime_test.go b/backend/internal/modules/cluster/module_admin_runtime_test.go new file mode 100644 index 0000000..a75b40b --- /dev/null +++ b/backend/internal/modules/cluster/module_admin_runtime_test.go @@ -0,0 +1,229 @@ +package cluster + +import ( + "bytes" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/go-chi/chi/v5" +) + +func TestProjectAdminRuntimeReturnsReadOnlyManifest(t *testing.T) { + router := chi.NewRouter() + module := &Module{} + router.Post("/clusters/{clusterID}/nodes/{nodeID}/admin-runtime/projection", module.projectAdminRuntime) + + req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{ + "schema_version":"rap.web_ingress.control_api_projection_request.v1", + "method":"GET", + "path":"/platform-admin/ui-manifest", + "scope":"platform", + "service_class":"platform_admin" + }`))) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + SchemaVersion string `json:"schema_version"` + Status string `json:"status"` + Reason string `json:"reason"` + StatusCode int `json:"status_code"` + Headers map[string]string `json:"headers"` + Body json.RawMessage `json:"body"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.SchemaVersion != "rap.web_ingress.control_api_projection_response.v1" || + response.Status != "ready" || + response.Reason != "ui_manifest_ready" || + response.StatusCode != http.StatusOK || + response.Headers["Content-Type"] != "application/json" { + t.Fatalf("response = %+v", response) + } + var body struct { + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + NodeID string `json:"node_id"` + Manifest map[string]any `json:"manifest"` + } + if err := json.Unmarshal(response.Body, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.ClusterID != "cluster-1" || + body.NodeID != "node-1" || + body.Manifest["projection_binding"] != "control_api_read_only" || + body.Manifest["mutation_enabled"] != false { + t.Fatalf("body = %+v", body) + } +} + +func TestProjectAdminRuntimeRejectsMutations(t *testing.T) { + router := chi.NewRouter() + module := &Module{} + router.Post("/clusters/{clusterID}/nodes/{nodeID}/admin-runtime/projection", module.projectAdminRuntime) + + req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{ + "schema_version":"rap.web_ingress.control_api_projection_request.v1", + "method":"POST", + "path":"/platform-admin/nodes", + "scope":"platform", + "service_class":"platform_admin" + }`))) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + Status string `json:"status"` + Reason string `json:"reason"` + StatusCode int `json:"status_code"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Status != "blocked" || response.Reason != "control_api_mutation_rejected" || response.StatusCode != http.StatusForbidden { + t.Fatalf("response = %+v", response) + } +} + +func TestProjectAdminRuntimeReturnsHealthProjection(t *testing.T) { + router := chi.NewRouter() + module := &Module{} + router.Post("/clusters/{clusterID}/nodes/{nodeID}/admin-runtime/projection", module.projectAdminRuntime) + + req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{ + "schema_version":"rap.web_ingress.control_api_projection_request.v1", + "method":"GET", + "path":"/readyz", + "scope":"platform", + "service_class":"platform_admin" + }`))) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + Status string `json:"status"` + Reason string `json:"reason"` + StatusCode int `json:"status_code"` + Body json.RawMessage `json:"body"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Status != "ready" || response.Reason != "admin_runtime_projection_ready" || response.StatusCode != http.StatusOK { + t.Fatalf("response = %+v", response) + } + var body struct { + Projection string `json:"projection"` + AuditRequired bool `json:"audit_required"` + } + if err := json.Unmarshal(response.Body, &body); err != nil { + t.Fatalf("decode body: %v", err) + } + if body.Projection != "read_only" || !body.AuditRequired { + t.Fatalf("body = %+v", body) + } +} + +func TestProjectAdminRuntimeBlocksUnknownReadProjection(t *testing.T) { + router := chi.NewRouter() + module := &Module{} + router.Post("/clusters/{clusterID}/nodes/{nodeID}/admin-runtime/projection", module.projectAdminRuntime) + + req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{ + "schema_version":"rap.web_ingress.control_api_projection_request.v1", + "method":"GET", + "path":"/platform-admin/nodes", + "scope":"platform", + "service_class":"platform_admin" + }`))) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + Status string `json:"status"` + Reason string `json:"reason"` + StatusCode int `json:"status_code"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Status != "blocked" || + response.Reason != "control_api_projection_not_implemented" || + response.StatusCode != http.StatusNotImplemented { + t.Fatalf("response = %+v", response) + } +} + +func TestProjectAdminRuntimeRejectsScopeClassMismatch(t *testing.T) { + router := chi.NewRouter() + module := &Module{} + router.Post("/clusters/{clusterID}/nodes/{nodeID}/admin-runtime/projection", module.projectAdminRuntime) + + req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{ + "schema_version":"rap.web_ingress.control_api_projection_request.v1", + "method":"GET", + "path":"/platform-admin/ui-manifest", + "scope":"organization", + "service_class":"platform_admin" + }`))) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusOK { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } + var response struct { + Status string `json:"status"` + Reason string `json:"reason"` + StatusCode int `json:"status_code"` + } + if err := json.Unmarshal(rec.Body.Bytes(), &response); err != nil { + t.Fatalf("decode response: %v", err) + } + if response.Status != "blocked" || + response.Reason != "control_api_projection_scope_rejected" || + response.StatusCode != http.StatusForbidden { + t.Fatalf("response = %+v", response) + } +} + +func TestProjectAdminRuntimeRejectsInvalidSchema(t *testing.T) { + router := chi.NewRouter() + module := &Module{} + router.Post("/clusters/{clusterID}/nodes/{nodeID}/admin-runtime/projection", module.projectAdminRuntime) + + req := httptest.NewRequest(http.MethodPost, "/clusters/cluster-1/nodes/node-1/admin-runtime/projection", bytes.NewReader([]byte(`{ + "schema_version":"wrong.schema", + "method":"GET", + "path":"/readyz", + "scope":"platform", + "service_class":"platform_admin" + }`))) + rec := httptest.NewRecorder() + + router.ServeHTTP(rec, req) + + if rec.Code != http.StatusBadRequest { + t.Fatalf("status = %d body=%s", rec.Code, rec.Body.String()) + } +} diff --git a/backend/internal/modules/cluster/postgres_store.go b/backend/internal/modules/cluster/postgres_store.go index 7b4fc50..6548c81 100644 --- a/backend/internal/modules/cluster/postgres_store.go +++ b/backend/internal/modules/cluster/postgres_store.go @@ -5,6 +5,8 @@ import ( "encoding/json" "errors" "fmt" + "net" + "net/url" "strings" "time" @@ -190,7 +192,7 @@ func (s *PostgresStore) UpdateCluster(ctx context.Context, input UpdateClusterIn func (s *PostgresStore) GetClusterAuthority(ctx context.Context, clusterID string) (ClusterAuthorityKey, error) { row := s.db.QueryRow(ctx, ` SELECT cluster_id::text, authority_state, key_algorithm, public_key, - public_key_fingerprint, private_key, created_at, updated_at + public_key_fingerprint, private_key, created_at, updated_at, metadata FROM cluster_authorities WHERE cluster_id = $1::uuid `, clusterID) @@ -3497,7 +3499,7 @@ func (s *PostgresStore) CheckVPNLeaseOwnerEligibility(ctx context.Context, clust WHERE nra.cluster_id = vc.cluster_id AND nra.node_id = $3::uuid AND nra.status = 'active' - AND nra.role IN ('vpn-exit', 'vpn-connector') + AND nra.role IN ('vpn-exit', 'vpn-connector', 'ipv4-egress') AND (nra.organization_id IS NULL OR nra.organization_id = vc.organization_id) ) AS has_authorized_role FROM vpn_connections vc @@ -3582,7 +3584,7 @@ func (s *PostgresStore) ListNodeVPNAssignments(ctx context.Context, clusterID, n WHERE nra.cluster_id = vc.cluster_id AND nra.node_id = $2::uuid AND nra.status = 'active' - AND nra.role IN ('vpn-exit', 'vpn-connector') + AND nra.role IN ('vpn-exit', 'vpn-connector', 'ipv4-egress') AND (nra.organization_id IS NULL OR nra.organization_id = vc.organization_id) ) AS has_authorized_role, EXISTS ( @@ -3769,13 +3771,33 @@ func scanClusterAuthority(row scanner) (ClusterAuthorityKey, error) { &item.PrivateKey, &item.CreatedAt, &item.UpdatedAt, + &item.Metadata, ); err != nil { return ClusterAuthorityKey{}, err } item.SchemaVersion = clusterauth.AuthoritySchemaVersion + ensureRaw(&item.Metadata, `{}`) + item.QuorumDescriptor = clusterAuthorityQuorumDescriptorFromMetadata(item.Metadata) return item, nil } +func clusterAuthorityQuorumDescriptorFromMetadata(metadata json.RawMessage) *QuorumDescriptor { + if len(metadata) == 0 || !json.Valid(metadata) { + return nil + } + var envelope struct { + QuorumDescriptor *QuorumDescriptor `json:"quorum_descriptor"` + Quorum *QuorumDescriptor `json:"quorum"` + } + if err := json.Unmarshal(metadata, &envelope); err != nil { + return nil + } + if envelope.QuorumDescriptor != nil { + return envelope.QuorumDescriptor + } + return envelope.Quorum +} + func scanNodeGroup(row scanner) (ClusterNodeGroup, error) { var item ClusterNodeGroup if err := row.Scan( @@ -4517,6 +4539,8 @@ func (s *PostgresStore) GetVPNClientProfile( ), '[]'::jsonb) AS allowed_node_ids, COALESCE(vc.placement_policy->'entry_node_ids', '[]'::jsonb) AS entry_node_ids, COALESCE(vc.placement_policy->>'exit_node_id', '') AS exit_node_id, + COALESCE(pool.id::text, '') AS exit_pool_id, + COALESCE(pool.name, vc.name) AS exit_pool_name, CASE WHEN l.id IS NULL THEN NULL ELSE jsonb_build_object( 'lease_id', l.id::text, 'owner_node_id', l.owner_node_id::text, @@ -4576,6 +4600,34 @@ func (s *PostgresStore) GetVPNClientProfile( 'runtime_observed_at', gateway_status.observed_at )) END AS client_config FROM vpn_connections vc + LEFT JOIN LATERAL ( + SELECT ep.id, ep.name + FROM fabric_egress_pools ep + WHERE ep.cluster_id = vc.cluster_id + AND ep.status = 'active' + AND ( + ep.id::text = COALESCE(vc.placement_policy->>'exit_pool_id', '') + OR ep.name = COALESCE(vc.placement_policy->>'exit_pool_name', '') + OR EXISTS ( + SELECT 1 + FROM fabric_egress_pool_nodes epn + WHERE epn.egress_pool_id = ep.id + AND epn.cluster_id = vc.cluster_id + AND epn.status = 'active' + AND epn.node_id::text = ANY ( + SELECT jsonb_array_elements_text(COALESCE(vc.placement_policy->'exit_node_ids', '[]'::jsonb)) + ) + ) + ) + ORDER BY + CASE + WHEN ep.id::text = COALESCE(vc.placement_policy->>'exit_pool_id', '') THEN 0 + WHEN ep.name = COALESCE(vc.placement_policy->>'exit_pool_name', '') THEN 1 + ELSE 2 + END, + ep.name + LIMIT 1 + ) pool ON TRUE LEFT JOIN vpn_connection_leases l ON l.cluster_id = vc.cluster_id AND l.vpn_connection_id = vc.id @@ -4620,6 +4672,8 @@ func (s *PostgresStore) GetVPNClientProfile( &allowedRaw, &entryRaw, &item.ExitNodeID, + &item.ExitPoolID, + &item.ExitPoolName, &activeLeaseRaw, &item.RoutePolicies, &item.ClientConfig, @@ -4641,6 +4695,15 @@ func (s *PostgresStore) GetVPNClientProfile( ensureRaw(&item.PlacementPolicy, `{}`) ensureRaw(&item.RoutePolicies, `[]`) ensureRaw(&item.ClientConfig, `{}`) + if item.ExitPoolName != "" || item.ExitPoolID != "" { + item.ClientConfig = mergeJSONObjects(item.ClientConfig, map[string]any{ + "exit_pool": map[string]any{ + "id": item.ExitPoolID, + "name": firstNonEmptyMetadataString(item.ExitPoolName, item.Name), + "kind": "virtual_pool", + }, + }) + } item.ClientConfig = enrichVPNClientFabricRoute(item, preferredEntryNodeID, preferredExitNodeID) profile.Connections = append(profile.Connections, item) } @@ -4651,8 +4714,13 @@ func (s *PostgresStore) GetVPNClientProfile( if err != nil { return VPNClientProfile{}, err } + exitEndpoints, err := s.vpnEntryEndpointCandidates(ctx, clusterID, vpnProfileExitNodeIDs(profile)) + if err != nil { + return VPNClientProfile{}, err + } for i := range profile.Connections { profile.Connections[i].ClientConfig = enrichVPNClientEntryEndpointCandidates(profile.Connections[i], entryEndpoints) + profile.Connections[i].ClientConfig = enrichVPNClientExitEndpointCandidates(profile.Connections[i], exitEndpoints) } return profile, nil } @@ -4733,6 +4801,18 @@ func vpnProfileEntryNodeIDs(profile VPNClientProfile) []string { return dedupeStrings(out) } +func vpnProfileExitNodeIDs(profile VPNClientProfile) []string { + var out []string + for _, connection := range profile.Connections { + route := vpnFabricRouteFromClientConfig(connection.ClientConfig) + out = append(out, route.SelectedExitNodeID) + out = append(out, route.ExitPoolNodeIDs...) + out = append(out, connection.ExitNodeID) + out = append(out, connection.AllowedNodeIDs...) + } + return dedupeStrings(out) +} + func (s *PostgresStore) vpnEntryEndpointCandidates(ctx context.Context, clusterID string, entryNodeIDs []string) (map[string][]map[string]any, error) { entryNodeIDs = dedupeStrings(entryNodeIDs) out := make(map[string][]map[string]any, len(entryNodeIDs)) @@ -4778,13 +4858,12 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra if len(metadata) == 0 || json.Unmarshal(metadata, &payload) != nil { return nil } + certByCandidate := endpointCandidateCertsFromHeartbeatMetadata(metadata) report := payload.MeshEndpointReport var out []map[string]any + seen := map[string]struct{}{} for _, candidate := range report.EndpointCandidates { address := strings.TrimSpace(candidate.Address) - if address == "" { - continue - } candidateNodeID := strings.TrimSpace(candidate.NodeID) if candidateNodeID == "" { candidateNodeID = nodeID @@ -4793,6 +4872,9 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra if transport == "" { transport = strings.TrimSpace(report.Transport) } + if !usableVPNFabricPeerEndpoint(address, transport) { + continue + } connectivityMode := strings.TrimSpace(candidate.ConnectivityMode) if connectivityMode == "" { connectivityMode = strings.TrimSpace(report.ConnectivityMode) @@ -4813,6 +4895,11 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra if endpointID == "" { endpointID = "mesh-" + candidateNodeID } + key := candidateNodeID + "\x00" + strings.ToLower(transport) + "\x00" + strings.ToLower(address) + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} item := map[string]any{ "node_id": candidateNodeID, "endpoint_id": endpointID, @@ -4826,6 +4913,15 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra "status": "reported", "source": "node_latest_heartbeat.mesh_endpoint_report.endpoint_candidates", } + if certSHA256 := firstNonEmptyMetadataString( + endpointCandidateMetadataString(candidate.Metadata, "tls_cert_sha256", "peer_cert_sha256"), + certByCandidate[endpointID], + certByCandidate[address], + certByCandidate[candidateNodeID+"\x00"+address], + ); certSHA256 != "" { + item["tls_cert_sha256"] = certSHA256 + item["peer_cert_sha256"] = certSHA256 + } if apiBaseURL := vpnEntryAPIBaseURL(address); apiBaseURL != "" { item["api_base_url"] = apiBaseURL } @@ -4833,7 +4929,7 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra } if len(out) == 0 { address := strings.TrimSpace(report.PeerEndpoint) - if address != "" { + if usableVPNFabricPeerEndpoint(address, strings.TrimSpace(report.Transport)) { item := map[string]any{ "node_id": nodeID, "endpoint_id": "mesh-peer-endpoint-" + nodeID, @@ -4856,6 +4952,107 @@ func vpnEntryEndpointCandidatesFromHeartbeat(nodeID string, capabilities json.Ra return out } +func endpointCandidateCertsFromHeartbeatMetadata(metadata json.RawMessage) map[string]string { + out := map[string]string{} + var payload map[string]any + if len(metadata) == 0 || json.Unmarshal(metadata, &payload) != nil { + return out + } + report, _ := payload["mesh_endpoint_report"].(map[string]any) + candidates, _ := report["endpoint_candidates"].([]any) + for _, raw := range candidates { + candidate, _ := raw.(map[string]any) + if candidate == nil { + continue + } + meta, _ := candidate["metadata"].(map[string]any) + cert := strings.TrimSpace(metadataAnyString(meta["tls_cert_sha256"])) + if cert == "" { + cert = strings.TrimSpace(metadataAnyString(meta["peer_cert_sha256"])) + } + if cert == "" { + continue + } + endpointID := strings.TrimSpace(metadataAnyString(candidate["endpoint_id"])) + address := strings.TrimSpace(metadataAnyString(candidate["address"])) + nodeID := strings.TrimSpace(metadataAnyString(candidate["node_id"])) + if endpointID != "" { + out[endpointID] = cert + } + if address != "" { + out[address] = cert + } + if nodeID != "" && address != "" { + out[nodeID+"\x00"+address] = cert + } + } + return out +} + +func metadataAnyString(value any) string { + switch typed := value.(type) { + case string: + return typed + default: + return "" + } +} + +func firstNonEmptyMetadataString(values ...string) string { + for _, value := range values { + if strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + +func usableVPNFabricPeerEndpoint(address string, transport string) bool { + address = strings.TrimSpace(address) + if address == "" { + return false + } + transport = strings.ToLower(strings.TrimSpace(transport)) + if !strings.Contains(transport, "quic") { + return false + } + parsed, err := url.Parse(address) + if err != nil { + return false + } + if strings.ToLower(parsed.Scheme) != "quic" { + return false + } + host := parsed.Hostname() + if host == "" { + return false + } + ip := net.ParseIP(host) + if ip == nil { + return true + } + if ip.IsUnspecified() || ip.IsLoopback() { + return false + } + return true +} + +func endpointCandidateMetadataString(metadata json.RawMessage, keys ...string) string { + if len(metadata) == 0 { + return "" + } + var values map[string]any + if json.Unmarshal(metadata, &values) != nil { + return "" + } + for _, key := range keys { + if value, ok := values[key].(string); ok && strings.TrimSpace(value) != "" { + return strings.TrimSpace(value) + } + } + return "" +} + func heartbeatCapabilityEnabled(capabilities json.RawMessage, name string) bool { var cfg map[string]any if len(capabilities) == 0 || json.Unmarshal(capabilities, &cfg) != nil { @@ -4921,6 +5118,44 @@ func enrichVPNClientEntryEndpointCandidates(connection VPNClientConnection, endp return out } +func enrichVPNClientExitEndpointCandidates(connection VPNClientConnection, endpoints map[string][]map[string]any) json.RawMessage { + var cfg map[string]any + if err := json.Unmarshal(connection.ClientConfig, &cfg); err != nil || cfg == nil { + cfg = map[string]any{} + } + route := vpnFabricRouteFromClientConfig(connection.ClientConfig) + exitIDs := dedupeStrings(append([]string{route.SelectedExitNodeID}, route.ExitPoolNodeIDs...)) + exitIDs = dedupeStrings(append(exitIDs, connection.ExitNodeID)) + exitIDs = dedupeStrings(append(exitIDs, connection.AllowedNodeIDs...)) + var candidates []map[string]any + seen := map[string]struct{}{} + for _, nodeID := range exitIDs { + for _, candidate := range endpoints[nodeID] { + address, _ := candidate["address"].(string) + endpointID, _ := candidate["endpoint_id"].(string) + key := nodeID + "\x00" + endpointID + "\x00" + address + if _, ok := seen[key]; ok { + continue + } + seen[key] = struct{}{} + enriched := make(map[string]any, len(candidate)+2) + for k, v := range candidate { + enriched[k] = v + } + enriched["selected_exit"] = nodeID != "" && nodeID == route.SelectedExitNodeID + enriched["exit_pool_member"] = true + candidates = append(candidates, enriched) + } + } + cfg["vpn_exit_endpoint_candidates"] = candidates + cfg["vpn_exit_endpoint_candidate_count"] = len(candidates) + out, err := json.Marshal(cfg) + if err != nil { + return connection.ClientConfig + } + return out +} + func listVPNConnectionAllowedNodes(ctx context.Context, q rowQuerier, clusterID, vpnConnectionID string) ([]VPNConnectionAllowedNode, error) { rows, err := q.Query(ctx, ` SELECT vpn_connection_id::text, cluster_id::text, node_id::text, role_preference, @@ -5087,13 +5322,32 @@ func ensureRaw(raw *json.RawMessage, fallback string) { } } +func mergeJSONObjects(raw json.RawMessage, values map[string]any) json.RawMessage { + out := map[string]any{} + _ = json.Unmarshal(raw, &out) + if out == nil { + out = map[string]any{} + } + for key, value := range values { + out[key] = value + } + payload, err := json.Marshal(out) + if err != nil { + return raw + } + return payload +} + func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID, preferredExitNodeID string) json.RawMessage { var cfg map[string]any if err := json.Unmarshal(item.ClientConfig, &cfg); err != nil || cfg == nil { cfg = map[string]any{} } entryPool := dedupeStrings(append([]string{}, item.EntryNodeIDs...)) - if len(entryPool) == 0 { + placementPolicy := jsonObjectFromRaw(item.PlacementPolicy) + entrySelector, _ := placementPolicy["entry_selector"].(string) + clientNodeEntry := strings.EqualFold(strings.TrimSpace(entrySelector), "client_node") || placementPolicy["android_node_agent_target"] == true + if len(entryPool) == 0 && !clientNodeEntry { entryPool = dedupeStrings(append([]string{}, item.AllowedNodeIDs...)) } exitPool := []string{} @@ -5107,7 +5361,10 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID, exitPool = dedupeStrings(exitPool) preferredEntryNodeID = strings.TrimSpace(preferredEntryNodeID) - selectedEntry := selectPreferredNode(entryPool, preferredEntryNodeID) + selectedEntry := "" + if !clientNodeEntry { + selectedEntry = selectPreferredNode(entryPool, preferredEntryNodeID) + } selectedExit := selectPreferredNode(exitPool, preferredExitNodeID) if selectedExit == "" && item.ActiveLease != nil && item.ActiveLease.OwnerNodeID != "" { selectedExit = item.ActiveLease.OwnerNodeID @@ -5116,6 +5373,8 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID, switch { case selectedEntry != "" && selectedExit != "": status = "planned" + case clientNodeEntry && selectedExit != "": + status = "planned" case selectedEntry == "": status = "waiting_for_entry" case selectedExit == "": @@ -5129,8 +5388,10 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID, "preferred_data_plane": "fabric_service_channel", "fallback_data_plane": "none", "backend_relay_fallback": false, - "selection_mode": "farm_authoritative_entry_to_exit", + "selection_mode": "farm_authoritative_client_node_to_exit_pool", "route_authority": "fabric_farm", + "entry_selector": firstNonEmptyString(entrySelector, "entry-node"), + "client_node_entry": clientNodeEntry, "vpn_builds_routes": false, "vpn_builds_tunnels": false, "farm_builds_routes": true, @@ -5163,7 +5424,9 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID, "diagnostics_only_protocol_summaries": true, }, "route_selection": map[string]any{ - "mode": "farm_authoritative_lowest_latency_healthy_route", + "mode": "farm_authoritative_lowest_latency_healthy_route_to_exit_pool", + "entry_selector": firstNonEmptyString(entrySelector, "entry-node"), + "client_node_entry": clientNodeEntry, "selected_entry_node_id": selectedEntry, "selected_exit_node_id": selectedExit, "route_candidates": routeCandidates, @@ -5175,7 +5438,7 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID, "preserve_vpn_connection_id": true, "alternate_route_count": alternateVPNRouteCount(routeCandidates, selectedEntry, selectedExit), "reroute_triggers": []string{ - "entry_unhealthy", + "client_node_mesh_path_unhealthy", "exit_unhealthy", "mesh_route_latency_regression", "mesh_route_loss_regression", @@ -5199,12 +5462,30 @@ func enrichVPNClientFabricRoute(item VPNClientConnection, preferredEntryNodeID, return out } +func jsonObjectFromRaw(raw json.RawMessage) map[string]any { + var out map[string]any + if len(raw) == 0 || json.Unmarshal(raw, &out) != nil || out == nil { + return map[string]any{} + } + return out +} + func vpnFabricRouteCandidates(entryPool, exitPool []string, selectedEntry, selectedExit string) []map[string]any { type pair struct { entry string exit string } pairs := make([]pair, 0, len(entryPool)*len(exitPool)+1) + if len(entryPool) == 0 && selectedExit != "" { + pairs = append(pairs, pair{exit: selectedExit}) + } + if len(entryPool) == 0 { + for _, exit := range exitPool { + if exit != "" { + pairs = append(pairs, pair{exit: exit}) + } + } + } if selectedEntry != "" && selectedExit != "" { pairs = append(pairs, pair{entry: selectedEntry, exit: selectedExit}) } @@ -5219,6 +5500,9 @@ func vpnFabricRouteCandidates(entryPool, exitPool []string, selectedEntry, selec seen := map[string]struct{}{} out := make([]map[string]any, 0, len(pairs)) for _, pair := range pairs { + if pair.exit == "" { + continue + } key := pair.entry + "\x00" + pair.exit if _, ok := seen[key]; ok { continue @@ -5226,17 +5510,22 @@ func vpnFabricRouteCandidates(entryPool, exitPool []string, selectedEntry, selec seen[key] = struct{}{} priority := len(out) + 1 role := "alternate" - if pair.entry == selectedEntry && pair.exit == selectedExit { + if pair.exit == selectedExit && (pair.entry == selectedEntry || selectedEntry == "") { role = "preferred" priority = 0 } - out = append(out, map[string]any{ - "entry_node_id": pair.entry, - "exit_node_id": pair.exit, - "role": role, - "priority": priority, - "status": "candidate", - }) + candidate := map[string]any{ + "exit_node_id": pair.exit, + "role": role, + "priority": priority, + "status": "candidate", + "source_role": "vpn-client", + "route_scope": "client_node_to_exit_pool", + } + if pair.entry != "" { + candidate["entry_node_id"] = pair.entry + } + out = append(out, candidate) } return out } diff --git a/backend/internal/modules/cluster/postgres_store_test.go b/backend/internal/modules/cluster/postgres_store_test.go index da871bc..bfda0cb 100644 --- a/backend/internal/modules/cluster/postgres_store_test.go +++ b/backend/internal/modules/cluster/postgres_store_test.go @@ -137,7 +137,7 @@ func TestEnrichVPNClientFabricRouteUsesActiveLeaseWhenNoPolicyExit(t *testing.T) } } -func TestEnrichVPNClientEntryEndpointCandidatesAddsReportedEntryAPI(t *testing.T) { +func TestEnrichVPNClientEntryEndpointCandidatesAddsReportedQUICEndpoint(t *testing.T) { item := VPNClientConnection{ EntryNodeIDs: []string{"entry-1"}, ClientConfig: json.RawMessage(`{ @@ -150,16 +150,16 @@ func TestEnrichVPNClientEntryEndpointCandidatesAddsReportedEntryAPI(t *testing.T } heartbeatMetadata := json.RawMessage(`{ "mesh_endpoint_report": { - "transport": "direct_http", + "transport": "direct_quic", "connectivity_mode": "direct", "nat_type": "none", "region": "test", - "peer_endpoint": "http://entry.example.test:19131", + "peer_endpoint": "quic://entry.example.test:19131", "endpoint_candidates": [{ - "endpoint_id": "public-http", + "endpoint_id": "public-quic", "node_id": "entry-1", - "transport": "direct_http", - "address": "http://entry.example.test:19131", + "transport": "direct_quic", + "address": "quic://entry.example.test:19131", "reachability": "public", "priority": 0 }] @@ -178,9 +178,12 @@ func TestEnrichVPNClientEntryEndpointCandidatesAddsReportedEntryAPI(t *testing.T } candidates := cfg["vpn_entry_endpoint_candidates"].([]any) candidate := candidates[0].(map[string]any) - if candidate["node_id"] != "entry-1" || candidate["api_base_url"] != "http://entry.example.test:19131/api/v1" { + if candidate["node_id"] != "entry-1" || candidate["address"] != "quic://entry.example.test:19131" { t.Fatalf("unexpected endpoint candidate: %#v", candidate) } + if _, ok := candidate["api_base_url"]; ok { + t.Fatalf("QUIC dataplane candidate must not expose an API base URL: %#v", candidate) + } if _, ok := candidate["local_gateway_shortcut"]; ok { t.Fatalf("local gateway shortcut must not be advertised in farm-owned VPN mode: %#v", candidate) } @@ -188,3 +191,29 @@ func TestEnrichVPNClientEntryEndpointCandidatesAddsReportedEntryAPI(t *testing.T t.Fatalf("unexpected endpoint metadata: %#v", candidate) } } + +func TestVPNEntryEndpointCandidatesKeepsQUICEndpointsAndRejectsLegacyHTTP(t *testing.T) { + heartbeatMetadata := json.RawMessage(`{ + "mesh_endpoint_report": { + "transport": "direct_quic", + "connectivity_mode": "direct", + "peer_endpoint": "quic://192.168.200.85:18080", + "endpoint_candidates": [ + {"endpoint_id":"admin-web","node_id":"entry-1","transport":"direct_quic","address":"quic://192.168.200.85:18080","reachability":"private","priority":0}, + {"endpoint_id":"http-old","node_id":"entry-1","transport":"direct_http","address":"http://192.168.200.85:19131","reachability":"private","priority":1}, + {"endpoint_id":"mesh-quic","node_id":"entry-1","transport":"direct_quic","address":"quic://192.168.200.85:19131","reachability":"private","priority":2} + ] + } + }`) + candidates := vpnEntryEndpointCandidatesFromHeartbeat("entry-1", nil, heartbeatMetadata) + if len(candidates) != 2 { + t.Fatalf("candidate count = %d, want two QUIC dataplane endpoints: %#v", len(candidates), candidates) + } + got := map[string]string{} + for _, candidate := range candidates { + got[candidate["endpoint_id"].(string)] = candidate["address"].(string) + } + if got["admin-web"] != "quic://192.168.200.85:18080" || got["mesh-quic"] != "quic://192.168.200.85:19131" { + t.Fatalf("unexpected candidates: %#v", candidates) + } +} diff --git a/backend/internal/modules/cluster/service.go b/backend/internal/modules/cluster/service.go index e07ee88..7d5c878 100644 --- a/backend/internal/modules/cluster/service.go +++ b/backend/internal/modules/cluster/service.go @@ -69,17 +69,18 @@ type clusterJoinTokenAuthorityPayload struct { } type clusterNodeApprovalAuthorityPayload struct { - SchemaVersion string `json:"schema_version"` - ClusterID string `json:"cluster_id"` - JoinRequestID string `json:"join_request_id"` - NodeID string `json:"node_id"` - NodeFingerprint string `json:"node_fingerprint"` - IdentityStatus string `json:"identity_status"` - HeartbeatEndpoint string `json:"heartbeat_endpoint"` - ApprovedByUserID string `json:"approved_by_user_id"` - IssuedAt time.Time `json:"issued_at"` - ControlPlaneOnly bool `json:"control_plane_only"` - ProductionForwarding bool `json:"production_forwarding"` + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + JoinRequestID string `json:"join_request_id"` + NodeID string `json:"node_id"` + NodeFingerprint string `json:"node_fingerprint"` + IdentityStatus string `json:"identity_status"` + HeartbeatEndpoint string `json:"heartbeat_endpoint"` + ApprovedByUserID string `json:"approved_by_user_id"` + ClusterAuthorityQuorumSHA256 string `json:"cluster_authority_quorum_sha256,omitempty"` + IssuedAt time.Time `json:"issued_at"` + ControlPlaneOnly bool `json:"control_plane_only"` + ProductionForwarding bool `json:"production_forwarding"` } type clusterMeshConfigAuthorityPayload struct { @@ -1419,6 +1420,10 @@ func (s *Service) signApprovedJoinRequest(ctx context.Context, input ApproveJoin ControlPlaneOnly: true, ProductionForwarding: false, } + payload.ClusterAuthorityQuorumSHA256, err = clusterAuthorityQuorumDescriptorHash(authorityKey) + if err != nil { + return ApprovedJoinRequest{}, err + } rawPayload, signature, err := clusterauth.SignPayload(authorityKey.PrivateKey, payload, s.now()) if err != nil { return ApprovedJoinRequest{}, err @@ -1429,6 +1434,7 @@ func (s *Service) signApprovedJoinRequest(ctx context.Context, input ApproveJoin } item.JoinRequest = updated item.Bootstrap.ClusterAuthority = authorityDescriptor(authorityKey) + item.Bootstrap.ClusterAuthorityQuorum = authorityKey.QuorumDescriptor item.Bootstrap.AuthorityPayload = rawPayload item.Bootstrap.AuthoritySignature = &signature return item, nil @@ -1462,6 +1468,10 @@ func (s *Service) bootstrapForApprovedJoinRequest(ctx context.Context, item Node ControlPlaneOnly: true, ProductionForwarding: false, } + payload.ClusterAuthorityQuorumSHA256, err = clusterAuthorityQuorumDescriptorHash(authorityKey) + if err != nil { + return NodeBootstrap{}, NodeJoinRequest{}, err + } rawPayload, signature, err := clusterauth.SignPayload(authorityKey.PrivateKey, payload, s.now()) if err != nil { return NodeBootstrap{}, NodeJoinRequest{}, err @@ -1490,14 +1500,29 @@ func (s *Service) bootstrapForApprovedJoinRequest(ctx context.Context, item Node Certificate: map[string]any{ "status": "pending_issuer_integration", }, - HeartbeatEndpoint: heartbeatEndpoint, - ClusterAuthority: authorityDescriptor(authorityKey), - AuthorityPayload: item.ApprovalPayload, - AuthoritySignature: &signature, + HeartbeatEndpoint: heartbeatEndpoint, + ClusterAuthority: authorityDescriptor(authorityKey), + ClusterAuthorityQuorum: authorityKey.QuorumDescriptor, + AuthorityPayload: item.ApprovalPayload, + AuthoritySignature: &signature, } return bootstrap, item, nil } +func clusterAuthorityQuorumDescriptorHash(authorityKey ClusterAuthorityKey) (string, error) { + if authorityKey.QuorumDescriptor == nil { + return "", nil + } + descriptor := *authorityKey.QuorumDescriptor + if descriptor.SchemaVersion == "" { + descriptor.SchemaVersion = clusterauth.QuorumSchemaVersion + } + if strings.TrimSpace(descriptor.ClusterID) == "" { + descriptor.ClusterID = authorityKey.ClusterID + } + return clusterauth.QuorumDescriptorHash(descriptor) +} + func nodeHeartbeatEndpoint(clusterID, nodeID string) string { return "/api/v1/clusters/" + clusterID + "/nodes/" + nodeID + "/heartbeats" } @@ -3937,9 +3962,54 @@ func (s *Service) signNodeUpdatePlan(ctx context.Context, plan NodeUpdatePlan) ( } plan.AuthorityPayload = rawPayload plan.AuthoritySignature = &signature + quorumEnvelope, err := nodeUpdatePlanQuorumEnvelope(authorityKey, rawPayload, s.now()) + if err != nil { + return NodeUpdatePlan{}, err + } + plan.AuthorityQuorum = quorumEnvelope return plan, nil } +func nodeUpdatePlanQuorumEnvelope(authorityKey ClusterAuthorityKey, payload json.RawMessage, signedAt time.Time) (*QuorumEnvelope, error) { + if authorityKey.QuorumDescriptor == nil { + return nil, nil + } + descriptor := *authorityKey.QuorumDescriptor + if descriptor.SchemaVersion == "" { + descriptor.SchemaVersion = clusterauth.QuorumSchemaVersion + } + if strings.TrimSpace(descriptor.ClusterID) == "" { + descriptor.ClusterID = authorityKey.ClusterID + } + signature, err := clusterauth.SignRaw(authorityKey.PrivateKey, payload, signedAt) + if err != nil { + return nil, err + } + payloadHash, err := clusterauth.HashRaw(payload) + if err != nil { + return nil, err + } + descriptorHash, err := clusterauth.QuorumDescriptorHash(descriptor) + if err != nil { + return nil, err + } + envelope := QuorumEnvelope{ + SchemaVersion: clusterauth.QuorumEnvelopeVersion, + ClusterID: descriptor.ClusterID, + Epoch: descriptor.Epoch, + Threshold: descriptor.Threshold, + PayloadSHA256: payloadHash, + QuorumSHA256: descriptorHash, + Signatures: []ClusterSignature{signature}, + AllowedScopes: []string{"update-authority"}, + DecisionReason: "node_update_plan", + } + if err := clusterauth.VerifyQuorumRaw(descriptor, payload, envelope, "update-authority"); err != nil { + return nil, err + } + return &envelope, nil +} + func (s *Service) UpsertFabricTestingFlag(ctx context.Context, input UpsertFabricTestingFlagInput) (FabricTestingFlag, error) { if err := s.ensurePlatformAdmin(ctx, input.ActorUserID); err != nil { return FabricTestingFlag{}, err @@ -5481,7 +5551,7 @@ func (s *Service) syntheticRouteByID(input GetNodeSyntheticMeshConfigInput, inte return SyntheticMeshRouteConfig{}, false } for _, intent := range intents { - route, _, _, _, _, ok := s.syntheticRouteFromIntent(input, intent) + route, _, _, _, _, ok := s.syntheticRouteFromIntent(input, intent, endpointPerspective{}) if ok && route.RouteID == routeID { return route, true } @@ -5627,7 +5697,11 @@ func isAllowedFabricServiceClass(value string) bool { case FabricServiceClassVPNPackets, FabricServiceClassRemoteWorkspace, FabricServiceClassFileTransfer, - FabricServiceClassVideo: + FabricServiceClassVideo, + FabricServiceClassPlatformAdmin, + FabricServiceClassClusterAdmin, + FabricServiceClassOrganization, + FabricServiceClassUserPortal: return true default: return false @@ -5648,6 +5722,11 @@ func normalizeFabricServiceChannels(channels []string, serviceClass string) []st return []string{FabricChannelControl, FabricChannelInteractive, FabricChannelDroppable} case FabricServiceClassFileTransfer: return []string{FabricChannelControl, FabricChannelReliable, FabricChannelBulk} + case FabricServiceClassPlatformAdmin, + FabricServiceClassClusterAdmin, + FabricServiceClassOrganization, + FabricServiceClassUserPortal: + return []string{FabricChannelControl, FabricChannelInteractive, FabricChannelReliable} default: return []string{FabricChannelControl, FabricChannelReliable} } @@ -5660,13 +5739,21 @@ func normalizeFabricRequiredRoles(roles []string, serviceClass string) []string } switch serviceClass { case FabricServiceClassVPNPackets: - return []string{"entry-node", "vpn-exit"} + return []string{"entry-node", "vpn-exit", "ipv4-egress"} case FabricServiceClassRemoteWorkspace: return []string{"entry-node", "rdp-worker"} case FabricServiceClassVideo: return []string{"entry-node", "video-relay"} case FabricServiceClassFileTransfer: return []string{"entry-node", "file-storage-cache"} + case FabricServiceClassPlatformAdmin: + return []string{"admin-ingress", "global-admin-runtime", "identity-runtime", "policy-authority", "audit-sink"} + case FabricServiceClassClusterAdmin: + return []string{"admin-ingress", "cluster-admin-runtime", "identity-runtime", "policy-authority", "audit-sink"} + case FabricServiceClassOrganization: + return []string{"public-ingress", "organization-portal-runtime", "identity-runtime", "policy-authority", "audit-sink"} + case FabricServiceClassUserPortal: + return []string{"public-ingress", "user-portal-runtime", "identity-runtime", "policy-authority", "audit-sink"} default: return []string{"entry-node"} } @@ -7110,6 +7197,11 @@ func defaultFabricServiceQoS(serviceClass string) string { return `{"priority":"interactive","interactive":true,"bulk_limit_mbps":0}` case FabricServiceClassVideo: return `{"priority":"interactive","interactive":true,"adaptive":true}` + case FabricServiceClassPlatformAdmin, + FabricServiceClassClusterAdmin, + FabricServiceClassOrganization, + FabricServiceClassUserPortal: + return `{"priority":"control","interactive":true,"bulk_limit_mbps":0,"requires_step_up_for_high_risk":true}` default: return `{"priority":"normal","interactive":false,"bulk_limit_mbps":0}` } @@ -7136,6 +7228,22 @@ func fabricServiceChannelHTTPIngress(serviceClass string) FabricServiceChannelHT 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" @@ -7222,7 +7330,7 @@ func (s *Service) GetNodeSyntheticMeshConfig(ctx context.Context, input GetNodeS } listenerConfig, err := s.nodeMeshListenerConfig(ctx, input) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh listener config: %w", err) } cfg.MeshListener = listenerConfig if listenerConfig != nil && listenerConfig.ProductionForwarding { @@ -7230,14 +7338,18 @@ func (s *Service) GetNodeSyntheticMeshConfig(ctx context.Context, input GetNodeS } flags, err := s.store.GetEffectiveNodeTestingFlags(ctx, input.ClusterID, input.NodeID) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh testing flags: %w", err) } if !flags.Enabled || !flags.SyntheticLinksEnabled { - return s.signSyntheticMeshConfig(ctx, cfg) + signed, err := s.signSyntheticMeshConfig(ctx, cfg) + if err != nil { + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh sign disabled config: %w", err) + } + return signed, nil } intents, err := s.store.ListRouteIntents(ctx, input.ClusterID) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh route intents: %w", err) } cfg.Enabled = true cfg.ConfigVersion = "c17z18-" + s.now().UTC().Format("20060102T150405Z") @@ -7248,13 +7360,13 @@ func (s *Service) GetNodeSyntheticMeshConfig(ctx context.Context, input GetNodeS } meshLinks, err := s.store.ListMeshLinks(ctx, input.ClusterID) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh links: %w", err) } relayPolicy := newRendezvousRelayPolicy(input.NodeID, meshLinks, s.now()) recoveryPolicy := s.fabricServiceChannelRecoveryPolicy(ctx, input.ClusterID) cluster, err := s.store.GetCluster(ctx, input.ClusterID) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh cluster: %w", err) } adaptivePolicy := fabricServiceChannelAdaptivePolicyFromCluster(cluster) cfg.ServiceChannelAdaptivePolicy = &adaptivePolicy @@ -7265,20 +7377,20 @@ func (s *Service) GetNodeSyntheticMeshConfig(ctx context.Context, input GetNodeS Now: s.now(), }) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh service channel feedback: %w", err) } cfg.ServiceChannelFeedback = serviceChannelRouteFeedbackReportWithPolicyAndProvenance(serviceChannelFeedbackItems, s.now(), recoveryPolicy, routeProvenance) serviceChannelFeedback := fabricServiceChannelRouteFeedbackFromObservationsWithProvenance(serviceChannelFeedbackItems, s.now(), recoveryPolicy, routeProvenance) cfg.ServiceChannelRemediationCommands, err = s.fabricServiceChannelRemediationCommandsForNode(ctx, input.ClusterID, input.NodeID, serviceChannelFeedback, s.now()) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh remediation commands: %w", err) } if err := s.recordFabricServiceChannelRemediationRebuildIntents(ctx, input.ClusterID, input.NodeID, cfg.ServiceChannelRemediationCommands, s.now()); err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh record remediation intents: %w", err) } remediationRoutePathDecisions, err := s.resolveFabricServiceChannelRemediationRebuildIntents(ctx, input, cfg.ServiceChannelRemediationCommands, intents, serviceChannelFeedback, cfg.ConfigVersion, s.now()) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh remediation decisions: %w", err) } serviceChannelExpiredFeedbackItems, err := s.store.ListFabricServiceChannelRouteFeedback(ctx, ListFabricServiceChannelRouteFeedbackInput{ ClusterID: input.ClusterID, @@ -7287,19 +7399,19 @@ func (s *Service) GetNodeSyntheticMeshConfig(ctx context.Context, input GetNodeS Now: s.now(), }) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh expired service channel feedback: %w", err) } mergeFabricServiceChannelRouteFeedback(serviceChannelFeedback, fabricServiceChannelManualRetryFeedbackFromObservationsWithProvenance(serviceChannelExpiredFeedbackItems, s.now(), recoveryPolicy, routeProvenance)) localPerspective, err := s.localEndpointPerspective(ctx, input.ClusterID, input.NodeID) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh local endpoint perspective: %w", err) } peerDirectory := map[string]*PeerDirectoryEntry{} recoverySeeds := map[string]PeerRecoverySeed{} rendezvousLeases := map[string]PeerRendezvousLease{} routePathDecisions := append([]RoutePathDecision{}, remediationRoutePathDecisions...) for _, intent := range intents { - route, peers, candidates, seeds, policyLeases, ok := s.syntheticRouteFromIntent(input, intent) + route, peers, candidates, seeds, policyLeases, ok := s.syntheticRouteFromIntent(input, intent, localPerspective) if !ok { continue } @@ -7312,16 +7424,16 @@ func (s *Service) GetNodeSyntheticMeshConfig(ctx context.Context, input GetNodeS } reportedPeers, reportedCandidates, err := s.reportedEndpointConfig(ctx, input.ClusterID, input.NodeID, route.Hops, localPerspective) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh reported endpoint config: %w", err) } feedback, err := s.rendezvousRelayFeedback(ctx, input.ClusterID, route.Hops, s.now()) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh rendezvous relay feedback: %w", err) } relayPolicy.addFeedback(feedback) replacementHints, err := s.rendezvousRelayReplacementHints(ctx, input.ClusterID, route.Hops, s.now()) if err != nil { - return NodeSyntheticMeshConfig{}, err + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh rendezvous replacement hints: %w", err) } relayPolicy.addReplacementHints(replacementHints) relayPolicy.addFeedback(replacementHintFeedback(replacementHints, s.now())) @@ -7332,8 +7444,28 @@ func (s *Service) GetNodeSyntheticMeshConfig(ctx context.Context, input GetNodeS for nodeID, items := range reportedCandidates { candidates[nodeID] = append(candidates[nodeID], items...) } + if localRelayCandidates := publicDirectRelayCandidates(localPerspective.PeerEndpointCandidates); len(localRelayCandidates) > 0 { + candidates[input.NodeID] = append(candidates[input.NodeID], enrichPeerEndpointCandidateCertPins(localRelayCandidates)...) + if isUsableFabricControlEndpoint(localPerspective.PeerEndpoint) && !endpointPrivateForOffsite(localPerspective.PeerEndpoint) { + peers[input.NodeID] = localPerspective.PeerEndpoint + } + } + relayPeers, relayCandidates, err := s.reportedRouteRelayEndpointConfig(ctx, input.ClusterID, input.NodeID, route.Hops, localPerspective) + if err != nil { + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh reported relay endpoint config: %w", err) + } + for nodeID, endpoint := range relayPeers { + if _, exists := peers[nodeID]; !exists { + peers[nodeID] = endpoint + } + } + for nodeID, items := range relayCandidates { + if len(items) > 0 { + candidates[nodeID] = append(candidates[nodeID], items...) + } + } routeLeases := scopedRendezvousLeases(policyLeases, route, input.NodeID, relayPolicy, s.now()) - routeLeases = append(routeLeases, derivedRendezvousLeases(route, peers, candidates, input.NodeID, relayPolicy, s.now())...) + routeLeases = append(routeLeases, derivedRendezvousLeases(route, peers, candidates, input.NodeID, localPerspective, relayPolicy, s.now())...) cfg.Routes = append(cfg.Routes, route) routePathDecisions = append(routePathDecisions, routePathDecisionForRoute(route, input.NodeID, routeLeases, relayPolicy, cfg.ConfigVersion, serviceChannelFeedback[route.RouteID])) mergePeerDirectoryRoute(peerDirectory, route, input.NodeID) @@ -7353,8 +7485,8 @@ func (s *Service) GetNodeSyntheticMeshConfig(ctx context.Context, input GetNodeS mergeRecoverySeeds(recoverySeeds, seeds) mergeRendezvousLeases(rendezvousLeases, routeLeases) } - if err := s.addCoreMeshBootstrapPeers(ctx, input, &cfg, peerDirectory, recoverySeeds, rendezvousLeases, localPerspective); err != nil { - return NodeSyntheticMeshConfig{}, err + if err := s.addCoreMeshBootstrapPeers(ctx, input, &cfg, peerDirectory, recoverySeeds, rendezvousLeases, localPerspective, relayPolicy); err != nil { + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh core bootstrap peers: %w", err) } cfg.RecoverySeeds = sortedRecoverySeeds(recoverySeeds, maxScopedRecoverySeeds) cfg.RendezvousLeases = sortedRendezvousLeases(rendezvousLeases, maxScopedRendezvousLeases) @@ -7364,7 +7496,11 @@ func (s *Service) GetNodeSyntheticMeshConfig(ctx context.Context, input GetNodeS markPeerDirectoryRecoverySeeds(peerDirectory, cfg.RecoverySeeds) markPeerDirectoryRendezvousLeases(peerDirectory, cfg.RendezvousLeases, input.NodeID) cfg.PeerDirectory = sortedPeerDirectory(peerDirectory) - return s.signSyntheticMeshConfig(ctx, cfg) + signed, err := s.signSyntheticMeshConfig(ctx, cfg) + if err != nil { + return NodeSyntheticMeshConfig{}, fmt.Errorf("synthetic mesh sign config: %w", err) + } + return signed, nil } func (s *Service) recordFabricServiceChannelRouteRebuildAttempts(ctx context.Context, input GetNodeSyntheticMeshConfigInput, report *RoutePathDecisionReport, feedbackReport *FabricServiceChannelRouteFeedbackReport) error { @@ -7719,7 +7855,7 @@ func nodeMeshListenerConfigFromDesired(workload NodeWorkloadDesiredState) (*Node }, nil } -func (s *Service) addCoreMeshBootstrapPeers(ctx context.Context, input GetNodeSyntheticMeshConfigInput, cfg *NodeSyntheticMeshConfig, peerDirectory map[string]*PeerDirectoryEntry, recoverySeeds map[string]PeerRecoverySeed, rendezvousLeases map[string]PeerRendezvousLease, localPerspective endpointPerspective) error { +func (s *Service) addCoreMeshBootstrapPeers(ctx context.Context, input GetNodeSyntheticMeshConfigInput, cfg *NodeSyntheticMeshConfig, peerDirectory map[string]*PeerDirectoryEntry, recoverySeeds map[string]PeerRecoverySeed, rendezvousLeases map[string]PeerRendezvousLease, localPerspective endpointPerspective, relayPolicy *rendezvousRelayPolicy) error { roles, err := s.store.ListNodeRoleAssignments(ctx, input.ClusterID, input.NodeID) if err != nil { return err @@ -7727,6 +7863,15 @@ func (s *Service) addCoreMeshBootstrapPeers(ctx context.Context, input GetNodeSy if !hasActiveNodeRole(roles, "core-mesh") { return nil } + localRelayCandidates := publicDirectRelayCandidates(localPerspective.PeerEndpointCandidates) + if len(localRelayCandidates) > 0 { + cfg.PeerEndpointCandidates[input.NodeID] = append(cfg.PeerEndpointCandidates[input.NodeID], localRelayCandidates...) + mergePeerDirectoryCandidates(peerDirectory, input.NodeID, localRelayCandidates) + if isUsableFabricControlEndpoint(localPerspective.PeerEndpoint) && !endpointPrivateForOffsite(localPerspective.PeerEndpoint) { + cfg.PeerEndpoints[input.NodeID] = localPerspective.PeerEndpoint + peerDirectoryEntry(peerDirectory, input.NodeID).EndpointCount++ + } + } nodes, err := s.store.ListClusterNodes(ctx, input.ClusterID) if err != nil { return err @@ -7753,14 +7898,14 @@ func (s *Service) addCoreMeshBootstrapPeers(ctx context.Context, input GetNodeSy } desiredEndpoint, desiredCandidates, err := s.desiredMeshListenerEndpointConfig(ctx, input.ClusterID, node.ID, added) if err != nil { - return err + return fmt.Errorf("desired mesh listener endpoint for node %s: %w", node.ID, err) } if added >= defaultCoreMeshBootstrapPeerTarget && !hasDirectUsableEndpointCandidate(desiredCandidates) { continue } heartbeats, err := s.store.ListNodeHeartbeats(ctx, input.ClusterID, node.ID, 1) if err != nil { - return err + return fmt.Errorf("list bootstrap peer heartbeat for node %s: %w", node.ID, err) } if len(heartbeats) == 0 && desiredEndpoint == "" && len(desiredCandidates) == 0 { continue @@ -7784,9 +7929,6 @@ func (s *Service) addCoreMeshBootstrapPeers(ctx context.Context, input GetNodeSy if len(candidates) > 0 { cfg.PeerEndpointCandidates[node.ID] = append(cfg.PeerEndpointCandidates[node.ID], candidates...) mergePeerDirectoryCandidates(peerDirectory, node.ID, candidates) - if lease, ok := controlPlaneBootstrapRendezvousLease(input.ClusterID, node.ID, candidates, localPerspective, s.now()); ok { - mergeRendezvousLeases(rendezvousLeases, []PeerRendezvousLease{lease}) - } } seed := recoverySeedFromEndpointReport(node.ID, endpoint, candidates, added) if seed.NodeID != "" && !endpointCandidateRequiresRendezvous(PeerEndpointCandidate{ @@ -7799,9 +7941,27 @@ func (s *Service) addCoreMeshBootstrapPeers(ctx context.Context, input GetNodeSy } added++ } + mergeRendezvousLeases(rendezvousLeases, coreMeshBootstrapRendezvousLeases(input.ClusterID, input.NodeID, cfg.PeerEndpointCandidates, relayPolicy, s.now())) return nil } +func publicDirectRelayCandidates(candidates []PeerEndpointCandidate) []PeerEndpointCandidate { + out := []PeerEndpointCandidate{} + for _, candidate := range candidates { + if endpointCandidateRequiresRendezvous(candidate) || endpointCandidatePrivateForOffsite(candidate) { + continue + } + if !isUsableFabricControlEndpoint(candidate.Address) { + continue + } + if strings.ToLower(strings.TrimSpace(candidate.Reachability)) != "public" && strings.ToLower(strings.TrimSpace(candidate.ConnectivityMode)) != "direct" { + continue + } + out = append(out, candidate) + } + return out +} + func hasDirectUsableEndpointCandidate(candidates []PeerEndpointCandidate) bool { for _, candidate := range candidates { if strings.TrimSpace(candidate.Address) != "" && @@ -8996,6 +9156,10 @@ func (s *Service) attachVPNFabricServiceChannelLeases(ctx context.Context, profi for i := range profile.Connections { connection := profile.Connections[i] route := vpnFabricRouteFromClientConfig(connection.ClientConfig) + if route.Status == "planned" && route.SelectedEntryNodeID == "" && route.SelectedExitNodeID != "" { + profile.Connections[i].ClientConfig = attachVPNMeshNodeRouteContract(connection.ClientConfig) + continue + } if route.Status != "planned" || route.SelectedEntryNodeID == "" || route.SelectedExitNodeID == "" { continue } @@ -9031,6 +9195,42 @@ func (s *Service) attachVPNFabricServiceChannelLeases(ctx context.Context, profi return profile } +func attachVPNMeshNodeRouteContract(raw json.RawMessage) json.RawMessage { + var cfg map[string]any + if err := json.Unmarshal(raw, &cfg); err != nil || cfg == nil { + cfg = map[string]any{} + } + cfg["fabric_service_channel_status"] = "mesh_node_route_required" + cfg["fabric_service_channel_lease"] = nil + cfg["vpn_client_node_contract"] = map[string]any{ + "schema_version": "rap.vpn_client_node_route.v1", + "node_role": "vpn-client", + "route_authority": "fabric_farm", + "entry_node_required": false, + "exit_selection": "pool", + "transport": "quic_fabric_mesh", + "legacy_protocol_supported": false, + "backend_packet_relay": false, + "android_runtime_packaging": "node_agent_required", + "standalone_vpnservice_only": false, + "service_binding": map[string]any{ + "type": "local_ipv4_ingress", + "accepts_from": []string{"android_vpnservice_tun", "linux_tun", "host_service_port"}, + "listen_tcp_ports": cfg["listen_tcp_ports"], + "listen_udp_ports": cfg["listen_udp_ports"], + "exit_selection": "pool", + "preferred_exit_pool_id": cfg["exit_pool_id"], + "packet_service_class": "vpn_packets", + "legacy_protocol_listener": false, + }, + } + out, err := json.Marshal(cfg) + if err != nil { + return raw + } + return out +} + func attachVPNFabricServiceChannelLease(raw json.RawMessage, lease FabricServiceChannelLease) json.RawMessage { var cfg map[string]any if err := json.Unmarshal(raw, &cfg); err != nil || cfg == nil { @@ -9078,10 +9278,21 @@ func enrichVPNDataplaneSession(profile VPNClientProfile, connection VPNClientCon sessionID = "vpn-session-" + now.UTC().Format("20060102T150405.000000000Z") } entryCandidates := vpnDataplaneEntryCandidates(route, connection, cfg) - transportCandidates := vpnDataplaneTransportCandidates(route, entryCandidates) + exitCandidates := vpnConcreteExitCandidatesFromClientConfig(cfg) + serviceChannelRequest := vpnFabricServiceChannelRequest(profile, connection, route, cfg, sessionID, now) + routeBundle := vpnFabricRouteBundle(route, entryCandidates, exitCandidates, now) + transportCandidates := vpnDataplaneTransportCandidates(route, entryCandidates, exitCandidates) status := "waiting_for_entry_endpoint" if route.Status == "planned" && route.SelectedEntryNodeID != "" && route.SelectedExitNodeID != "" { status = "ready_for_entry_listener" + } 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" } cfg["vpn_dataplane_session"] = map[string]any{ "schema_version": "rap.vpn_dataplane_session.v1", @@ -9095,7 +9306,7 @@ func enrichVPNDataplaneSession(profile VPNClientProfile, connection VPNClientCon "vpn_connection_id": connection.ID, "entry_node_id": route.SelectedEntryNodeID, "exit_node_id": route.SelectedExitNodeID, - "preferred_transport": "fabric_service_channel_v1", + "preferred_transport": preferredTransport, "fallback_transport": "none", "route_authority": "fabric_farm", "backend_relay_allowed": false, @@ -9105,15 +9316,29 @@ func enrichVPNDataplaneSession(profile VPNClientProfile, connection VPNClientCon "all_ip_traffic": true, "protocol_specific_routing": false, }, + "client_node_service_binding": map[string]any{ + "type": "local_ipv4_ingress", + "accepts_from": []string{"android_vpnservice_tun", "linux_tun", "host_service_port"}, + "listen_tcp_ports": cfg["listen_tcp_ports"], + "listen_udp_ports": cfg["listen_udp_ports"], + "exit_selection": "pool", + "preferred_exit_pool_id": cfg["exit_pool_id"], + "selected_exit_node_id": route.SelectedExitNodeID, + "packet_service_class": "vpn_packets", + "legacy_protocol_listener": false, + }, "auth": map[string]any{ "type": "control_plane_issued_bearer", "token": "rap_vpn_dps_" + sessionID, "token_ttl_seconds": int(expiresAt.Sub(now).Seconds()), - "node_validation": "entry_node_calls_control_plane_introspection", + "node_validation": nodeValidation, "introspection_path": "/api/v1/clusters/{cluster_id}/vpn/dataplane-sessions/{session_id}/introspect", }, - "entry_candidates": entryCandidates, - "transport_candidates": transportCandidates, + "entry_candidates": entryCandidates, + "exit_candidates": exitCandidates, + "fabric_service_channel_request": serviceChannelRequest, + "fabric_route_bundle": routeBundle, + "transport_candidates": transportCandidates, } out, err := json.Marshal(cfg) if err != nil { @@ -9122,6 +9347,160 @@ func enrichVPNDataplaneSession(profile VPNClientProfile, connection VPNClientCon return out } +func vpnFabricServiceChannelRequest(profile VPNClientProfile, connection VPNClientConnection, route vpnClientFabricRoute, cfg map[string]any, channelID string, now time.Time) map[string]any { + targetPoolIDs := vpnConnectionExitPoolIDs(connection) + if len(targetPoolIDs) == 0 { + if raw, ok := cfg["exit_pool_id"].(string); ok && strings.TrimSpace(raw) != "" { + targetPoolIDs = []string{strings.TrimSpace(raw)} + } + } + if len(targetPoolIDs) == 0 && route.SelectedExitNodeID != "" { + targetPoolIDs = []string{"selected-exit-node-pool"} + } + warmStandby := 1 + if len(route.ExitPoolNodeIDs) <= 1 && len(targetPoolIDs) <= 1 { + warmStandby = 0 + } + return map[string]any{ + "schema_version": "rap.fabric_service_channel_request.v1", + "channel_id": channelID, + "cluster_id": profile.ClusterID, + "organization_id": profile.OrganizationID, + "user_id": profile.UserID, + "resource_id": connection.ID, + "source_role": "vpn-client", + "service_class": "vpn_packets", + "target": map[string]any{ + "kind": "pool", + "pool_ids": targetPoolIDs, + "node_ids": route.ExitPoolNodeIDs, + "selected_node_id": route.SelectedExitNodeID, + "service_role": "ipv4-egress", + "selection_policy": "latency_and_load_aware", + "single_member_pool": len(route.ExitPoolNodeIDs) <= 1, + }, + "traffic": map[string]any{ + "mode": "duplex", + "channel_class": "vpn_packet", + "application_protocol_agnostic": true, + "tunnel_type": "universal_ip_packet", + "flow_distribution": "latency_and_load_aware", + "service_adapter_owns_protocol": false, + "fabric_owns_route_and_failover": true, + }, + "resilience": map[string]any{ + "min_active_paths": 1, + "warm_standby_paths": warmStandby, + "failover": "pool_member_or_next_authorized_pool", + "reroute_on": []string{"route_failure", "latency_regression", "loss_regression", "backpressure", "pool_member_failure"}, + }, + "adapter_contract": map[string]any{ + "adapter": "vpn-client", + "adapter_role": "tun_packet_adapter", + "adapter_may_select_endpoint": false, + "adapter_may_use_legacy_relay": false, + }, + "issued_at": now, + } +} + +func vpnConnectionExitPoolIDs(connection VPNClientConnection) []string { + var target struct { + ExitPoolIDs []string `json:"exit_pool_ids"` + ExitPoolID string `json:"exit_pool_id"` + } + _ = json.Unmarshal(connection.TargetEndpoint, &target) + out := dedupeStrings(target.ExitPoolIDs) + if len(out) == 0 && strings.TrimSpace(target.ExitPoolID) != "" { + out = []string{strings.TrimSpace(target.ExitPoolID)} + } + var placement struct { + ExitPoolIDs []string `json:"exit_pool_ids"` + ExitPools []struct { + PoolID string `json:"pool_id"` + } `json:"exit_pools"` + } + _ = json.Unmarshal(connection.PlacementPolicy, &placement) + out = append(out, placement.ExitPoolIDs...) + for _, pool := range placement.ExitPools { + if strings.TrimSpace(pool.PoolID) != "" { + out = append(out, strings.TrimSpace(pool.PoolID)) + } + } + return dedupeStrings(out) +} + +func vpnFabricRouteBundle(route vpnClientFabricRoute, entryCandidates []map[string]any, exitCandidates []map[string]any, now time.Time) map[string]any { + primaryPath := vpnFabricRouteLeasePath("primary", route.SelectedExitNodeID, exitCandidates) + standbyPaths := vpnFabricRouteLeaseStandbyPaths(route.SelectedExitNodeID, exitCandidates) + return map[string]any{ + "schema_version": "rap.fabric_route_bundle.v1", + "route_authority": "fabric_farm", + "selected_entry_node_id": route.SelectedEntryNodeID, + "selected_target_node_id": route.SelectedExitNodeID, + "target_pool_node_ids": route.ExitPoolNodeIDs, + "entry_candidates": entryCandidates, + "target_candidates": exitCandidates, + "endpoint_candidates": exitCandidates, + "route_lease": map[string]any{ + "schema_version": "rap.fabric_route_lease.v1", + "lease_id": "lease-" + firstNonEmptyString(route.SelectedExitNodeID, "pool") + "-" + now.UTC().Format("20060102T150405Z"), + "route_authority": "fabric_farm", + "target_kind": "pool", + "target_pool_node_ids": route.ExitPoolNodeIDs, + "selected_target_node": route.SelectedExitNodeID, + "primary_path": primaryPath, + "warm_standby_paths": standbyPaths, + "multipath": map[string]any{"enabled": true, "flow_distribution": "latency_and_load_aware", "min_active_paths": 1, "max_parallel_paths": maxInt(1, minInt(2, len(exitCandidates)))}, + "rebuild_policy": map[string]any{"owner": "fabric_farm", "reroute_on": []string{"route_failure", "latency_regression", "loss_regression", "backpressure", "pool_member_failure"}, "service_adapter_action": "keep_sending_packets_to_channel"}, + "service_visibility": "opaque_route_lease", + "physical_path_visible": false, + "generated_at": now, + }, + "primary_paths": 1, + "warm_standby_paths": maxInt(0, minInt(1, len(route.ExitPoolNodeIDs)-1)), + "legacy_visibility": "opaque_to_service_adapters", + "generated_at": now, + } +} + +func vpnFabricRouteLeasePath(pathID string, selectedNodeID string, candidates []map[string]any) map[string]any { + pathCandidates := make([]map[string]any, 0, len(candidates)) + for _, candidate := range candidates { + nodeID, _ := candidate["node_id"].(string) + if selectedNodeID != "" && nodeID != "" && nodeID != selectedNodeID { + continue + } + pathCandidates = append(pathCandidates, candidate) + } + if len(pathCandidates) == 0 { + pathCandidates = candidates + } + return map[string]any{ + "path_id": pathID, + "target_node_id": selectedNodeID, + "status": "ready", + "endpoint_candidates": pathCandidates, + } +} + +func vpnFabricRouteLeaseStandbyPaths(selectedNodeID string, candidates []map[string]any) []map[string]any { + out := make([]map[string]any, 0, len(candidates)) + seen := map[string]struct{}{} + for _, candidate := range candidates { + nodeID, _ := candidate["node_id"].(string) + if nodeID == "" || nodeID == selectedNodeID { + continue + } + if _, ok := seen[nodeID]; ok { + continue + } + seen[nodeID] = struct{}{} + out = append(out, vpnFabricRouteLeasePath("standby-"+nodeID, nodeID, candidates)) + } + return out +} + func vpnDataplaneEntryCandidates(route vpnClientFabricRoute, connection VPNClientConnection, cfg map[string]any) []map[string]any { concrete := vpnConcreteEntryCandidatesFromClientConfig(cfg) ids := dedupeStrings(append([]string{route.SelectedEntryNodeID}, connection.EntryNodeIDs...)) @@ -9187,16 +9566,39 @@ func vpnConcreteEntryCandidatesFromClientConfig(cfg map[string]any) []map[string return out } -func vpnDataplaneTransportCandidates(route vpnClientFabricRoute, entryCandidates []map[string]any) []map[string]any { +func vpnConcreteExitCandidatesFromClientConfig(cfg map[string]any) []map[string]any { + raw, ok := cfg["vpn_exit_endpoint_candidates"] + if !ok { + return nil + } + payload, err := json.Marshal(raw) + if err != nil { + return nil + } + var out []map[string]any + if err := json.Unmarshal(payload, &out); err != nil { + return nil + } + return out +} + +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": "fabric_service_channel_v1", - "status": "contract_ready_listener_pending", + "type": transportType, + "status": transportStatus, "entry_node_id": route.SelectedEntryNodeID, "exit_node_id": route.SelectedExitNodeID, "route_authority": "fabric_farm", "backend_relay_allowed": false, "entry_candidates": entryCandidates, + "exit_candidates": exitCandidates, "application_protocols": []string{"ip"}, }, } @@ -10017,7 +10419,7 @@ func linuxInstallProfileFromScope(input DockerInstallProfileRequest, scopeRaw js MeshListenAutoPortEnd: positiveOrDefault(scope.MeshListenAutoPortEnd, 19231), MeshAdvertiseEndpoint: strings.TrimRight(strings.TrimSpace(scope.MeshAdvertiseEndpoint), "/"), MeshAdvertiseEndpointsJSON: scope.MeshAdvertiseEndpointsJSON, - MeshAdvertiseTransport: firstNonEmptyString(strings.TrimSpace(scope.MeshAdvertiseTransport), "direct_http"), + MeshAdvertiseTransport: firstNonEmptyString(strings.TrimSpace(scope.MeshAdvertiseTransport), "direct_quic"), MeshConnectivityMode: firstNonEmptyString(strings.TrimSpace(scope.MeshConnectivityMode), "outbound_only"), MeshNATType: firstNonEmptyString(strings.TrimSpace(scope.MeshNATType), "unknown"), MeshRegion: firstNonEmptyString(strings.TrimSpace(scope.MeshRegion), "linux"), @@ -10212,10 +10614,11 @@ type rendezvousRelayFeedbackEntry struct { } type rendezvousRelaySelection struct { - RelayNodeID string - Endpoint string - Score int - Reasons []string + RelayNodeID string + Endpoint string + PeerCertSHA256 string + Score int + Reasons []string } type rendezvousRelayPolicy struct { @@ -10229,7 +10632,7 @@ type rendezvousRelayPolicy struct { const ( maxScopedRecoverySeeds = 20 - maxScopedRendezvousLeases = 20 + maxScopedRendezvousLeases = 128 defaultCoreMeshBootstrapPeerTarget = 3 rendezvousRelayFeedbackMaxAge = 2 * time.Minute ) @@ -10239,7 +10642,7 @@ type nodeSelector struct { NodeIDs []string `json:"node_ids"` } -func (s *Service) syntheticRouteFromIntent(input GetNodeSyntheticMeshConfigInput, intent MeshRouteIntent) (SyntheticMeshRouteConfig, map[string]string, map[string][]PeerEndpointCandidate, []PeerRecoverySeed, []PeerRendezvousLease, bool) { +func (s *Service) syntheticRouteFromIntent(input GetNodeSyntheticMeshConfigInput, intent MeshRouteIntent, localPerspective endpointPerspective) (SyntheticMeshRouteConfig, map[string]string, map[string][]PeerEndpointCandidate, []PeerRecoverySeed, []PeerRendezvousLease, bool) { if intent.Status != "active" { return SyntheticMeshRouteConfig{}, nil, nil, nil, nil, false } @@ -10260,7 +10663,7 @@ func (s *Service) syntheticRouteFromIntent(input GetNodeSyntheticMeshConfigInput if len(hops) == 0 && sourceNodeID != "" && destinationNodeID != "" { hops = []string{sourceNodeID, destinationNodeID} } - if len(hops) < 2 || !containsString(hops, input.NodeID) { + if len(hops) < 2 { return SyntheticMeshRouteConfig{}, nil, nil, nil, nil, false } if err := validatePeerEndpointCandidates(policy.PeerEndpointCandidates, hops); err != nil { @@ -10272,6 +10675,25 @@ func (s *Service) syntheticRouteFromIntent(input GetNodeSyntheticMeshConfigInput if err := validatePeerRendezvousLeases(policy.RendezvousLeases, hops, s.now()); err != nil { return SyntheticMeshRouteConfig{}, nil, nil, nil, nil, false } + scopedHops := append([]string{}, hops...) + if !containsString(scopedHops, input.NodeID) { + lease, ok := policyRendezvousLeaseForRelayNode(policy.RendezvousLeases, hops, input.NodeID, intent.ID) + if !ok { + if !localPerspectiveCanServeRendezvousRelay(localPerspective) { + return SyntheticMeshRouteConfig{}, nil, nil, nil, nil, false + } + peerNodeID := destinationNodeID + if peerNodeID == "" || !containsString(hops, peerNodeID) { + peerNodeID = hops[len(hops)-1] + } + scopedHops = effectiveRoutePathWithReplacement(hops, peerNodeID, "", input.NodeID) + } else { + scopedHops = effectiveRoutePathWithReplacement(hops, lease.PeerNodeID, "", lease.RelayNodeID) + } + if !containsString(scopedHops, input.NodeID) { + return SyntheticMeshRouteConfig{}, nil, nil, nil, nil, false + } + } if sourceNodeID == "" { sourceNodeID = hops[0] } @@ -10314,7 +10736,7 @@ func (s *Service) syntheticRouteFromIntent(input GetNodeSyntheticMeshConfigInput ClusterID: input.ClusterID, SourceNodeID: sourceNodeID, DestinationNodeID: destinationNodeID, - Hops: hops, + Hops: scopedHops, AllowedChannels: allowedChannels, ExpiresAt: expiresAt, MaxTTL: maxTTL, @@ -10324,13 +10746,47 @@ func (s *Service) syntheticRouteFromIntent(input GetNodeSyntheticMeshConfigInput PeerDirectoryVersion: peerDirectoryVersion, } return route, - scopedPeerEndpoints(policy.PeerEndpoints, hops), - scopedPeerEndpointCandidates(policy.PeerEndpointCandidates, hops), + scopedPeerEndpoints(policy.PeerEndpoints, scopedHops), + scopedPeerEndpointCandidates(policy.PeerEndpointCandidates, scopedHops), policy.RecoverySeeds, normalizeRendezvousLeases(policy.RendezvousLeases, route, s.now()), true } +func localPerspectiveCanServeRendezvousRelay(local endpointPerspective) bool { + if len(publicDirectRelayCandidates(local.PeerEndpointCandidates)) > 0 { + return true + } + return isUsableFabricControlEndpoint(local.PeerEndpoint) && !endpointPrivateForOffsite(local.PeerEndpoint) +} + +func policyRendezvousLeaseForRelayNode(leases []PeerRendezvousLease, routeHops []string, localNodeID string, routeID string) (PeerRendezvousLease, bool) { + localNodeID = strings.TrimSpace(localNodeID) + if localNodeID == "" { + return PeerRendezvousLease{}, false + } + var selected PeerRendezvousLease + found := false + for _, lease := range leases { + if strings.TrimSpace(lease.RelayNodeID) != localNodeID || + strings.TrimSpace(lease.PeerNodeID) == "" || + !containsString(routeHops, lease.PeerNodeID) || + !rendezvousLeaseAppliesToRoute(lease, routeID) { + continue + } + if !found || rendezvousLeaseBetterForRoutePath(lease, selected) { + selected = lease + found = true + } + } + return selected, found +} + +func rendezvousLeaseAppliesToRoute(lease PeerRendezvousLease, routeID string) bool { + routeID = strings.TrimSpace(routeID) + return routeID == "" || len(lease.RouteIDs) == 0 || containsString(lease.RouteIDs, routeID) +} + func (s *Service) reportedEndpointConfig(ctx context.Context, clusterID string, localNodeID string, routePath []string, localPerspective endpointPerspective) (map[string]string, map[string][]PeerEndpointCandidate, error) { peers := map[string]string{} candidates := map[string][]PeerEndpointCandidate{} @@ -10362,6 +10818,7 @@ func (s *Service) reportedEndpointConfig(ctx context.Context, clusterID string, } } peerEndpoint, nodeCandidates = scopeEndpointReportForLocal(localPerspective, peerEndpoint, nodeCandidates) + nodeCandidates = enrichPeerEndpointCandidateCertPins(nodeCandidates) if peerEndpoint != "" { peers[nodeID] = peerEndpoint } @@ -10372,11 +10829,138 @@ func (s *Service) reportedEndpointConfig(ctx context.Context, clusterID string, return peers, candidates, nil } +func (s *Service) reportedRouteRelayEndpointConfig(ctx context.Context, clusterID string, localNodeID string, routePath []string, localPerspective endpointPerspective) (map[string]string, map[string][]PeerEndpointCandidate, error) { + nodes, err := s.store.ListClusterNodes(ctx, clusterID) + if err != nil { + return nil, nil, err + } + sort.SliceStable(nodes, func(i, j int) bool { + if nodes[i].HealthStatus != nodes[j].HealthStatus { + return nodes[i].HealthStatus == "healthy" + } + iSeen := nodeLastSeen(nodes[i]) + jSeen := nodeLastSeen(nodes[j]) + if !iSeen.Equal(jSeen) { + return iSeen.After(jSeen) + } + return nodes[i].CreatedAt.Before(nodes[j].CreatedAt) + }) + routeNodes := map[string]struct{}{} + for _, nodeID := range routePath { + if nodeID = strings.TrimSpace(nodeID); nodeID != "" { + routeNodes[nodeID] = struct{}{} + } + } + peers := map[string]string{} + candidates := map[string][]PeerEndpointCandidate{} + added := 0 + for _, node := range nodes { + if node.ID == "" || + node.ID == localNodeID || + node.MembershipStatus != "active" || + node.RegistrationStatus != NodeRegistrationActive || + node.HealthStatus != "healthy" { + continue + } + if _, inRoute := routeNodes[node.ID]; inRoute { + continue + } + desiredEndpoint, desiredCandidates, err := s.desiredMeshListenerEndpointConfig(ctx, clusterID, node.ID, added) + if err != nil { + return nil, nil, fmt.Errorf("desired mesh listener endpoint for relay node %s: %w", node.ID, err) + } + heartbeats, err := s.store.ListNodeHeartbeats(ctx, clusterID, node.ID, 1) + if err != nil { + return nil, nil, fmt.Errorf("list relay peer heartbeat for node %s: %w", node.ID, err) + } + endpoint := desiredEndpoint + nodeCandidates := append([]PeerEndpointCandidate{}, desiredCandidates...) + if len(heartbeats) > 0 { + reportedEndpoint, reportedCandidates, ok := endpointReportFromHeartbeat(heartbeats[0]) + if ok { + if endpoint == "" { + endpoint = reportedEndpoint + } + nodeCandidates = append(nodeCandidates, reportedCandidates...) + } + } + endpoint, nodeCandidates = scopeEndpointReportForLocal(localPerspective, endpoint, nodeCandidates) + nodeCandidates = publicDirectRelayCandidates(enrichPeerEndpointCandidateCertPins(nodeCandidates)) + if len(nodeCandidates) == 0 { + continue + } + if endpoint != "" && !endpointPrivateForOffsite(endpoint) { + peers[node.ID] = endpoint + } + candidates[node.ID] = append(candidates[node.ID], nodeCandidates...) + added++ + if added >= defaultCoreMeshBootstrapPeerTarget { + break + } + } + return peers, candidates, nil +} + +func enrichPeerEndpointCandidateCertPins(candidates []PeerEndpointCandidate) []PeerEndpointCandidate { + if len(candidates) == 0 { + return candidates + } + certByEndpoint := map[string]string{} + for _, candidate := range candidates { + endpoint := strings.TrimRight(strings.TrimSpace(candidate.Address), "/") + if endpoint == "" { + continue + } + if certSHA256 := peerEndpointCandidateTLSCertSHA256(candidate); certSHA256 != "" { + certByEndpoint[endpoint] = certSHA256 + } + } + if len(certByEndpoint) == 0 { + return candidates + } + out := append([]PeerEndpointCandidate{}, candidates...) + for i := range out { + if peerEndpointCandidateTLSCertSHA256(out[i]) != "" { + continue + } + endpoint := strings.TrimRight(strings.TrimSpace(out[i].Address), "/") + certSHA256 := certByEndpoint[endpoint] + if certSHA256 == "" { + continue + } + out[i].Metadata = peerEndpointCandidateMetadataWithCert(out[i].Metadata, certSHA256) + } + return out +} + +func peerEndpointCandidateMetadataWithCert(raw json.RawMessage, certSHA256 string) json.RawMessage { + certSHA256 = strings.TrimSpace(certSHA256) + if certSHA256 == "" { + return raw + } + values := map[string]any{} + if len(raw) > 0 && json.Valid(raw) { + _ = json.Unmarshal(raw, &values) + } + tlsCert, _ := values["tls_cert_sha256"].(string) + peerCert, _ := values["peer_cert_sha256"].(string) + if strings.TrimSpace(tlsCert) == "" && strings.TrimSpace(peerCert) == "" { + values["tls_cert_sha256"] = certSHA256 + } + payload, err := json.Marshal(values) + if err != nil { + return raw + } + return payload +} + type endpointPerspective struct { OutboundOnly bool Region string ControlPlaneURL string ControlPlaneRelayEndpoint string + PeerEndpoint string + PeerEndpointCandidates []PeerEndpointCandidate } func (s *Service) localEndpointPerspective(ctx context.Context, clusterID, localNodeID string) (endpointPerspective, error) { @@ -10410,11 +10994,14 @@ func endpointPerspectiveFromHeartbeat(heartbeat NodeHeartbeat) endpointPerspecti } connectivity := strings.ToLower(strings.TrimSpace(metadata.MeshEndpointReport.ConnectivityMode)) reachability := strings.ToLower(strings.TrimSpace(metadata.MeshListenerReport.InboundReachability)) + peerEndpoint, peerCandidates, _ := endpointReportFromHeartbeat(heartbeat) return endpointPerspective{ OutboundOnly: connectivity == "outbound_only" || reachability == "outbound_only" || metadata.MeshListenerReport.OneWayConnectivity, Region: strings.TrimSpace(metadata.MeshEndpointReport.Region), ControlPlaneURL: strings.TrimSpace(metadata.MeshOutboundSessionReport.ControlPlaneURL), ControlPlaneRelayEndpoint: controlPlaneRelayEndpointFromURL(metadata.MeshOutboundSessionReport.ControlPlaneURL), + PeerEndpoint: peerEndpoint, + PeerEndpointCandidates: peerCandidates, } } @@ -10442,6 +11029,10 @@ func controlPlaneRelayEndpointFromURL(raw string) string { } func controlPlaneBootstrapRendezvousLease(clusterID, peerNodeID string, candidates []PeerEndpointCandidate, local endpointPerspective, now time.Time) (PeerRendezvousLease, bool) { + return PeerRendezvousLease{}, false +} + +func legacyControlPlaneBootstrapRendezvousLease(clusterID, peerNodeID string, candidates []PeerEndpointCandidate, local endpointPerspective, now time.Time) (PeerRendezvousLease, bool) { if !local.OutboundOnly || local.ControlPlaneRelayEndpoint == "" { return PeerRendezvousLease{}, false } @@ -10480,14 +11071,16 @@ func controlPlaneBootstrapRendezvousLease(clusterID, peerNodeID string, candidat } func scopeEndpointReportForLocal(local endpointPerspective, endpoint string, candidates []PeerEndpointCandidate) (string, []PeerEndpointCandidate) { - if !local.OutboundOnly { + if !local.OutboundOnly && strings.TrimSpace(local.Region) == "" { return endpoint, candidates } out := make([]PeerEndpointCandidate, 0, len(candidates)) directUsable := false + remotePrivate := false for _, candidate := range candidates { - if endpointCandidatePrivateForOffsite(candidate) { + if endpointCandidatePrivateForLocalOffsite(local, candidate) { candidate = relayRequiredCandidateForOffsite(candidate) + remotePrivate = true } else if !endpointCandidateRequiresRendezvous(candidate) { directUsable = true } @@ -10496,12 +11089,59 @@ func scopeEndpointReportForLocal(local endpointPerspective, endpoint string, can } out = append(out, candidate) } - if !directUsable && endpointPrivateForOffsite(endpoint) { + if !directUsable && (local.OutboundOnly || remotePrivate) && endpointPrivateForOffsite(endpoint) { endpoint = "" } return endpoint, out } +func endpointCandidatePrivateForLocalOffsite(local endpointPerspective, candidate PeerEndpointCandidate) bool { + if !endpointCandidatePrivateForOffsite(candidate) { + return false + } + if endpointCandidateSharesLocalPrivateLAN(local, candidate) { + return false + } + localRegion := strings.TrimSpace(local.Region) + peerRegion := strings.TrimSpace(candidate.Region) + return local.OutboundOnly || (localRegion != "" && peerRegion != "" && !strings.EqualFold(localRegion, peerRegion)) +} + +func endpointCandidateSharesLocalPrivateLAN(local endpointPerspective, candidate PeerEndpointCandidate) bool { + remoteIP := net.ParseIP(peerEndpointHost(candidate.Address)) + if remoteIP == nil || !remoteIP.IsPrivate() { + return false + } + for _, localCandidate := range local.PeerEndpointCandidates { + localIP := net.ParseIP(peerEndpointHost(localCandidate.Address)) + if localIP == nil || !localIP.IsPrivate() { + continue + } + if privateIPsShareLikelyLAN(localIP, remoteIP) { + return true + } + } + localIP := net.ParseIP(peerEndpointHost(local.PeerEndpoint)) + return localIP != nil && localIP.IsPrivate() && privateIPsShareLikelyLAN(localIP, remoteIP) +} + +func privateIPsShareLikelyLAN(left, right net.IP) bool { + if left4, right4 := left.To4(), right.To4(); left4 != nil && right4 != nil { + return left4[0] == right4[0] && left4[1] == right4[1] && left4[2] == right4[2] + } + left16 := left.To16() + right16 := right.To16() + if left16 == nil || right16 == nil { + return false + } + for i := 0; i < 8; i++ { + if left16[i] != right16[i] { + return false + } + } + return true +} + func endpointCandidatePrivateForOffsite(candidate PeerEndpointCandidate) bool { connectivity := strings.ToLower(strings.TrimSpace(candidate.ConnectivityMode)) reachability := strings.ToLower(strings.TrimSpace(candidate.Reachability)) @@ -10518,7 +11158,7 @@ func endpointPrivateForOffsite(endpoint string) bool { } func relayRequiredCandidateForOffsite(candidate PeerEndpointCandidate) PeerEndpointCandidate { - candidate.Transport = "relay" + candidate.Transport = "relay_quic" candidate.Reachability = "relay" candidate.ConnectivityMode = "relay_required" candidate.NATType = firstNonEmptyString(candidate.NATType, "unknown") @@ -11564,7 +12204,7 @@ func (s *Service) selectServiceChannelRouteReplacement(input GetNodeSyntheticMes selectedScore := -1 scopes := fabricServiceChannelRouteIntentReplacementScopes(intents) for _, intent := range intents { - route, _, _, _, _, ok := s.syntheticRouteFromIntent(input, intent) + route, _, _, _, _, ok := s.syntheticRouteFromIntent(input, intent, endpointPerspective{}) if !ok || route.RouteID == fencedRoute.RouteID { continue } @@ -11675,11 +12315,62 @@ func routePathDecisionForRoute(route SyntheticMeshRouteConfig, localNodeID strin if len(decision.ScoreReasons) == 0 { decision.ScoreReasons = []string{"relay_replacement_policy"} } + } else if lease, ok := routePathRendezvousLeaseForRoute(route, leases); ok { + decision.DecisionID = route.RouteID + "-path-" + localNodeID + "-via-" + lease.RelayNodeID + decision.EffectiveHops = effectiveRoutePathWithReplacement(route.Hops, lease.PeerNodeID, "", lease.RelayNodeID) + decision.SelectedRelayID = lease.RelayNodeID + decision.SelectedRelayEndpoint = lease.RelayEndpoint + decision.RendezvousPeerNodeID = lease.PeerNodeID + decision.RendezvousLeaseID = lease.LeaseID + decision.RendezvousLeaseReason = lease.Reason + decision.DecisionSource = "rendezvous_relay_required" + decision.PathScore = 900 + if lease.Priority > 0 { + decision.PathScore = 1000 - lease.Priority + if decision.PathScore < 1 { + decision.PathScore = 1 + } + } + decision.ScoreReasons = []string{"rendezvous_relay_required", "passive_nat_reverse_path"} } decision.PreviousHopID, decision.NextHopID, decision.LocalRole = routePathLocalPosition(decision.EffectiveHops, localNodeID, decision.SelectedRelayID, decision.StaleRelayNodeID) return decision } +func routePathRendezvousLeaseForRoute(route SyntheticMeshRouteConfig, leases []PeerRendezvousLease) (PeerRendezvousLease, bool) { + var selected PeerRendezvousLease + found := false + for _, lease := range leases { + if strings.TrimSpace(lease.RelayNodeID) == "" || + strings.TrimSpace(lease.PeerNodeID) == "" || + !containsString(lease.RouteIDs, route.RouteID) || + !containsString(route.Hops, lease.PeerNodeID) { + continue + } + if !found || rendezvousLeaseBetterForRoutePath(lease, selected) { + selected = lease + found = true + } + } + return selected, found +} + +func rendezvousLeaseBetterForRoutePath(candidate PeerRendezvousLease, current PeerRendezvousLease) bool { + if candidate.Priority != current.Priority { + if current.Priority <= 0 { + return true + } + if candidate.Priority <= 0 { + return false + } + return candidate.Priority < current.Priority + } + if candidate.RelayNodeID != current.RelayNodeID { + return candidate.RelayNodeID < current.RelayNodeID + } + return candidate.LeaseID < current.LeaseID +} + func effectiveRoutePathWithReplacement(original []string, peerNodeID string, staleRelayNodeID string, selectedRelayID string) []string { out := make([]string, 0, len(original)+1) for _, nodeID := range original { @@ -11802,7 +12493,6 @@ func validatePeerRendezvousLeases(leases []PeerRendezvousLease, routePath []stri relayEndpoint == "" || peerNodeID == relayNodeID || !containsString(routePath, peerNodeID) || - !containsString(routePath, relayNodeID) || (transport != "" && !isPeerRendezvousTransport(transport)) || (!lease.ExpiresAt.IsZero() && !lease.ExpiresAt.After(now)) || (len(lease.Metadata) > 0 && !json.Valid(lease.Metadata)) { @@ -11879,24 +12569,50 @@ func scopedRendezvousLeases(leases []PeerRendezvousLease, route SyntheticMeshRou out := make([]PeerRendezvousLease, 0, len(normalized)) for _, lease := range normalized { if feedback, stale := relayPolicy.staleForLease(route.RouteID, lease); stale { - relayPolicy.recordWithdrawal(route, lease, feedback) - continue + if rendezvousLeaseStaleFeedbackCanWithdraw(lease) || relayPolicy.hasReplacementForLease(route.RouteID, lease) { + relayPolicy.recordWithdrawal(route, lease, feedback) + continue + } } - if containsString(route.Hops, lease.PeerNodeID) && containsString(route.Hops, lease.RelayNodeID) { + if containsString(route.Hops, lease.PeerNodeID) { out = append(out, lease) } } return out } -func derivedRendezvousLeases(route SyntheticMeshRouteConfig, peers map[string]string, candidates map[string][]PeerEndpointCandidate, localNodeID string, relayPolicy *rendezvousRelayPolicy, now time.Time) []PeerRendezvousLease { +func (p *rendezvousRelayPolicy) hasReplacementForLease(routeID string, lease PeerRendezvousLease) bool { + if p == nil { + return false + } + for _, decision := range p.replacements { + if decision.RouteID == routeID && + decision.PeerNodeID == lease.PeerNodeID && + decision.StaleRelayNodeID == lease.RelayNodeID && + decision.SelectedRelayID != "" && + decision.SelectedRelayID != lease.RelayNodeID { + return true + } + } + return false +} + +func rendezvousLeaseStaleFeedbackCanWithdraw(lease PeerRendezvousLease) bool { + reason := strings.ToLower(strings.TrimSpace(lease.Reason)) + if strings.Contains(reason, "operator") || strings.Contains(reason, "manual") { + return false + } + return true +} + +func derivedRendezvousLeases(route SyntheticMeshRouteConfig, peers map[string]string, candidates map[string][]PeerEndpointCandidate, localNodeID string, localPerspective endpointPerspective, relayPolicy *rendezvousRelayPolicy, now time.Time) []PeerRendezvousLease { if !containsString(route.Hops, localNodeID) { return nil } out := []PeerRendezvousLease{} for peerNodeID, items := range candidates { peerNodeID = strings.TrimSpace(peerNodeID) - if peerNodeID == "" || !containsString(route.Hops, peerNodeID) || !peerEndpointCandidatesRequireRendezvous(items) { + if peerNodeID == "" || !containsString(route.Hops, peerNodeID) || !peerEndpointCandidatesRequireRendezvousForLocal(items, localPerspective) { continue } selection := selectRendezvousRelay(route, peerNodeID, localNodeID, peers, candidates, relayPolicy) @@ -11957,6 +12673,15 @@ func selectRendezvousRelay(route SyntheticMeshRouteConfig, peerNodeID string, lo preferred = append(preferred, routePath[peerIndex+1]) } preferred = append(preferred, routePath...) + extraCandidates := make([]string, 0, len(candidates)) + for nodeID := range candidates { + nodeID = strings.TrimSpace(nodeID) + if nodeID != "" { + extraCandidates = append(extraCandidates, nodeID) + } + } + sort.Strings(extraCandidates) + preferred = append(preferred, extraCandidates...) seen := map[string]struct{}{} relayCandidates := []rendezvousRelaySelection{} for _, relayNodeID := range preferred { @@ -11971,16 +12696,17 @@ func selectRendezvousRelay(route SyntheticMeshRouteConfig, peerNodeID string, lo if _, stale := relayPolicy.relayStale(route.RouteID, peerNodeID, relayNodeID); stale { continue } - endpoint, endpointScore, endpointReasons := relayControlEndpointForNode(relayNodeID, peers, candidates) + endpoint, peerCertSHA256, endpointScore, endpointReasons := relayControlEndpointForNode(relayNodeID, peers, candidates) if endpoint == "" { continue } score, scoreReasons := rendezvousRelayCandidateScore(route.RouteID, routePath, peerIndex, relayNodeID, localNodeID, endpointScore, endpointReasons, relayPolicy) relayCandidates = append(relayCandidates, rendezvousRelaySelection{ - RelayNodeID: relayNodeID, - Endpoint: endpoint, - Score: score, - Reasons: scoreReasons, + RelayNodeID: relayNodeID, + Endpoint: endpoint, + PeerCertSHA256: peerCertSHA256, + Score: score, + Reasons: scoreReasons, }) } if len(relayCandidates) == 0 { @@ -11995,10 +12721,7 @@ func selectRendezvousRelay(route SyntheticMeshRouteConfig, peerNodeID string, lo return relayCandidates[0] } -func relayControlEndpointForNode(nodeID string, peers map[string]string, candidates map[string][]PeerEndpointCandidate) (string, int, []string) { - if endpoint := strings.TrimRight(strings.TrimSpace(peers[nodeID]), "/"); isUsableHTTPControlEndpoint(endpoint) { - return endpoint, 80, []string{"reported_peer_endpoint"} - } +func relayControlEndpointForNode(nodeID string, peers map[string]string, candidates map[string][]PeerEndpointCandidate) (string, string, int, []string) { items := append([]PeerEndpointCandidate{}, candidates[nodeID]...) sort.SliceStable(items, func(i, j int) bool { if items[i].Priority != items[j].Priority { @@ -12011,7 +12734,11 @@ func relayControlEndpointForNode(nodeID string, peers map[string]string, candida continue } endpoint := strings.TrimRight(strings.TrimSpace(candidate.Address), "/") - if isUsableHTTPControlEndpoint(endpoint) { + if isUsableFabricControlEndpoint(endpoint) { + peerCertSHA256 := peerEndpointCandidateTLSCertSHA256(candidate) + if peerCertSHA256 == "" { + peerCertSHA256 = peerEndpointCandidateTLSCertSHA256ForEndpoint(items, endpoint) + } score := 70 reasons := []string{"endpoint_candidate"} if candidate.Priority > 0 { @@ -12029,10 +12756,126 @@ func relayControlEndpointForNode(nodeID string, peers map[string]string, candida score += 10 reasons = append(reasons, "direct") } - return endpoint, score, reasons + if peerCertSHA256 != "" { + score += 15 + reasons = append(reasons, "pinned_relay_cert") + } + return endpoint, peerCertSHA256, score, reasons } } - return "", 0, nil + if endpoint := strings.TrimRight(strings.TrimSpace(peers[nodeID]), "/"); isUsableFabricControlEndpoint(endpoint) { + return endpoint, "", 80, []string{"reported_peer_endpoint"} + } + return "", "", 0, nil +} + +func coreMeshBootstrapRendezvousLeases(clusterID, localNodeID string, candidates map[string][]PeerEndpointCandidate, relayPolicy *rendezvousRelayPolicy, now time.Time) []PeerRendezvousLease { + now = now.UTC() + nodeIDs := make([]string, 0, len(candidates)) + for nodeID := range candidates { + nodeID = strings.TrimSpace(nodeID) + if nodeID != "" { + nodeIDs = append(nodeIDs, nodeID) + } + } + sort.Strings(nodeIDs) + out := []PeerRendezvousLease{} + for _, peerNodeID := range nodeIDs { + items := candidates[peerNodeID] + if !peerEndpointCandidatesRequireRendezvous(items) { + continue + } + selection := selectCoreMeshBootstrapRelay(peerNodeID, localNodeID, candidates, relayPolicy) + if selection.RelayNodeID == "" || selection.Endpoint == "" { + continue + } + lease := PeerRendezvousLease{ + LeaseID: "core-mesh-bootstrap-rv-" + peerNodeID + "-via-" + selection.RelayNodeID, + PeerNodeID: peerNodeID, + RelayNodeID: selection.RelayNodeID, + RelayEndpoint: selection.Endpoint, + Transport: "relay_control", + ConnectivityMode: "relay_required", + RouteIDs: []string{"core-mesh-bootstrap"}, + AllowedChannels: []string{"fabric_control", "route_control"}, + Priority: rendezvousLeasePriority(items), + ControlPlaneOnly: true, + IssuedAt: now, + ExpiresAt: now.Add(5 * time.Minute), + Reason: "farm_mesh_bootstrap_relay", + Metadata: coreMeshBootstrapRendezvousLeaseMetadata(clusterID, selection), + } + if lease.Priority <= 0 { + lease.Priority = 90 + } + out = append(out, lease) + } + return out +} + +func selectCoreMeshBootstrapRelay(peerNodeID, localNodeID string, candidates map[string][]PeerEndpointCandidate, relayPolicy *rendezvousRelayPolicy) rendezvousRelaySelection { + relayNodeIDs := make([]string, 0, len(candidates)) + for relayNodeID := range candidates { + relayNodeID = strings.TrimSpace(relayNodeID) + if relayNodeID != "" && relayNodeID != peerNodeID { + relayNodeIDs = append(relayNodeIDs, relayNodeID) + } + } + sort.Strings(relayNodeIDs) + selections := []rendezvousRelaySelection{} + for _, relayNodeID := range relayNodeIDs { + endpoint, peerCertSHA256, endpointScore, endpointReasons := relayControlEndpointForNode(relayNodeID, nil, candidates) + if endpoint == "" { + continue + } + score := 500 + endpointScore + reasons := append([]string{"farm_bootstrap_relay"}, endpointReasons...) + if relayNodeID == localNodeID { + score += 40 + reasons = append(reasons, "local_entry_relay") + } + linkScore, linkReasons := rendezvousRelayLinkScore(relayNodeID, relayPolicy) + score += linkScore + reasons = append(reasons, linkReasons...) + selections = append(selections, rendezvousRelaySelection{ + RelayNodeID: relayNodeID, + Endpoint: endpoint, + PeerCertSHA256: peerCertSHA256, + Score: score, + Reasons: reasons, + }) + } + if len(selections) == 0 { + return rendezvousRelaySelection{} + } + sort.SliceStable(selections, func(i, j int) bool { + if selections[i].Score != selections[j].Score { + return selections[i].Score > selections[j].Score + } + return selections[i].RelayNodeID < selections[j].RelayNodeID + }) + return selections[0] +} + +func coreMeshBootstrapRendezvousLeaseMetadata(clusterID string, selection rendezvousRelaySelection) json.RawMessage { + payload := map[string]any{ + "cluster_id": strings.TrimSpace(clusterID), + "source": "farm_mesh_bootstrap", + "selected_relay_score": selection.Score, + "selected_relay_score_reasons": selection.Reasons, + "service_workload_traffic": false, + "production_forwarding": false, + "control_service_relay": false, + } + if selection.PeerCertSHA256 != "" { + payload["peer_cert_sha256"] = selection.PeerCertSHA256 + payload["tls_cert_sha256"] = selection.PeerCertSHA256 + } + raw, err := json.Marshal(payload) + if err != nil { + return json.RawMessage(`{"source":"farm_mesh_bootstrap","control_service_relay":false}`) + } + return raw } func rendezvousRelayCandidateScore(routeID string, routePath []string, peerIndex int, relayNodeID string, localNodeID string, endpointScore int, endpointReasons []string, relayPolicy *rendezvousRelayPolicy) (int, []string) { @@ -12183,6 +13026,10 @@ func rendezvousRelayLeaseMetadata(selection rendezvousRelaySelection, replacemen "relay_selection_score_reasons": selection.Reasons, "production_payload_forwarding": false, } + if selection.PeerCertSHA256 != "" { + payload["peer_cert_sha256"] = selection.PeerCertSHA256 + payload["tls_cert_sha256"] = selection.PeerCertSHA256 + } if replacement { payload["replacement_for_stale_relay"] = true } @@ -12193,6 +13040,36 @@ func rendezvousRelayLeaseMetadata(selection rendezvousRelaySelection, replacemen return raw } +func peerEndpointCandidateTLSCertSHA256(candidate PeerEndpointCandidate) string { + var metadata struct { + PeerCertSHA256 string `json:"peer_cert_sha256,omitempty"` + TLSCertSHA256 string `json:"tls_cert_sha256,omitempty"` + } + if len(candidate.Metadata) == 0 || !json.Valid(candidate.Metadata) { + return "" + } + if err := json.Unmarshal(candidate.Metadata, &metadata); err != nil { + return "" + } + return firstNonEmptyString(metadata.PeerCertSHA256, metadata.TLSCertSHA256) +} + +func peerEndpointCandidateTLSCertSHA256ForEndpoint(candidates []PeerEndpointCandidate, endpoint string) string { + endpoint = strings.TrimRight(strings.TrimSpace(endpoint), "/") + if endpoint == "" { + return "" + } + for _, candidate := range candidates { + if strings.TrimRight(strings.TrimSpace(candidate.Address), "/") != endpoint { + continue + } + if certSHA256 := peerEndpointCandidateTLSCertSHA256(candidate); certSHA256 != "" { + return certSHA256 + } + } + return "" +} + func hasPolicyTag(tags []string, want string) bool { want = strings.ToLower(strings.TrimSpace(want)) for _, tag := range tags { @@ -12210,6 +13087,13 @@ func maxInt(a int, b int) int { return b } +func minInt(a int, b int) int { + if a < b { + return a + } + return b +} + func absInt(value int) int { if value < 0 { return -value @@ -12218,8 +13102,12 @@ func absInt(value int) int { } func peerEndpointCandidatesRequireRendezvous(candidates []PeerEndpointCandidate) bool { + return peerEndpointCandidatesRequireRendezvousForLocal(candidates, endpointPerspective{}) +} + +func peerEndpointCandidatesRequireRendezvousForLocal(candidates []PeerEndpointCandidate, local endpointPerspective) bool { for _, candidate := range candidates { - if endpointCandidateRequiresRendezvous(candidate) { + if endpointCandidateRequiresRendezvous(candidate) || endpointCandidatePrivateForLocalOffsite(local, candidate) { return true } } @@ -12472,7 +13360,7 @@ func scopedPeerEndpointCandidates(candidates map[string][]PeerEndpointCandidate, func isPeerEndpointTransport(value string) bool { switch value { - case "direct_http", "direct_tcp_tls", "wss", "relay", "outbound_reverse": + case "direct_quic", "relay_quic", "direct_http", "direct_tcp_tls", "wss", "relay", "outbound_reverse": return true default: return false @@ -12481,7 +13369,7 @@ func isPeerEndpointTransport(value string) bool { func isPeerRendezvousTransport(value string) bool { switch value { - case "relay_control", "relay", "wss", "direct_tcp_tls": + case "relay_control", "relay_quic", "relay", "direct_quic", "wss", "direct_tcp_tls": return true default: return false @@ -12534,6 +13422,14 @@ func isHTTPControlEndpoint(endpoint string) bool { return strings.HasPrefix(endpoint, "http://") || strings.HasPrefix(endpoint, "https://") } +func isQUICControlEndpoint(endpoint string) bool { + return strings.HasPrefix(strings.ToLower(strings.TrimSpace(endpoint)), "quic://") +} + +func isUsableFabricControlEndpoint(endpoint string) bool { + return (isQUICControlEndpoint(endpoint) || isHTTPControlEndpoint(endpoint)) && !isUnusableLocalPeerEndpoint(endpoint) +} + func isUsableHTTPControlEndpoint(endpoint string) bool { return isHTTPControlEndpoint(endpoint) && !isUnusableLocalPeerEndpoint(endpoint) } diff --git a/backend/internal/modules/cluster/service_test.go b/backend/internal/modules/cluster/service_test.go index 5343296..114cda5 100644 --- a/backend/internal/modules/cluster/service_test.go +++ b/backend/internal/modules/cluster/service_test.go @@ -347,6 +347,71 @@ func TestAssignNodeRoleRejectsUnknownRole(t *testing.T) { } } +func TestAssignNodeRoleAllowsWebAdminPlacementRoles(t *testing.T) { + roles := []string{ + "public-ingress", + "admin-ingress", + "global-admin-runtime", + "cluster-admin-runtime", + "organization-portal-runtime", + "user-portal-runtime", + "identity-runtime", + "policy-authority", + "audit-sink", + } + for _, role := range roles { + t.Run(role, func(t *testing.T) { + store := &fakeRepository{platformRole: PlatformRoleAdmin} + service := NewService(store) + + item, err := service.AssignNodeRole(context.Background(), AssignNodeRoleInput{ + ActorUserID: "admin-1", + ClusterID: "cluster-1", + NodeID: "node-1", + Role: role, + }) + if err != nil { + t.Fatalf("assign role: %v", err) + } + if item.Role != role { + t.Fatalf("role = %q, want %q", item.Role, role) + } + }) + } +} + +func TestFabricAdminServiceClassesAreScopedToAdminRoles(t *testing.T) { + cases := []struct { + serviceClass string + requiredRole string + pathNeedle string + }{ + {FabricServiceClassPlatformAdmin, "global-admin-runtime", "platform-admin"}, + {FabricServiceClassClusterAdmin, "cluster-admin-runtime", "cluster-admin"}, + {FabricServiceClassOrganization, "organization-portal-runtime", "organizations"}, + {FabricServiceClassUserPortal, "user-portal-runtime", "users"}, + } + for _, tc := range cases { + t.Run(tc.serviceClass, func(t *testing.T) { + if !isAllowedFabricServiceClass(tc.serviceClass) { + t.Fatalf("service class %q is not allowed", tc.serviceClass) + } + roles := normalizeFabricRequiredRoles(nil, tc.serviceClass) + if !containsString(roles, tc.requiredRole) || !containsString(roles, "identity-runtime") || !containsString(roles, "policy-authority") { + t.Fatalf("required roles = %+v", roles) + } + channels := normalizeFabricServiceChannels(nil, tc.serviceClass) + if !containsString(channels, FabricChannelControl) || !containsString(channels, FabricChannelInteractive) || !containsString(channels, FabricChannelReliable) { + t.Fatalf("channels = %+v", channels) + } + ingress := fabricServiceChannelHTTPIngress(tc.serviceClass) + if !strings.Contains(ingress.PathTemplate, tc.pathNeedle) { + t.Fatalf("path = %q, want %q", ingress.PathTemplate, tc.pathNeedle) + } + }) + } +} + func TestAttachExistingNodeRequiresPlatformAdmin(t *testing.T) { store := &fakeRepository{platformRole: "user"} service := NewService(store) @@ -567,6 +632,70 @@ func TestApproveJoinRequestReturnsBootstrapContract(t *testing.T) { } } +func TestApproveJoinRequestReturnsSignedQuorumDescriptor(t *testing.T) { + keys, err := clusterauth.GenerateKeyPair() + if err != nil { + t.Fatalf("generate key: %v", err) + } + quorum := &QuorumDescriptor{ + SchemaVersion: clusterauth.QuorumSchemaVersion, + ClusterID: "cluster-1", + Epoch: "epoch-1", + Threshold: 1, + Members: []clusterauth.QuorumMember{ + { + NodeID: "authority-1", + Role: "update-authority", + PublicKey: keys.PublicKeyB64, + PublicKeyFingerprint: keys.Fingerprint, + Scopes: []string{"update-authority"}, + }, + }, + } + store := &fakeRepository{ + platformRole: PlatformRoleAdmin, + clusterAuthority: ClusterAuthorityKey{ + ClusterAuthorityDescriptor: ClusterAuthorityDescriptor{ + SchemaVersion: clusterauth.AuthoritySchemaVersion, + ClusterID: "cluster-1", + AuthorityState: "active", + KeyAlgorithm: clusterauth.AlgorithmEd25519, + PublicKey: keys.PublicKeyB64, + PublicKeyFingerprint: keys.Fingerprint, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }, + PrivateKey: keys.PrivateKeyB64, + QuorumDescriptor: quorum, + }, + } + service := NewService(store) + + approved, err := service.ApproveJoinRequest(context.Background(), ApproveJoinRequestInput{ + ActorUserID: "admin-1", + ClusterID: "cluster-1", + JoinRequestID: "join-request-1", + NodeKey: "node-key-1", + }) + if err != nil { + t.Fatalf("approve join request: %v", err) + } + if approved.Bootstrap.ClusterAuthorityQuorum == nil { + t.Fatalf("bootstrap missing quorum descriptor: %+v", approved.Bootstrap) + } + var payload clusterNodeApprovalAuthorityPayload + if err := json.Unmarshal(approved.Bootstrap.AuthorityPayload, &payload); err != nil { + t.Fatalf("decode authority payload: %v", err) + } + quorumHash, err := clusterauth.QuorumDescriptorHash(*quorum) + if err != nil { + t.Fatalf("hash quorum: %v", err) + } + if payload.ClusterAuthorityQuorumSHA256 != quorumHash { + t.Fatalf("quorum hash = %q, want %q", payload.ClusterAuthorityQuorumSHA256, quorumHash) + } +} + func TestGetJoinRequestBootstrapReturnsSignedApproval(t *testing.T) { nodeID := "node-1" store := &fakeRepository{ @@ -694,7 +823,8 @@ func TestGetVPNClientProfileEnsuresFabricVPNPacketRouteIntents(t *testing.T) { vpnClientProfile: VPNClientProfile{ SchemaVersion: "rap.vpn_client_profile.v1", Connections: []VPNClientConnection{{ - ID: "vpn-1", + ID: "vpn-1", + TargetEndpoint: json.RawMessage(`{"type":"fabric_ipv4_exit_pool","exit_pool_ids":["home-ipv4"]}`), ClientConfig: json.RawMessage(`{ "vpn_fabric_route": { "status": "planned", @@ -735,6 +865,34 @@ func TestGetVPNClientProfileEnsuresFabricVPNPacketRouteIntents(t *testing.T) { if session["preferred_transport"] != "fabric_service_channel_v1" || session["fallback_transport"] != "none" || session["backend_relay_allowed"] != false { t.Fatalf("unexpected dataplane session transports: %#v", session) } + request, ok := session["fabric_service_channel_request"].(map[string]any) + if !ok { + t.Fatalf("missing fabric service channel request in %#v", session) + } + if request["service_class"] != "vpn_packets" || request["source_role"] != "vpn-client" { + t.Fatalf("unexpected fabric service channel request: %#v", request) + } + target := request["target"].(map[string]any) + poolIDs := target["pool_ids"].([]any) + if target["kind"] != "pool" || target["service_role"] != "ipv4-egress" || len(poolIDs) != 1 || poolIDs[0] != "home-ipv4" { + t.Fatalf("unexpected fabric service channel target: %#v", target) + } + adapter := request["adapter_contract"].(map[string]any) + if adapter["adapter_may_select_endpoint"] != false || adapter["adapter_may_use_legacy_relay"] != false { + t.Fatalf("vpn adapter must not own transport decisions: %#v", adapter) + } + routeBundle, ok := session["fabric_route_bundle"].(map[string]any) + if !ok || routeBundle["legacy_visibility"] != "opaque_to_service_adapters" { + t.Fatalf("missing opaque route bundle: %#v", session["fabric_route_bundle"]) + } + routeLease, ok := routeBundle["route_lease"].(map[string]any) + if !ok || routeLease["schema_version"] != "rap.fabric_route_lease.v1" || routeLease["service_visibility"] != "opaque_route_lease" { + t.Fatalf("missing route lease: %#v", routeBundle["route_lease"]) + } + rebuildPolicy := routeLease["rebuild_policy"].(map[string]any) + if rebuildPolicy["owner"] != "fabric_farm" || rebuildPolicy["service_adapter_action"] != "keep_sending_packets_to_channel" { + t.Fatalf("unexpected route lease rebuild policy: %#v", rebuildPolicy) + } if session["entry_node_id"] != "entry-1" || session["exit_node_id"] != "exit-1" { t.Fatalf("unexpected dataplane session route: %#v", session) } @@ -920,6 +1078,88 @@ func TestNodeUpdatePlanSelectsMatchingReleaseArtifact(t *testing.T) { } } +func TestNodeUpdatePlanIncludesQuorumAuthorityWhenConfigured(t *testing.T) { + keys, err := clusterauth.GenerateKeyPair() + if err != nil { + t.Fatalf("generate key: %v", err) + } + store := &fakeRepository{ + platformRole: PlatformRoleAdmin, + clusterAuthority: ClusterAuthorityKey{ + ClusterAuthorityDescriptor: ClusterAuthorityDescriptor{ + SchemaVersion: clusterauth.AuthoritySchemaVersion, + ClusterID: "cluster-1", + AuthorityState: "active", + KeyAlgorithm: clusterauth.AlgorithmEd25519, + PublicKey: keys.PublicKeyB64, + PublicKeyFingerprint: keys.Fingerprint, + CreatedAt: time.Now().UTC(), + UpdatedAt: time.Now().UTC(), + }, + PrivateKey: keys.PrivateKeyB64, + QuorumDescriptor: &QuorumDescriptor{ + SchemaVersion: clusterauth.QuorumSchemaVersion, + ClusterID: "cluster-1", + Epoch: "epoch-1", + Threshold: 1, + Members: []clusterauth.QuorumMember{ + { + NodeID: "authority-1", + Role: "update-authority", + PublicKey: keys.PublicKeyB64, + PublicKeyFingerprint: keys.Fingerprint, + Scopes: []string{"update-authority"}, + }, + }, + }, + }, + releaseVersions: []ReleaseVersion{ + { + ID: "release-1", + ClusterID: "cluster-1", + Product: "rap-node-agent", + Version: "0.1.0-c17z26", + Channel: "dev", + Status: "active", + Artifacts: []ReleaseArtifact{ + {ID: "docker", ClusterID: "cluster-1", Product: "rap-node-agent", Version: "0.1.0-c17z26", OS: "linux", Arch: "amd64", InstallType: "docker", Kind: "docker_image_tar", URL: "https://cache/agent.tar", SHA256: "docker-sha"}, + }, + }, + }, + nodeUpdatePolicies: map[string]NodeUpdatePolicy{ + "node-1|rap-node-agent": { + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-node-agent", + Channel: "dev", + Strategy: "manual", + Enabled: true, + RollbackAllowed: true, + }, + }, + } + service := NewService(store) + + plan, err := service.GetNodeUpdatePlan(context.Background(), GetNodeUpdatePlanInput{ + ClusterID: "cluster-1", + NodeID: "node-1", + Product: "rap-node-agent", + CurrentVersion: "0.1.0-c17z25", + OS: "linux", + Arch: "amd64", + InstallType: "docker", + }) + if err != nil { + t.Fatalf("update plan: %v", err) + } + if plan.AuthorityQuorum == nil { + t.Fatalf("update plan must include quorum envelope: %+v", plan) + } + if err := clusterauth.VerifyQuorumRaw(*store.clusterAuthority.QuorumDescriptor, plan.AuthorityPayload, *plan.AuthorityQuorum, "update-authority"); err != nil { + t.Fatalf("verify quorum authority: %v", err) + } +} + func TestNodeUpdatePlanAbsolutizesRelativeArtifactURLs(t *testing.T) { store := &fakeRepository{ platformRole: PlatformRoleAdmin, @@ -1914,6 +2154,7 @@ func TestGetNodeSyntheticMeshConfigScopesPrivateBootstrapPeersForOutboundOnlyNod clusterNodes: []ClusterNode{ {ID: "node-local", RegistrationStatus: NodeRegistrationActive, HealthStatus: "healthy", MembershipStatus: "active", CreatedAt: now.Add(-2 * time.Hour), LastSeenAt: ptrTime(now)}, {ID: "node-peer", RegistrationStatus: NodeRegistrationActive, HealthStatus: "healthy", MembershipStatus: "active", CreatedAt: now.Add(-time.Hour), LastSeenAt: ptrTime(now.Add(-time.Second))}, + {ID: "node-relay", RegistrationStatus: NodeRegistrationActive, HealthStatus: "healthy", MembershipStatus: "active", CreatedAt: now.Add(-90 * time.Minute), LastSeenAt: ptrTime(now.Add(-2 * time.Second))}, }, nodeRoles: map[string][]NodeRoleAssignment{ "node-local": {{NodeID: "node-local", Role: "core-mesh", Status: "active"}}, @@ -1954,8 +2195,8 @@ func TestGetNodeSyntheticMeshConfigScopesPrivateBootstrapPeersForOutboundOnlyNod "endpoint_candidates": [{ "endpoint_id": "node-peer-lan", "node_id": "node-peer", - "transport": "direct_http", - "address": "http://192.168.200.61:19133", + "transport": "direct_quic", + "address": "quic://192.168.200.61:19133", "reachability": "private", "connectivity_mode": "private_lan", "priority": 1 @@ -1963,6 +2204,30 @@ func TestGetNodeSyntheticMeshConfigScopesPrivateBootstrapPeersForOutboundOnlyNod } }`), }}, + "node-relay": {{ + ClusterID: "cluster-1", + NodeID: "node-relay", + ObservedAt: now, + Metadata: json.RawMessage(`{ + "mesh_endpoint_report": { + "cluster_id": "cluster-1", + "node_id": "node-relay", + "peer_endpoint": "quic://relay.example.test:19131", + "transport": "direct_quic", + "connectivity_mode": "direct", + "region": "public", + "endpoint_candidates": [{ + "endpoint_id": "node-relay-public", + "node_id": "node-relay", + "transport": "direct_quic", + "address": "quic://relay.example.test:19131", + "reachability": "public", + "connectivity_mode": "direct", + "priority": 1 + }] + } + }`), + }}, }, }) service.now = func() time.Time { return now } @@ -1982,7 +2247,7 @@ func TestGetNodeSyntheticMeshConfigScopesPrivateBootstrapPeersForOutboundOnlyNod t.Fatalf("peer candidates = %+v, want relay-required candidate", cfg.PeerEndpointCandidates) } candidate := candidates[0] - if candidate.Transport != "relay" || candidate.Reachability != "relay" || candidate.ConnectivityMode != "relay_required" { + if candidate.Transport != "relay_quic" || candidate.Reachability != "relay" || candidate.ConnectivityMode != "relay_required" { t.Fatalf("candidate not converted to relay required: %+v", candidate) } if !containsString(candidate.PolicyTags, "offsite-private-lan-blocked") || !containsString(candidate.PolicyTags, "relay-required") { @@ -2002,10 +2267,10 @@ func TestGetNodeSyntheticMeshConfigScopesPrivateBootstrapPeersForOutboundOnlyNod } lease := cfg.RendezvousLeases[0] if lease.PeerNodeID != "node-peer" || - lease.RelayNodeID != "control-plane-relay" || - lease.RelayEndpoint != "https://control.example.test" || + lease.RelayNodeID != "node-relay" || + lease.RelayEndpoint != "quic://relay.example.test:19131" || lease.Transport != "relay_control" || - lease.Reason != "control_plane_bootstrap_relay" || + lease.Reason != "farm_mesh_bootstrap_relay" || !lease.ControlPlaneOnly { t.Fatalf("unexpected bootstrap rendezvous lease: %+v", lease) } @@ -2395,6 +2660,206 @@ func TestGetNodeSyntheticMeshConfigAppliesReplacementPathHintForExit(t *testing. } } +func TestRoutePathDecisionUsesRendezvousLeaseForPassiveNATRoute(t *testing.T) { + now := time.Date(2026, 5, 17, 3, 45, 0, 0, time.UTC) + route := SyntheticMeshRouteConfig{ + RouteID: "route-a-b", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-b", + Hops: []string{"node-a", "node-b"}, + AllowedChannels: []string{"fabric_control", "route_control"}, + ExpiresAt: now.Add(time.Hour), + } + decision := routePathDecisionForRoute(route, "node-a", []PeerRendezvousLease{{ + LeaseID: "route-a-b-rv-node-b-via-node-r", + PeerNodeID: "node-b", + RelayNodeID: "node-r", + RelayEndpoint: "quic://node-r.example.test:19443", + Transport: "relay_control", + ConnectivityMode: "relay_required", + RouteIDs: []string{"route-a-b"}, + Priority: 10, + ControlPlaneOnly: true, + IssuedAt: now, + ExpiresAt: now.Add(time.Hour), + Reason: "auto_rendezvous_required", + }}, newRendezvousRelayPolicy("node-a", nil, now), "generation-1", fabricServiceChannelRouteFeedback{}) + + if decision.DecisionSource != "rendezvous_relay_required" || + decision.SelectedRelayID != "node-r" || + decision.SelectedRelayEndpoint != "quic://node-r.example.test:19443" || + decision.RendezvousPeerNodeID != "node-b" || + decision.RendezvousLeaseID != "route-a-b-rv-node-b-via-node-r" || + decision.RendezvousLeaseReason != "auto_rendezvous_required" || + decision.NextHopID != "node-r" || + decision.LocalRole != "entry" || + strings.Join(decision.EffectiveHops, ",") != "node-a,node-r,node-b" || + !decision.ControlPlaneOnly || + decision.ProductionForwarding { + t.Fatalf("unexpected rendezvous route path decision: %+v", decision) + } +} + +func TestScopedRendezvousLeasesKeepsOperatorPassiveNATLeaseWhenRelayFeedbackIsStale(t *testing.T) { + now := time.Date(2026, 5, 17, 5, 15, 0, 0, time.UTC) + route := SyntheticMeshRouteConfig{ + RouteID: "route-a-b", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-b", + Hops: []string{"node-a", "node-b"}, + AllowedChannels: []string{"fabric_control", "route_control"}, + ExpiresAt: now.Add(time.Hour), + } + lease := PeerRendezvousLease{ + LeaseID: "route-a-b-rv-node-b-via-node-r", + PeerNodeID: "node-b", + RelayNodeID: "node-r", + RelayEndpoint: "quic://node-r.example.test:19443", + Transport: "relay_control", + ConnectivityMode: "relay_required", + RouteIDs: []string{"route-a-b"}, + Priority: 10, + ControlPlaneOnly: true, + IssuedAt: now, + ExpiresAt: now.Add(time.Hour), + Reason: "operator_rendezvous_required_for_passive_nat", + } + relayPolicy := newRendezvousRelayPolicy("node-a", nil, now) + relayPolicy.addFeedback([]rendezvousRelayFeedbackEntry{{ + RouteIDs: []string{"route-a-b"}, + PeerNodeID: "node-b", + RelayNodeID: "node-r", + LeaseID: "route-a-b-rv-node-b-via-node-r", + ReporterNodeID: "node-a", + }}) + + leases := scopedRendezvousLeases([]PeerRendezvousLease{lease}, route, "node-a", relayPolicy, now) + if len(leases) != 1 || leases[0].LeaseID != lease.LeaseID { + t.Fatalf("operator passive NAT lease must remain scoped despite stale feedback: %+v", leases) + } + if report := relayPolicy.report(); report != nil && report.WithdrawnLeaseCount != 0 { + t.Fatalf("operator passive NAT lease must not be withdrawn: %+v", report) + } +} + +func TestDerivedRendezvousLeaseCanSelectRelayOutsideOriginalPath(t *testing.T) { + now := time.Date(2026, 5, 17, 4, 30, 0, 0, time.UTC) + route := SyntheticMeshRouteConfig{ + RouteID: "route-a-b", + ClusterID: "cluster-1", + SourceNodeID: "node-a", + DestinationNodeID: "node-b", + Hops: []string{"node-a", "node-b"}, + AllowedChannels: []string{"fabric_control", "route_control"}, + ExpiresAt: now.Add(time.Hour), + } + leases := derivedRendezvousLeases(route, map[string]string{}, map[string][]PeerEndpointCandidate{ + "node-b": { + { + EndpointID: "node-b-private", + NodeID: "node-b", + Transport: "direct_quic", + Address: "quic://10.10.10.20:19131", + Reachability: "private", + ConnectivityMode: "private_lan", + Region: "remote-lan", + Priority: 5, + LastVerifiedAt: &now, + }, + }, + "node-r": { + { + EndpointID: "node-r-public", + NodeID: "node-r", + Transport: "direct_quic", + Address: "quic://203.0.113.10:19131", + Reachability: "public", + ConnectivityMode: "direct", + Region: "internet", + Priority: 10, + PolicyTags: []string{"fast-path"}, + LastVerifiedAt: &now, + }, + }, + }, "node-a", endpointPerspective{Region: "home-lan"}, newRendezvousRelayPolicy("node-a", nil, now), now) + + if len(leases) != 1 || + leases[0].PeerNodeID != "node-b" || + leases[0].RelayNodeID != "node-r" || + leases[0].RelayEndpoint != "quic://203.0.113.10:19131" || + leases[0].Reason != "auto_rendezvous_required" { + t.Fatalf("unexpected derived rendezvous leases: %+v", leases) + } +} + +func TestGetNodeSyntheticMeshConfigIncludesRendezvousRelayOutsideOriginalHops(t *testing.T) { + now := time.Date(2026, 5, 17, 4, 15, 0, 0, time.UTC) + service := NewService(&fakeRepository{ + testingFlags: EffectiveNodeTestingFlags{ + Enabled: true, + SyntheticLinksEnabled: true, + }, + routeIntents: []MeshRouteIntent{ + { + ID: "route-a-b", + ClusterID: "cluster-1", + SourceSelector: json.RawMessage(`{"node_id":"node-a"}`), + DestinationSelector: json.RawMessage(`{"node_id":"node-b"}`), + ServiceClass: "vpn_packets", + Status: "active", + Policy: json.RawMessage(`{ + "synthetic_enabled": true, + "hops": ["node-a", "node-b"], + "allowed_channels": ["fabric_control", "route_control"], + "expires_at": "2026-05-17T05:15:00Z", + "rendezvous_leases": [ + { + "lease_id": "route-a-b-rv-node-b-via-node-r", + "peer_node_id": "node-b", + "relay_node_id": "node-r", + "relay_endpoint": "quic://node-r.example.test:19443", + "transport": "relay_control", + "connectivity_mode": "relay_required", + "route_ids": ["route-a-b"], + "allowed_channels": ["fabric_control", "route_control"], + "priority": 10, + "control_plane_only": true, + "expires_at": "2026-05-17T05:15:00Z", + "reason": "auto_rendezvous_required" + } + ] + }`), + UpdatedAt: now, + }, + }, + }) + service.now = func() time.Time { return now } + + cfg, err := service.GetNodeSyntheticMeshConfig(context.Background(), GetNodeSyntheticMeshConfigInput{ + ClusterID: "cluster-1", + NodeID: "node-r", + }) + if err != nil { + t.Fatalf("get synthetic config: %v", err) + } + if len(cfg.Routes) != 1 || strings.Join(cfg.Routes[0].Hops, ",") != "node-a,node-r,node-b" { + t.Fatalf("relay scoped route missing effective hops: %+v", cfg.Routes) + } + if cfg.RoutePathDecisions == nil || len(cfg.RoutePathDecisions.Decisions) != 1 { + t.Fatalf("relay route path decision missing: %+v", cfg.RoutePathDecisions) + } + decision := cfg.RoutePathDecisions.Decisions[0] + if decision.SelectedRelayID != "node-r" || + decision.LocalRole != "selected_relay" || + decision.PreviousHopID != "node-a" || + decision.NextHopID != "node-b" || + strings.Join(decision.EffectiveHops, ",") != "node-a,node-r,node-b" { + t.Fatalf("unexpected relay scoped decision: %+v", decision) + } +} + func TestGetNodeSyntheticMeshConfigUsesRouteHealthDriftToReselectRelay(t *testing.T) { now := time.Date(2026, 4, 28, 12, 30, 0, 0, time.UTC) routeHealthMetadata, err := json.Marshal(map[string]any{ diff --git a/backend/internal/platform/clusterauth/authority.go b/backend/internal/platform/clusterauth/authority.go index 9992a9f..7bbb720 100644 --- a/backend/internal/platform/clusterauth/authority.go +++ b/backend/internal/platform/clusterauth/authority.go @@ -16,6 +16,8 @@ import ( const ( AuthoritySchemaVersion = "rap.cluster_authority.v1" SignatureSchemaVersion = "rap.cluster_authority.signature.v1" + QuorumSchemaVersion = "rap.cluster_authority.quorum.v1" + QuorumEnvelopeVersion = "rap.cluster_authority.quorum_envelope.v1" AlgorithmEd25519 = "ed25519" ) @@ -39,6 +41,34 @@ type Signature struct { SignedAt time.Time `json:"signed_at"` } +type QuorumMember struct { + NodeID string `json:"node_id,omitempty"` + Role string `json:"role,omitempty"` + PublicKey string `json:"public_key"` + PublicKeyFingerprint string `json:"public_key_fingerprint"` + Scopes []string `json:"scopes,omitempty"` +} + +type QuorumDescriptor struct { + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + Epoch string `json:"epoch"` + Threshold int `json:"threshold"` + Members []QuorumMember `json:"members"` +} + +type QuorumEnvelope struct { + SchemaVersion string `json:"schema_version"` + ClusterID string `json:"cluster_id"` + Epoch string `json:"epoch"` + Threshold int `json:"threshold"` + PayloadSHA256 string `json:"payload_sha256"` + QuorumSHA256 string `json:"quorum_sha256"` + Signatures []Signature `json:"signatures"` + AllowedScopes []string `json:"allowed_scopes,omitempty"` + DecisionReason string `json:"decision_reason,omitempty"` +} + func GenerateKeyPair() (KeyPair, error) { publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader) if err != nil { @@ -128,6 +158,82 @@ func VerifyRaw(publicKeyB64 string, payload json.RawMessage, signature Signature return nil } +func VerifyQuorumRaw(descriptor QuorumDescriptor, payload json.RawMessage, envelope QuorumEnvelope, requiredScope string) error { + if descriptor.SchemaVersion != QuorumSchemaVersion { + return fmt.Errorf("%w: quorum schema_version must be %s", ErrInvalidSignature, QuorumSchemaVersion) + } + if envelope.SchemaVersion != QuorumEnvelopeVersion { + return fmt.Errorf("%w: quorum envelope schema_version must be %s", ErrInvalidSignature, QuorumEnvelopeVersion) + } + if strings.TrimSpace(descriptor.ClusterID) == "" || descriptor.ClusterID != envelope.ClusterID { + return fmt.Errorf("%w: quorum cluster mismatch", ErrInvalidSignature) + } + if strings.TrimSpace(descriptor.Epoch) == "" || descriptor.Epoch != envelope.Epoch { + return fmt.Errorf("%w: quorum epoch mismatch", ErrInvalidSignature) + } + threshold := descriptor.Threshold + if envelope.Threshold > threshold { + threshold = envelope.Threshold + } + if threshold <= 0 || threshold > len(descriptor.Members) { + return fmt.Errorf("%w: invalid quorum threshold", ErrInvalidSignature) + } + payloadHash, err := HashRaw(payload) + if err != nil { + return err + } + if envelope.PayloadSHA256 != payloadHash { + return fmt.Errorf("%w: quorum payload hash mismatch", ErrInvalidSignature) + } + descriptorHash, err := QuorumDescriptorHash(descriptor) + if err != nil { + return err + } + if envelope.QuorumSHA256 != descriptorHash { + return fmt.Errorf("%w: quorum descriptor hash mismatch", ErrInvalidSignature) + } + members := map[string]QuorumMember{} + for _, member := range descriptor.Members { + fingerprint := strings.TrimSpace(member.PublicKeyFingerprint) + if fingerprint == "" { + publicKey, err := DecodePublicKey(member.PublicKey) + if err != nil { + return err + } + fingerprint = Fingerprint(publicKey) + } + if _, exists := members[fingerprint]; exists { + return fmt.Errorf("%w: duplicate quorum member", ErrInvalidSignature) + } + member.PublicKeyFingerprint = fingerprint + members[fingerprint] = member + } + seen := map[string]bool{} + valid := 0 + for _, signature := range envelope.Signatures { + fingerprint := strings.TrimSpace(signature.KeyFingerprint) + if seen[fingerprint] { + continue + } + member, ok := members[fingerprint] + if !ok { + return fmt.Errorf("%w: quorum signer is not a member", ErrInvalidSignature) + } + if requiredScope != "" && !memberAllowsScope(member, requiredScope) { + return fmt.Errorf("%w: quorum signer scope mismatch", ErrInvalidSignature) + } + if err := VerifyRaw(member.PublicKey, payload, signature); err != nil { + return err + } + seen[fingerprint] = true + valid++ + } + if valid < threshold { + return fmt.Errorf("%w: quorum threshold not met", ErrInvalidSignature) + } + return nil +} + func CanonicalJSON(raw json.RawMessage) ([]byte, error) { if len(raw) == 0 { return nil, fmt.Errorf("%w: empty payload", ErrInvalidPayload) @@ -152,6 +258,28 @@ func HashRaw(raw json.RawMessage) (string, error) { return hex.EncodeToString(sum[:]), nil } +func QuorumDescriptorHash(descriptor QuorumDescriptor) (string, error) { + raw, err := json.Marshal(descriptor) + if err != nil { + return "", err + } + return HashRaw(raw) +} + +func memberAllowsScope(member QuorumMember, requiredScope string) bool { + requiredScope = strings.TrimSpace(requiredScope) + if requiredScope == "" { + return true + } + for _, scope := range member.Scopes { + scope = strings.TrimSpace(scope) + if scope == "*" || scope == requiredScope { + return true + } + } + return false +} + func DecodePublicKey(value string) (ed25519.PublicKey, error) { decoded, err := decodeBase64(strings.TrimSpace(value)) if err != nil { diff --git a/backend/internal/platform/clusterauth/authority_test.go b/backend/internal/platform/clusterauth/authority_test.go index 5f68ddf..0cade7f 100644 --- a/backend/internal/platform/clusterauth/authority_test.go +++ b/backend/internal/platform/clusterauth/authority_test.go @@ -3,6 +3,7 @@ package clusterauth import ( "encoding/json" "errors" + "fmt" "testing" "time" ) @@ -42,3 +43,85 @@ func TestVerifyRawRejectsTamperedPayload(t *testing.T) { t.Fatalf("err = %v, want ErrInvalidSignature", err) } } + +func TestVerifyQuorumRawAcceptsThreshold(t *testing.T) { + payload := json.RawMessage(`{"cluster_id":"cluster-1","schema_version":"rap.node_update_plan_authority.v1","action":"update"}`) + descriptor, keys := testQuorum(t, 3, 2) + payloadHash, err := HashRaw(payload) + if err != nil { + t.Fatalf("HashRaw: %v", err) + } + quorumHash, err := QuorumDescriptorHash(descriptor) + if err != nil { + t.Fatalf("QuorumDescriptorHash: %v", err) + } + signatureA, err := SignRaw(keys[0].PrivateKeyB64, payload, time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("SignRaw A: %v", err) + } + signatureB, err := SignRaw(keys[1].PrivateKeyB64, payload, time.Date(2026, 5, 16, 12, 0, 1, 0, time.UTC)) + if err != nil { + t.Fatalf("SignRaw B: %v", err) + } + envelope := QuorumEnvelope{ + SchemaVersion: QuorumEnvelopeVersion, + ClusterID: "cluster-1", + Epoch: "epoch-1", + Threshold: 2, + PayloadSHA256: payloadHash, + QuorumSHA256: quorumHash, + Signatures: []Signature{signatureA, signatureB}, + } + if err := VerifyQuorumRaw(descriptor, payload, envelope, "update-authority"); err != nil { + t.Fatalf("VerifyQuorumRaw: %v", err) + } +} + +func TestVerifyQuorumRawRejectsBelowThreshold(t *testing.T) { + payload := json.RawMessage(`{"cluster_id":"cluster-1","schema_version":"rap.node_update_plan_authority.v1","action":"update"}`) + descriptor, keys := testQuorum(t, 3, 2) + payloadHash, _ := HashRaw(payload) + quorumHash, _ := QuorumDescriptorHash(descriptor) + signature, err := SignRaw(keys[0].PrivateKeyB64, payload, time.Date(2026, 5, 16, 12, 0, 0, 0, time.UTC)) + if err != nil { + t.Fatalf("SignRaw: %v", err) + } + envelope := QuorumEnvelope{ + SchemaVersion: QuorumEnvelopeVersion, + ClusterID: "cluster-1", + Epoch: "epoch-1", + Threshold: 2, + PayloadSHA256: payloadHash, + QuorumSHA256: quorumHash, + Signatures: []Signature{signature}, + } + if err := VerifyQuorumRaw(descriptor, payload, envelope, "update-authority"); !errors.Is(err, ErrInvalidSignature) { + t.Fatalf("err = %v, want ErrInvalidSignature", err) + } +} + +func testQuorum(t *testing.T, count int, threshold int) (QuorumDescriptor, []KeyPair) { + t.Helper() + descriptor := QuorumDescriptor{ + SchemaVersion: QuorumSchemaVersion, + ClusterID: "cluster-1", + Epoch: "epoch-1", + Threshold: threshold, + } + keys := make([]KeyPair, 0, count) + for i := 0; i < count; i++ { + keyPair, err := GenerateKeyPair() + if err != nil { + t.Fatalf("GenerateKeyPair: %v", err) + } + descriptor.Members = append(descriptor.Members, QuorumMember{ + NodeID: fmt.Sprintf("authority-%d", i+1), + Role: "update-authority", + PublicKey: keyPair.PublicKeyB64, + PublicKeyFingerprint: keyPair.Fingerprint, + Scopes: []string{"update-authority"}, + }) + keys = append(keys, keyPair) + } + return descriptor, keys +} diff --git a/backend/migrations/000031_fabric_vpn_node_roles.down.sql b/backend/migrations/000031_fabric_vpn_node_roles.down.sql new file mode 100644 index 0000000..22b237d --- /dev/null +++ b/backend/migrations/000031_fabric_vpn_node_roles.down.sql @@ -0,0 +1,17 @@ +ALTER TABLE node_role_assignments + DROP CONSTRAINT IF EXISTS node_role_assignments_role_check; + +ALTER TABLE node_role_assignments + ADD CONSTRAINT node_role_assignments_role_check + CHECK (role IN ( + 'entry-node', + 'relay-node', + 'core-mesh', + 'rdp-worker', + 'vnc-worker', + 'vpn-exit', + 'vpn-connector', + 'file-storage-cache', + 'update-cache', + 'video-relay' + )); diff --git a/backend/migrations/000031_fabric_vpn_node_roles.up.sql b/backend/migrations/000031_fabric_vpn_node_roles.up.sql new file mode 100644 index 0000000..2487446 --- /dev/null +++ b/backend/migrations/000031_fabric_vpn_node_roles.up.sql @@ -0,0 +1,28 @@ +ALTER TABLE node_role_assignments + DROP CONSTRAINT IF EXISTS node_role_assignments_role_check; + +ALTER TABLE node_role_assignments + ADD CONSTRAINT node_role_assignments_role_check + CHECK (role IN ( + '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' + )); diff --git a/clients/android/README.md b/clients/android/README.md index 13ee83e..cbff44a 100644 --- a/clients/android/README.md +++ b/clients/android/README.md @@ -9,8 +9,10 @@ Implemented now: while the device session is valid; - load organization-scoped VPN client profile from `/clusters/{clusterID}/vpn/client-profile`; - request Android VPN permission and create a `VpnService` TUN interface; -- relay TUN packets through the Control Plane HTTP packet relay to the active - `home-1` gateway lease. +- 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. - 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 @@ -19,9 +21,11 @@ Implemented now: device session is revoked or expires, the app asks for the password once and then rotates the device keys/profile again. -This is still a lab runtime, not a production WireGuard/IPsec implementation. -The active Linux gateway node must be able to create `/dev/net/tun`, run `ip`, -`sysctl`, and `iptables`, and enable NAT for `10.77.0.0/24`. +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. Build from this repository on Windows: diff --git a/clients/android/app/build.gradle b/clients/android/app/build.gradle index b8add65..29d04f3 100644 --- a/clients/android/app/build.gradle +++ b/clients/android/app/build.gradle @@ -22,7 +22,8 @@ android { return (value == null ? "" : value.toString()).replace("\\", "\\\\").replace("\"", "\\\"") } - def defaultBackendUrl = project.findProperty("RAP_ANDROID_DEFAULT_BACKEND_URL") ?: "https://vpn.cin.su/api/v1" + 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 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" @@ -30,9 +31,10 @@ android { applicationId "su.cin.rapvpn" minSdk 26 targetSdk 35 - versionCode 210 - versionName "0.2.210" + versionCode 227 + versionName "0.2.227" buildConfigField "String", "DEFAULT_BACKEND_URL", "\"${normalizeGradleString(defaultBackendUrl)}\"" + buildConfigField "String", "FABRIC_BOOTSTRAP_PEERS", "\"${normalizeGradleString(defaultFabricBootstrapPeers)}\"" buildConfigField "String", "DEFAULT_CLUSTER_ID", "\"${normalizeGradleString(defaultClusterId)}\"" buildConfigField "String", "DEFAULT_ORGANIZATION_ID", "\"${normalizeGradleString(defaultOrganizationId)}\"" } @@ -45,5 +47,6 @@ android { } dependencies { + implementation files("libs/rap-fabricvpn.aar") implementation "com.squareup.okhttp3:okhttp:5.3.2" } diff --git a/clients/android/app/libs/rap-fabricvpn-sources.jar b/clients/android/app/libs/rap-fabricvpn-sources.jar new file mode 100644 index 0000000..b87527a Binary files /dev/null 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 new file mode 100644 index 0000000..c0cb082 Binary files /dev/null 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 7a7d9c1..5c21020 100644 --- a/clients/android/app/src/main/AndroidManifest.xml +++ b/clients/android/app/src/main/AndroidManifest.xml @@ -10,12 +10,6 @@ android:label="RAP VPN" android:theme="@style/AppTheme" android:usesCleartextTraffic="true"> - - 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 51cb36a..ec25a7c 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 @@ -25,7 +25,7 @@ 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 PUBLIC_FABRIC_BACKEND_URL = "https://vpn.cin.su/api/v1"; + 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"; @@ -46,11 +46,9 @@ public class MainActivity extends Activity { private EditText password; private TextView status; private TextView profileSummary; - private TextView serverDirectory; private TextView runtimeStatus; private String profileJson = ""; private String vpnConnectionId = ""; - private JSONArray lastResources = new JSONArray(); private RapApiClient.AuthContext authContext = null; private SharedPreferences prefs; private SharedPreferences runtimePrefs; @@ -68,7 +66,7 @@ public class MainActivity extends Activity { int pad = dp(20); root.setPadding(pad, pad, pad, pad); - backendUrl = field("Backend URL", preferredBackendUrl()); + 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")); @@ -93,12 +91,6 @@ public class MainActivity extends Activity { profileSummary.setTextSize(14); profileSummary.setText(summaryText()); - serverDirectory = new TextView(this); - serverDirectory.setTextColor(0xffe8eef2); - serverDirectory.setTextSize(15); - serverDirectory.setPadding(0, dp(14), 0, dp(14)); - serverDirectory.setText(""); - status = new TextView(this); status.setTextColor(0xffd8eadf); status.setPadding(0, dp(14), 0, dp(14)); @@ -111,11 +103,11 @@ public class MainActivity extends Activity { runtimeStatus.setText(runtimeStatusText()); Button load = new Button(this); - load.setText("Войти / обновить профиль"); + load.setText("Войти / обновить пулы"); load.setOnClickListener(v -> loadProfile(false)); Button start = new Button(this); - start.setText("Включить HOME VPN"); + start.setText("Подключить"); start.setOnClickListener(v -> prepareVpn()); Button stop = new Button(this); @@ -156,17 +148,12 @@ public class MainActivity extends Activity { }); Button settings = new Button(this); - settings.setText("Настройки"); + settings.setText("Аккаунт"); settings.setOnClickListener(v -> showSettingsDialog()); - Button servers = new Button(this); - servers.setText("Открыть удаленный сервер"); - servers.setOnClickListener(v -> showServerPicker()); - root.addView(title); root.addView(profileSummary); root.addView(load); - root.addView(servers); root.addView(start); root.addView(stop); root.addView(settings); @@ -204,25 +191,17 @@ public class MainActivity extends Activity { RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this); authContext = authenticate(client); String activeOrganizationId = resolveOrganizationId(client, authContext.userId); - String requestedExitNodeId = selectedExitNodeId(); profileJson = client.vpnClientProfile( clusterId.getText().toString(), activeOrganizationId, authContext.userId, - requestedExitNodeId + "" ); vpnConnectionId = firstConnectionId(profileJson); saveProfileState(); - JSONObject resourcePayload = client.resources(activeOrganizationId, authContext.userId); - lastResources = resourcePayload.optJSONArray("resources"); - if (lastResources == null) { - lastResources = new JSONArray(); - } - String resourcesText = resourcesText(resourcePayload); runOnUiThread(() -> { profileSummary.setText(summaryText()); - serverDirectory.setText(resourcesText); - status.setText(startAfterLoad ? "Профиль обновлен. Запускаю VPN..." : "Профиль и ключи устройства обновлены."); + status.setText(startAfterLoad ? "Список пулов обновлен. Подключаю..." : "Список доступных пулов обновлен."); startDiagnosticChannel(); if (startAfterLoad) { requestVpnPermission(); @@ -233,12 +212,12 @@ public class MainActivity extends Activity { String message = friendlyError(ex); boolean canUseSavedProfile = startAfterLoad && !profileJson.isEmpty() && !vpnConnectionId.isEmpty(); if (canUseSavedProfile) { - status.setText("Профиль сейчас не обновился: " + message + ". Запускаю VPN с сохраненным рабочим профилем."); + status.setText("Список пулов сейчас не обновился: " + message + ". Подключаюсь с сохраненным рабочим профилем."); startDiagnosticChannel(); requestVpnPermission(); return; } - status.setText("Ошибка профиля: " + message); + status.setText("Ошибка входа: " + message); if (message.contains("логин") || message.contains("пароль") || message.contains("Сессия устройства")) { clearSavedAuth(false); showSettingsDialog(); @@ -250,7 +229,7 @@ public class MainActivity extends Activity { private void prepareVpn() { loadProfile(true); - status.setText("Обновляю сессию устройства и VPN-профиль..."); + status.setText("Обновляю сессию устройства и доступные пулы..."); } private void requestVpnPermission() { @@ -281,7 +260,7 @@ public class MainActivity extends Activity { intent.putExtra(RapVpnService.EXTRA_CLUSTER_ID, clusterId.getText().toString()); intent.putExtra(RapVpnService.EXTRA_VPN_CONNECTION_ID, vpnConnectionId); startForegroundService(intent); - status.setText("VPN запускается. Версия " + APP_VERSION + ". Backend: " + backendUrl.getText() + ". Connection: " + vpnConnectionId + ". Ожидаю статус подключения."); + status.setText("VPN подключается через ферму. Версия " + APP_VERSION + ". Ожидаю рабочий канал."); runtimeStatus.setText("Запрашиваю статус... " + runtimeStatusText()); runtimeStatus.postDelayed(() -> { String state = runtimePrefs.getString("state", ""); @@ -493,7 +472,7 @@ public class MainActivity extends Activity { if ("planned".equals(status)) { String entry = fabricRoute.optString("selected_entry_node_id", "").trim(); String exit = fabricRoute.optString("selected_exit_node_id", "").trim(); - if (!entry.isEmpty() && !exit.isEmpty()) { + if (!exit.isEmpty()) { return id; } } @@ -510,29 +489,6 @@ public class MainActivity extends Activity { return connections.getJSONObject(0).getString("id"); } - private String resourcesText(JSONObject payload) throws Exception { - JSONArray resources = payload.optJSONArray("resources"); - if (resources == null || resources.length() == 0) { - return "Серверы: доступных ресурсов нет."; - } - StringBuilder text = new StringBuilder("Серверы:\n"); - int limit = Math.min(resources.length(), 6); - for (int i = 0; i < limit; i++) { - JSONObject resource = resources.getJSONObject(i); - text.append("• ") - .append(resource.optString("name", "server")) - .append(" ") - .append(resource.optString("protocol", "rdp")) - .append(" ") - .append(resource.optString("address", "")) - .append('\n'); - } - if (resources.length() > limit) { - text.append("и еще ").append(resources.length() - limit).append("..."); - } - return text.toString().trim(); - } - private int dp(int value) { return (int) (value * getResources().getDisplayMetrics().density); } @@ -542,23 +498,101 @@ public class MainActivity extends Activity { String connectionId = vpnConnectionId == null || vpnConnectionId.isEmpty() ? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, "")) : vpnConnectionId; - String backendText = backendUrl == null ? "" : backendUrl.getText().toString().trim(); - String clusterText = clusterId == null ? "" : clusterId.getText().toString().trim(); - String organizationText = organizationId == null ? "" : organizationId.getText().toString().trim(); - String exitNode = selectedExitNodeId(); + String poolText = availablePoolsText(); + String selectedPoolText = selectedPoolName(); String profileDNS = profileDNSServersText(); return "Версия: " + APP_VERSION - + "\nКластер: " + (clusterText.isEmpty() ? "не задан" : clusterText) - + "\nОрганизация: " + (organizationText.isEmpty() ? "не задана" : organizationText) - + "\nТочка входа: автоматическая (из настроек кластера)" - + "\nТочка выхода: " + (exitNode.isEmpty() ? "не выбрана (по умолчанию)" : exitNode) + + "\nУзел Android: в ферме" + + "\nBootstrap фермы: " + bootstrapPeerCount() + " узл." + + "\nДоступные выходы: " + (poolText.isEmpty() ? "войдите для загрузки" : poolText) + + "\nВыбранный выход: " + (selectedPoolText.isEmpty() ? "автоматически" : selectedPoolText) + "\nDNS выхода: " + (profileDNS.isEmpty() ? "будет получен из профиля" : profileDNS) - + "\nBackend: " + (backendText.isEmpty() ? "не задан" : backendText) + "\nТрафик: " + (prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true) ? "весь через VPN" : "по профилю") + "\nDevice: " + (deviceId.isEmpty() ? "нет" : deviceId) + "\nConnection: " + (connectionId.isEmpty() ? "нет" : connectionId); } + private int bootstrapPeerCount() { + if (FABRIC_BOOTSTRAP_PEERS == null || FABRIC_BOOTSTRAP_PEERS.trim().isEmpty()) { + return 0; + } + int count = 0; + for (String value : FABRIC_BOOTSTRAP_PEERS.split(",")) { + if (!value.trim().isEmpty()) { + count++; + } + } + return count; + } + + private String availablePoolsText() { + if (profileJson == null || profileJson.trim().isEmpty()) { + return ""; + } + try { + JSONObject root = new JSONObject(profileJson); + JSONObject vpnProfile = root.optJSONObject("vpn_client_profile"); + JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections"); + if (connections == null || connections.length() == 0) { + return ""; + } + StringBuilder out = new StringBuilder(); + for (int i = 0; i < connections.length(); i++) { + JSONObject connection = connections.optJSONObject(i); + if (connection == null) { + continue; + } + String name = connection.optString("exit_pool_name", "").trim(); + if (name.isEmpty()) { + name = connection.optString("name", "").trim(); + } + if (name.isEmpty()) { + continue; + } + if (out.length() > 0) { + out.append(", "); + } + out.append(name); + } + return out.toString(); + } catch (Exception ignored) { + return ""; + } + } + + private String selectedPoolName() { + if (profileJson == null || profileJson.trim().isEmpty()) { + return ""; + } + try { + JSONObject root = new JSONObject(profileJson); + JSONObject vpnProfile = root.optJSONObject("vpn_client_profile"); + JSONArray connections = vpnProfile == null ? null : vpnProfile.optJSONArray("connections"); + if (connections == null || connections.length() == 0) { + return ""; + } + String preferredConnection = vpnConnectionId == null || vpnConnectionId.isEmpty() + ? (prefs == null ? "" : prefs.getString(PREF_VPN_CONNECTION_ID, "")) + : vpnConnectionId; + for (int i = 0; i < connections.length(); i++) { + JSONObject connection = connections.optJSONObject(i); + if (connection == null) { + continue; + } + if (!preferredConnection.isEmpty() && !preferredConnection.equals(connection.optString("id", ""))) { + continue; + } + String name = connection.optString("exit_pool_name", "").trim(); + if (name.isEmpty()) { + name = connection.optString("name", "").trim(); + } + return name; + } + } catch (Exception ignored) { + } + return ""; + } + private String profileDNSServersText() { if (profileJson == null || profileJson.trim().isEmpty()) { return runtimePrefs == null ? "" : runtimePrefs.getString("dns_servers", ""); @@ -665,17 +699,14 @@ public class MainActivity extends Activity { || "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) - || "http://192.168.200.61:18080/api/v1".equals(lower) - || "http://docker-test.cin.su:18080/api/v1".equals(lower)) { - return PUBLIC_FABRIC_BACKEND_URL; + || "http://195.123.240.88:19131/api/v1".equals(lower)) { + return DEFAULT_BACKEND_URL; } return candidate; } private String selectedExitNodeId() { - String configured = prefs == null ? "" : prefs.getString(PREF_SELECTED_EXIT_NODE_ID, ""); - return normalizeSelectedExitNodeId(configured); + return ""; } private String normalizeSelectedExitNodeId(String value) { @@ -824,16 +855,10 @@ public class MainActivity extends Activity { form.setOrientation(LinearLayout.VERTICAL); int pad = dp(12); form.setPadding(pad, pad, pad, pad); - EditText backendDraft = field("Backend URL", backendUrl.getText().toString()); - EditText clusterDraft = field("Cluster ID", clusterId.getText().toString()); - EditText organizationDraft = field("Organization ID", organizationId.getText().toString()); EditText emailDraft = field("Email", email.getText().toString()); EditText passwordDraft = field("Password", password.getText().toString()); passwordDraft.setInputType(InputType.TYPE_CLASS_TEXT | InputType.TYPE_TEXT_VARIATION_PASSWORD); passwordDraft.setHint("Password (не сохраняется)"); - EditText selectedExitDraft = field( - "Точка выхода (Node ID, например ifcm)", - prefs.getString(PREF_SELECTED_EXIT_NODE_ID, "")); CheckBox showPassword = new CheckBox(this); showPassword.setText("Показать пароль"); showPassword.setTextColor(0xff111111); @@ -847,30 +872,19 @@ public class MainActivity extends Activity { forceFullTunnel.setText("Полный маршрут через VPN"); forceFullTunnel.setChecked(prefs.getBoolean(PREF_FORCE_FULL_TUNNEL, true)); forceFullTunnel.setTextColor(0xff111111); - form.addView(backendDraft); - form.addView(clusterDraft); - form.addView(organizationDraft); form.addView(emailDraft); form.addView(passwordDraft); - form.addView(selectedExitDraft); form.addView(showPassword); form.addView(forceFullTunnel); new AlertDialog.Builder(this) - .setTitle("Настройки подключения") + .setTitle("Аккаунт VPN") .setView(form) .setPositiveButton("Сохранить", (dialog, which) -> { - backendUrl.setText(backendDraft.getText().toString()); - clusterId.setText(clusterDraft.getText().toString()); - organizationId.setText(organizationDraft.getText().toString()); email.setText(emailDraft.getText().toString()); password.setText(passwordDraft.getText().toString()); - String normalizedExit = normalizeSelectedExitNodeId(selectedExitDraft.getText().toString()); prefs.edit() - .putString(PREF_SELECTED_EXIT_NODE_ID, normalizedExit) + .remove(PREF_SELECTED_EXIT_NODE_ID) .apply(); - if (!normalizedExit.equals(selectedExitDraft.getText().toString().trim())) { - status.setText("Точка выхода очищена: значение было не похоже на Node ID/alias."); - } prefs.edit().putBoolean(PREF_FORCE_FULL_TUNNEL, forceFullTunnel.isChecked()).apply(); saveSettings(); profileSummary.setText(summaryText()); @@ -898,60 +912,4 @@ public class MainActivity extends Activity { return message; } - private void showServerPicker() { - if (lastResources.length() == 0) { - loadProfile(); - status.setText("Загружаю список серверов..."); - return; - } - String[] labels = new String[lastResources.length()]; - for (int i = 0; i < lastResources.length(); i++) { - JSONObject resource = lastResources.optJSONObject(i); - labels[i] = resource == null - ? "server" - : resource.optString("name", "server") + " " + resource.optString("address", ""); - } - new AlertDialog.Builder(this) - .setTitle("Удаленный сервер") - .setItems(labels, (dialog, which) -> startRemoteDesktop(which)) - .show(); - } - - private void startRemoteDesktop(int index) { - JSONObject resource = lastResources.optJSONObject(index); - if (resource == null) { - return; - } - if (authContext == null || authContext.userId.isEmpty() || authContext.deviceId.isEmpty()) { - loadProfile(); - status.setText("Профиль обновляется. Повторите открытие сервера."); - return; - } - status.setText("Открываю " + resource.optString("name", "сервер") + "..."); - new Thread(() -> { - try { - RapApiClient client = new RapApiClient(backendUrl.getText().toString(), this); - JSONObject result = client.startSession(resource.getString("id"), authContext.userId, authContext.deviceId); - Intent intent = new Intent(this, RdpActivity.class); - intent.putExtra(RdpActivity.EXTRA_SESSION_RESULT, result.toString()); - intent.putExtra(RdpActivity.EXTRA_GATEWAY_URL, gatewayUrl()); - intent.putExtra(RdpActivity.EXTRA_RESOURCE_NAME, resource.optString("name", "Remote Desktop")); - runOnUiThread(() -> { - status.setText("Сессия создана."); - startActivity(intent); - }); - } catch (Exception ex) { - runOnUiThread(() -> status.setText("Ошибка RDP: " + ex.getMessage())); - } - }).start(); - } - - private String gatewayUrl() { - String api = backendUrl.getText().toString().trim(); - String gateway = api.replace("https://", "wss://").replace("http://", "ws://"); - if (gateway.endsWith("/")) { - gateway = gateway.substring(0, gateway.length() - 1); - } - return gateway + "/gateway/ws"; - } } 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 index 289404f..0e6c25b 100644 --- a/clients/android/app/src/main/java/su/cin/rapvpn/RapDiagnosticService.java +++ b/clients/android/app/src/main/java/su/cin/rapvpn/RapDiagnosticService.java @@ -54,7 +54,6 @@ public class RapDiagnosticService extends Service { 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 PUBLIC_FABRIC_BACKEND_URL = "https://vpn.cin.su/api/v1"; 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; @@ -427,6 +426,9 @@ public class RapDiagnosticService extends Service { } String type = payload.optString("type", ""); JSONObject params = payload.optJSONObject("payload"); + if (params == null) { + params = payload.optJSONObject("params"); + } if (params == null) { params = payload; } @@ -447,7 +449,7 @@ public class RapDiagnosticService extends Service { 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) || "vpn_rdp_probe".equals(type)) { + } 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)); @@ -472,7 +474,7 @@ public class RapDiagnosticService extends Service { } 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-rdp-vpn-build.json")); + 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)) { @@ -590,6 +592,16 @@ public class RapDiagnosticService extends Service { } 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) @@ -602,7 +614,7 @@ public class RapDiagnosticService extends Service { } long sent = runtime.getLong("uplink_sent_total", 0); long down = runtime.getLong("downlink_received_total", 0); - return !relay.isEmpty() && (sent > 0 || down > 0); + return (fabricMode || !relay.isEmpty()) && (sent > 0 || down > 0); } private static final class ControlEndpoint { @@ -662,7 +674,6 @@ public class RapDiagnosticService extends Service { return "vpn_http_get".equals(type) || "vpn_page_probe".equals(type) || "vpn_tcp_connect".equals(type) - || "vpn_rdp_probe".equals(type) || "vpn_download_test".equals(type); } @@ -688,17 +699,17 @@ public class RapDiagnosticService extends Service { if ("vpn_page_probe".equals(type)) { return runVPNPageProbe(payload); } - if ("vpn_tcp_connect".equals(type) || "vpn_rdp_probe".equals(type)) { + 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-rdp-vpn-build.json"), + 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-rdp-vpn-build.json")); + return runVPNDownloadTest(payload.optString("url", "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json")); } return "retry skipped: unsupported probe " + type; } @@ -711,7 +722,7 @@ public class RapDiagnosticService extends Service { copy = new JSONObject(); } try { - if ("vpn_tcp_connect".equals(type) || "vpn_rdp_probe".equals(type)) { + 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)) { @@ -758,6 +769,8 @@ public class RapDiagnosticService extends Service { 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); @@ -900,9 +913,8 @@ public class RapDiagnosticService extends Service { if (organizationId == null || organizationId.trim().isEmpty()) { organizationId = DEFAULT_ORGANIZATION_ID; } - String exitNodeId = prefs.getString(PREF_SELECTED_EXIT_NODE_ID, ""); RapApiClient client = new RapApiClient(backendUrl, this, true); - String profileJson = client.vpnClientProfile(clusterId, organizationId, userId, exitNodeId); + 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"); @@ -927,7 +939,6 @@ public class RapDiagnosticService extends Service { 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 selectedExitNodeId = params.optString("selected_exit_node_id", params.optString("exit_node_id", "")).trim(); String profileJson = params.optString("profile_json", "").trim(); JSONObject root; if (profileJson.isEmpty()) { @@ -962,13 +973,6 @@ public class RapDiagnosticService extends Service { } JSONObject connection = profile.getJSONArray("connections").getJSONObject(0); String connectionId = params.optString("vpn_connection_id", connection.optString("id", "")).trim(); - JSONObject config = connection.optJSONObject("client_config"); - if (selectedExitNodeId.isEmpty() && config != null) { - JSONObject route = config.optJSONObject("vpn_fabric_route"); - if (route != null) { - selectedExitNodeId = route.optString("selected_exit_node_id", ""); - } - } SharedPreferences.Editor editor = getSharedPreferences(PREFS, MODE_PRIVATE).edit() .putString("backend_url", backendUrl) .putString("cluster_id", clusterId) @@ -979,9 +983,7 @@ public class RapDiagnosticService extends Service { if (!trustedDeviceId.isEmpty()) { editor.putString(PREF_DEVICE_ID, trustedDeviceId); } - if (!selectedExitNodeId.isEmpty()) { - editor.putString(PREF_SELECTED_EXIT_NODE_ID, selectedExitNodeId); - } + editor.remove(PREF_SELECTED_EXIT_NODE_ID); editor.apply(); Intent stopIntent = new Intent(this, RapVpnService.class); stopIntent.setAction(RapVpnService.ACTION_STOP); @@ -1095,10 +1097,8 @@ public class RapDiagnosticService extends Service { || "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) - || "http://192.168.200.61:18080/api/v1".equals(lower) - || "http://docker-test.cin.su:18080/api/v1".equals(lower)) { - return PUBLIC_FABRIC_BACKEND_URL; + || "http://195.123.240.88:19131/api/v1".equals(lower)) { + return DEFAULT_BACKEND_URL; } return candidate; } @@ -1187,7 +1187,7 @@ public class RapDiagnosticService extends Service { 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-rdp-vpn-build.json"))); + 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); } @@ -1240,13 +1240,13 @@ public class RapDiagnosticService extends Service { if (timeoutMs > 45000) { timeoutMs = 45000; } - String rdpHost = payload.optString("rdp_host", "192.168.200.95"); - int rdpPort = payload.optInt("rdp_port", 3389); + 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-rdp-vpn-build.json", + "http://192.168.200.61:18080/downloads/rap-android-vpn-build.json", "http://192.168.200.61:18080/" }; List urls = new ArrayList<>(); @@ -1264,7 +1264,7 @@ public class RapDiagnosticService extends Service { urls.add(url); } } - String rdpBefore = runVPNTCPConnect(rdpHost, rdpPort, Math.min(timeoutMs, 10000)); + String tcpBefore = runVPNTCPConnect(tcpHost, tcpPort, Math.min(timeoutMs, 10000)); String[] results = new String[parallel]; Thread[] threads = new Thread[parallel]; final int requestTimeoutMs = timeoutMs; @@ -1301,13 +1301,13 @@ public class RapDiagnosticService extends Service { sample.append(item == null ? "timeout/no_result" : item); } } - String rdpAfter = runVPNTCPConnect(rdpHost, rdpPort, Math.min(timeoutMs, 10000)); + String tcpAfter = runVPNTCPConnect(tcpHost, tcpPort, Math.min(timeoutMs, 10000)); return compact("vpn_mixed_load_test parallel=" + parallel + " ok=" + ok + " failed=" + failed + " elapsed_ms=" + elapsed - + " rdp_before={" + rdpBefore + "}" - + " rdp_after={" + rdpAfter + "}" + + " tcp_before={" + tcpBefore + "}" + + " tcp_after={" + tcpAfter + "}" + " sample={" + sample + "}", 2500); } @@ -1573,6 +1573,8 @@ public class RapDiagnosticService extends Service { 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; } 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 b72ca72..4193550 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 @@ -20,6 +20,10 @@ import android.util.Log; import org.json.JSONArray; import org.json.JSONObject; +import su.cin.rapvpn.fabric.fabricvpn.Fabricvpn; +import su.cin.rapvpn.fabric.fabricvpn.Manager; +import su.cin.rapvpn.fabric.fabricvpn.SocketProtector; + import java.io.FileDescriptor; import java.io.FileInputStream; import java.net.DatagramPacket; @@ -79,7 +83,7 @@ public class RapVpnService extends VpnService { private static final int RUNTIME_STATUS_INTERVAL_MS = 500; private static final int RUNTIME_WATCHDOG_INTERVAL_MS = 2000; private static final int RUNTIME_WATCHDOG_STALE_SYNACK_MS = 7000; - private static final int RUNTIME_WATCHDOG_RDP_STALE_SYNACK_MS = 4000; + private static final int RUNTIME_WATCHDOG_PRIORITY_STALE_SYNACK_MS = 4000; private static final int RUNTIME_WATCHDOG_STALE_ROUNDS_BEFORE_RECOVERY = 3; private static final int RUNTIME_WATCHDOG_STALE_SYNACKS_BEFORE_RECOVERY = 4; private static final int RUNTIME_WATCHDOG_MAX_STALE_ROUNDS_BEFORE_RECOVERY = 6; @@ -209,6 +213,9 @@ public class RapVpnService extends VpnService { 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) { @@ -288,40 +295,18 @@ public class RapVpnService extends VpnService { return START_NOT_STICKY; } persistStartConfig(profile, backendUrl, clusterId, vpnConnectionId); - List packetRelayUrls = activePacketRelayUrlsByProfile == null || activePacketRelayUrlsByProfile.isEmpty() - ? singletonUrl(activePacketRelayUrlByProfile) - : new ArrayList<>(activePacketRelayUrlsByProfile); - if (!activeFabricServiceChannel.enabled) { - shutdownReason = "fabric service channel lease required"; - writeRuntimeStatus("error", "vpn not started: fabric service channel lease required", 0, 0, 0, 0); + 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); + writeRuntimeDetail("legacy_dataplane_disabled", "Android VPN client accepts only the QUIC fabric mesh node route profile. HTTP relay, WebSocket relay and entry endpoint modes are disabled for runtime traffic.", "mesh", 0, 1, "legacy_dataplane_disabled", -1); stopSelf(); return START_NOT_STICKY; } - if (packetRelayUrls.isEmpty()) { - shutdownReason = "missing farm entry endpoint"; - writeRuntimeStatus("error", "vpn not started: missing farm entry endpoint", 0, 0, 0, 0); + if (!startFabricMeshNodeRuntime(clusterId, vpnConnectionId, activeFabricRuntimeConfigJson)) { stopSelf(); return START_NOT_STICKY; } - startPacketRelay(backendUrl, packetRelayUrls, clusterId, vpnConnectionId); - if (!running) { - shutdownReason = "relay not running"; - writeRuntimeStatus("error", "vpn not started: relay not running", 0, 0, 0, 0); - stopSelf(); - return START_NOT_STICKY; - } - if (tunnel == null || backendUrl == null || backendUrl.isEmpty() - || clusterId == null || clusterId.isEmpty() - || vpnConnectionId == null || vpnConnectionId.isEmpty()) { - shutdownReason = "invalid runtime"; - writeRuntimeStatus("error", "vpn not started: invalid runtime", 0, 0, 0, 0); - stopSelf(); - return START_NOT_STICKY; - } - writeRuntimeStatus("running", "vpn service active " + vpnAddressIPv4, 0, 0, downlinkReceivedPackets.get(), 0); - startVPNReadinessWarmup(configuredDnsServers(), configuredDnsProbeDomains(), vpnConnectionId); - shutdownReason = "running"; - return START_NOT_STICKY; + return START_STICKY; } private void ensureDiagnosticServiceRunning() { @@ -467,6 +452,8 @@ public class RapVpnService extends VpnService { activePacketRelayUrlByProfile = ""; activePacketRelayUrlsByProfile = new ArrayList<>(); activeFabricServiceChannel = new FabricServiceChannel(); + activeMeshNodeRouteMode = false; + activeFabricRuntimeConfigJson = ""; VpnClientConfig config = parseClientConfig(profileJson, backendUrl); SharedPreferences prefs = getSharedPreferences(PREF_NAME, MODE_PRIVATE); boolean forceFullTunnel = prefs.getBoolean(MainActivity.PREF_FORCE_FULL_TUNNEL, true); @@ -525,6 +512,8 @@ public class RapVpnService extends VpnService { activePacketRelayUrlByProfile = config.packetRelayBaseUrl; activePacketRelayUrlsByProfile = new ArrayList<>(config.packetRelayBaseUrls); activeFabricServiceChannel = config.fabricServiceChannel; + activeMeshNodeRouteMode = config.meshNodeRouteMode; + activeFabricRuntimeConfigJson = config.fabricRuntimeConfigJson; } catch (Exception e) { Log.e(TAG, "vpn tunnel establish failed", e); writeRuntimeStatus("error", "tunnel failed: " + e.getMessage(), 0, 0, 0, 0); @@ -561,7 +550,10 @@ public class RapVpnService extends VpnService { if ("planned".equals(status) && connection == null) { String entry = candidateRoute.optString("selected_entry_node_id", "").trim(); String exit = candidateRoute.optString("selected_exit_node_id", "").trim(); - if (!entry.isEmpty() && !exit.isEmpty()) { + boolean clientNodeEntry = candidateRoute.optBoolean("client_node_entry", false) + || "client_node".equalsIgnoreCase(candidateRoute.optString("entry_selector", "")) + || "client_node_to_exit_pool".equalsIgnoreCase(candidateRoute.optString("route_type", "")); + if ((!entry.isEmpty() || clientNodeEntry) && !exit.isEmpty()) { connection = candidate; selectedConnectionId = candidateId; break; @@ -581,6 +573,7 @@ public class RapVpnService extends VpnService { return config; } JSONObject clientConfig = connection.optJSONObject("client_config"); + JSONObject dataplaneSession = null; if (clientConfig != null) { String vpnAddress = clientConfig.optString("vpn_address", ""); if (!vpnAddress.isEmpty()) { @@ -593,7 +586,7 @@ public class RapVpnService extends VpnService { readStringArray(clientConfig.optJSONArray("dns_servers"), config.dnsServers, true); readStringArray(clientConfig.optJSONArray("dns_probe_domains"), config.dnsProbeDomains, false); readStringArray(clientConfig.optJSONArray("routes"), config.splitRoutes, false); - JSONObject dataplaneSession = clientConfig.optJSONObject("vpn_dataplane_session"); + dataplaneSession = clientConfig.optJSONObject("vpn_dataplane_session"); if (dataplaneSession != null) { config.dataplaneSessionStatus = dataplaneSession.optString("status", ""); config.dataplanePreferredTransport = dataplaneSession.optString("preferred_transport", ""); @@ -604,7 +597,17 @@ public class RapVpnService extends VpnService { config.dataplaneTransportCandidateCount = transportCandidates == null ? 0 : transportCandidates.length(); JSONArray entryCandidates = dataplaneSession.optJSONArray("entry_candidates"); config.dataplaneEntryCandidateCount = entryCandidates == null ? 0 : entryCandidates.length(); + JSONArray exitCandidates = dataplaneSession.optJSONArray("exit_candidates"); + config.dataplaneExitCandidateCount = exitCandidates == null ? 0 : exitCandidates.length(); + JSONObject routeBundle = dataplaneSession.optJSONObject("fabric_route_bundle"); + JSONArray routeBundleEndpoints = routeBundle == null ? null : routeBundle.optJSONArray("endpoint_candidates"); + config.fabricMeshExitEndpoints = summarizeFabricMeshEndpoints(routeBundleEndpoints == null ? exitCandidates : routeBundleEndpoints); config.dataplaneSelectedTransport = selectDataplanePacketTransport(dataplaneSession); + config.meshNodeRouteMode = isMeshNodeRouteDataplane(dataplaneSession); + if (config.meshNodeRouteMode) { + 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); @@ -656,12 +659,102 @@ public class RapVpnService extends VpnService { selectedConnectionId = waitingConnectionId; } config.selectedConnectionId = selectedConnectionId; + if (config.meshNodeRouteMode && dataplaneSession != null) { + String clusterId = profile == null ? "" : profile.optString("cluster_id", ""); + config.fabricRuntimeConfigJson = buildFabricRuntimeConfig(clusterId, selectedConnectionId, config.dataplaneExitNodeId, dataplaneSession); + } } catch (Exception ignored) { config.configNotes.add("Failed parsing profile: using defaults"); } return config; } + private String buildFabricRuntimeConfig(String clusterId, String vpnConnectionId, String exitNodeId, JSONObject dataplaneSession) { + try { + JSONObject out = new JSONObject(); + String sessionCluster = dataplaneSession == null ? "" : dataplaneSession.optString("cluster_id", ""); + String sessionId = dataplaneSession == null ? "" : dataplaneSession.optString("session_id", ""); + String connectionId = vpnConnectionId == null ? "" : vpnConnectionId.trim(); + out.put("cluster_id", firstNonEmpty(clusterId, sessionCluster)); + out.put("local_node_id", "android-vpn-" + firstNonEmpty(connectionId, sessionId, String.valueOf(System.currentTimeMillis()))); + out.put("exit_node_id", firstNonEmpty(exitNodeId, dataplaneSession == null ? "" : dataplaneSession.optString("exit_node_id", ""))); + out.put("vpn_connection_id", firstNonEmpty(connectionId, sessionId)); + out.put("stream_shards", 4); + JSONObject request = dataplaneSession == null ? null : dataplaneSession.optJSONObject("fabric_service_channel_request"); + JSONObject routeBundle = dataplaneSession == null ? null : dataplaneSession.optJSONObject("fabric_route_bundle"); + if (request != null) { + out.put("service_channel_request", request); + } + if (routeBundle != null) { + out.put("route_bundle", routeBundle); + } + JSONArray endpointCache = new JSONArray(); + JSONArray candidates = routeBundle == null ? null : routeBundle.optJSONArray("endpoint_candidates"); + if (candidates == null && routeBundle != null) { + candidates = routeBundle.optJSONArray("target_candidates"); + } + if (candidates == null) { + candidates = dataplaneSession == null ? null : dataplaneSession.optJSONArray("exit_candidates"); + } + if (candidates != null) { + for (int i = 0; i < candidates.length(); i++) { + JSONObject candidate = candidates.optJSONObject(i); + if (!isUsableFabricQUICCandidate(candidate)) { + continue; + } + JSONObject endpoint = new JSONObject(); + endpoint.put("endpoint_id", candidate.optString("endpoint_id", candidate.optString("address", ""))); + endpoint.put("node_id", candidate.optString("node_id", out.optString("exit_node_id", ""))); + endpoint.put("transport", firstNonEmpty(candidate.optString("transport", ""), "direct_quic")); + endpoint.put("address", candidate.optString("address", "")); + endpoint.put("priority", candidate.optInt("priority", 1000 + i)); + endpoint.put("peer_cert_sha256", firstNonEmpty( + candidate.optString("peer_cert_sha256", ""), + candidate.optString("tls_cert_sha256", ""), + nestedString(candidate, "metadata", "peer_cert_sha256"), + nestedString(candidate, "metadata", "tls_cert_sha256"))); + endpoint.put("tls_cert_sha256", endpoint.optString("peer_cert_sha256", "")); + endpointCache.put(endpoint); + } + } + out.put("deprecated_runtime_endpoint_cache", endpointCache); + return out.toString(); + } catch (Exception e) { + return ""; + } + } + + private boolean isUsableFabricQUICCandidate(JSONObject candidate) { + if (candidate == null) { + return false; + } + String address = candidate.optString("address", "").trim(); + String transport = candidate.optString("transport", "").trim(); + if (address.isEmpty()) { + return false; + } + return address.toLowerCase(Locale.US).startsWith("quic://") + || "quic".equalsIgnoreCase(transport) + || "direct_quic".equalsIgnoreCase(transport); + } + + private String nestedString(JSONObject object, String parent, String child) { + JSONObject nested = object == null ? null : object.optJSONObject(parent); + return nested == null ? "" : nested.optString(child, ""); + } + + private String firstNonEmpty(String... values) { + if (values == null) { + return ""; + } + for (String value : values) { + if (value != null && !value.trim().isEmpty()) { + return value.trim(); + } + } + return ""; + } + private int parseMtu(int mtu) { if (mtu <= 0) { return DEFAULT_VPN_MTU; @@ -691,10 +784,33 @@ public class RapVpnService extends VpnService { } private String selectDataplanePacketTransport(JSONObject dataplaneSession) { + if (isMeshNodeRouteDataplane(dataplaneSession)) { + return "fabric_mesh_node_route_v1"; + } JSONObject candidate = selectSafeEntryDirectHTTPCandidate(dataplaneSession); return candidate == null ? "" : "entry_direct_http_v1"; } + private boolean isMeshNodeRouteDataplane(JSONObject dataplaneSession) { + if (dataplaneSession == null) { + return false; + } + if ("fabric_mesh_node_route_v1".equals(dataplaneSession.optString("preferred_transport", ""))) { + return true; + } + JSONArray transportCandidates = dataplaneSession.optJSONArray("transport_candidates"); + if (transportCandidates == null) { + return false; + } + for (int i = 0; i < transportCandidates.length(); i++) { + JSONObject candidate = transportCandidates.optJSONObject(i); + if (candidate != null && "fabric_mesh_node_route_v1".equals(candidate.optString("type", ""))) { + return true; + } + } + return false; + } + private List selectDataplanePacketRelayBaseUrls(JSONObject dataplaneSession, String backendUrl) { List urls = new ArrayList<>(); JSONObject candidate = selectSafeEntryDirectHTTPCandidate(dataplaneSession); @@ -787,6 +903,37 @@ public class RapVpnService extends VpnService { return null; } + private String summarizeFabricMeshEndpoints(JSONArray candidates) { + if (candidates == null || candidates.length() == 0) { + return ""; + } + List endpoints = new ArrayList<>(); + for (int i = 0; i < candidates.length(); i++) { + JSONObject candidate = candidates.optJSONObject(i); + if (candidate == null) { + continue; + } + String address = candidate.optString("address", "").trim(); + if (address.isEmpty()) { + continue; + } + String transport = candidate.optString("transport", "").trim(); + if (!address.toLowerCase().startsWith("quic://") + && !"quic".equalsIgnoreCase(transport) + && !"direct_quic".equalsIgnoreCase(transport)) { + continue; + } + String nodeId = candidate.optString("node_id", "").trim(); + String endpointId = candidate.optString("endpoint_id", "").trim(); + String label = nodeId.isEmpty() ? address : nodeId + "=" + address; + if (!endpointId.isEmpty()) { + label += "#" + endpointId; + } + endpoints.add(label); + } + return joinList(endpoints); + } + private String normalizeHTTPBaseUrl(String value) { if (value == null) { return ""; @@ -879,12 +1026,16 @@ public class RapVpnService extends VpnService { .putString("dataplane_entry_node_id", config.dataplaneEntryNodeId) .putString("dataplane_exit_node_id", config.dataplaneExitNodeId) .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)) .putInt("dataplane_transport_candidate_count", config.dataplaneTransportCandidateCount) .putInt("dataplane_entry_candidate_count", config.dataplaneEntryCandidateCount) + .putInt("dataplane_exit_candidate_count", config.dataplaneExitCandidateCount) + .putString("fabric_mesh_exit_endpoints", config.fabricMeshExitEndpoints) .commit(); } catch (Exception ignored) { } @@ -1001,6 +1152,143 @@ public class RapVpnService extends VpnService { return parts.length > 0 && !parts[0].isEmpty() ? parts[0] : "10.77.0.2"; } + private boolean startFabricMeshNodeRuntime(String clusterId, String vpnConnectionId, String fabricRuntimeConfigJson) { + if (tunnel == null || clusterId == null || clusterId.isEmpty() || vpnConnectionId == null || vpnConnectionId.isEmpty()) { + writeRuntimeStatus("error", "fabric runtime not started: tunnel/cluster/connection missing", 0, 0, 0, 0); + return false; + } + if (fabricRuntimeConfigJson == null || fabricRuntimeConfigJson.trim().isEmpty()) { + writeRuntimeStatus("error", "fabric runtime not started: missing QUIC exit pool config", 0, 0, 0, 0); + return false; + } + try { + JSONObject cfg = new JSONObject(fabricRuntimeConfigJson); + JSONArray endpoints = cfg.optJSONArray("endpoints"); + boolean hasRouteLease = hasFabricRouteLeaseCandidates(cfg.optJSONObject("route_bundle")); + JSONObject request = cfg.optJSONObject("service_channel_request"); + if (request == null || request.optString("schema_version", "").isEmpty()) { + writeRuntimeStatus("error", "fabric runtime not started: missing service channel request", 0, 0, 0, 0); + return false; + } + if (!hasRouteLease && (endpoints == null || endpoints.length() == 0)) { + writeRuntimeStatus("error", "fabric runtime not started: no fabric route lease candidates", 0, 0, 0, 0); + return false; + } + } catch (Exception e) { + writeRuntimeStatus("error", "fabric runtime config invalid: " + e.getMessage(), 0, 0, 0, 0); + return false; + } + + stopPacketRelay(); + try { + Fabricvpn.touch(); + Manager manager = Fabricvpn.newManager(); + manager.setSocketProtector(new SocketProtector() { + @Override + public boolean protect(long fd) { + try { + return RapVpnService.this.protect((int) fd); + } catch (Exception e) { + writeRuntimeDetail("socket_protect_failed", e.getMessage(), "fabric", 0, 1, e.getClass().getSimpleName(), -1); + return false; + } + } + }); + manager.start(fabricRuntimeConfigJson); + fabricVpnManager = manager; + } catch (Throwable e) { + fabricVpnManager = null; + writeRuntimeStatus("error", "fabric runtime start failed: " + e.getMessage(), 0, 0, 0, 1); + writeRuntimeDetail("start_failed", "QUIC fabric vpn runtime failed: " + e.getMessage(), "fabric", 0, 1, e.getClass().getSimpleName(), -1); + return false; + } + + running = true; + 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"); + 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); + } + downlinkThread = new Thread(() -> pumpFabricDownlinkToQueue(runtimeId), "rap-vpn-fabric-downlink-receiver"); + downlinkWriterThread = new Thread(() -> pumpDownlinkQueueToTun(runtimeId), "rap-vpn-fabric-downlink-writer"); + diagnosticWatchdogThread = new Thread(this::runDiagnosticServiceWatchdog, "rap-vpn-diagnostic-watchdog"); + uplinkThread.start(); + for (Thread senderThread : uplinkSenderThreads) { + senderThread.start(); + } + downlinkThread.start(); + downlinkWriterThread.start(); + diagnosticWatchdogThread.start(); + return true; + } + + private boolean hasFabricRouteLeaseCandidates(JSONObject routeBundle) { + if (routeBundle == null) { + return false; + } + JSONObject lease = routeBundle.optJSONObject("route_lease"); + if (lease == null) { + return false; + } + JSONObject primary = lease.optJSONObject("primary_path"); + if (hasUsableFabricQUICCandidate(primary == null ? null : primary.optJSONArray("endpoint_candidates"))) { + return true; + } + JSONArray standby = lease.optJSONArray("warm_standby_paths"); + if (standby == null) { + return false; + } + for (int i = 0; i < standby.length(); i++) { + JSONObject path = standby.optJSONObject(i); + if (hasUsableFabricQUICCandidate(path == null ? null : path.optJSONArray("endpoint_candidates"))) { + return true; + } + } + return false; + } + + private boolean hasUsableFabricQUICCandidate(JSONArray candidates) { + if (candidates == null) { + return false; + } + for (int i = 0; i < candidates.length(); i++) { + if (isUsableFabricQUICCandidate(candidates.optJSONObject(i))) { + return true; + } + } + return false; + } + + private void initializePacketRuntimeQueues() { + 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); + } + } + 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) @@ -1316,6 +1604,14 @@ public class RapVpnService extends VpnService { if (relay != null) { relay.close(); } + Manager manager = fabricVpnManager; + fabricVpnManager = null; + if (manager != null) { + try { + manager.stop(); + } catch (Exception ignored) { + } + } closeTunHandles(); interruptAndJoin(uplinkThread); if (uplinkSenderThreads != null) { @@ -1602,7 +1898,7 @@ public class RapVpnService extends VpnService { } long now = System.currentTimeMillis(); int stale = staleTCPHandshakeCount(); - int rdpStale = staleRdpTCPHandshakeCount(); + int priorityStale = stalePriorityTCPHandshakeCount(); long downlinkPackets = downlinkReceivedPackets.get(); long uplinkPackets = uplinkSentPackets.get(); boolean downlinkProgressed = downlinkPackets > lastRuntimeWatchdogDownlinkPackets; @@ -1618,16 +1914,16 @@ public class RapVpnService extends VpnService { } if (downlinkProgressed) { runtimeWatchdogStaleRounds = 0; - writeRuntimeDetail("watchdog_observed_downlink", "stale=" + stale + " rdp_stale=" + rdpStale + " downlink_progress=true uplink_progress=" + uplinkProgressed, "watchdog", runtimeWatchdogRecoveries.get(), tcpHandshakeStalls.get(), "", -1); + writeRuntimeDetail("watchdog_observed_downlink", "stale=" + stale + " priority_stale=" + priorityStale + " downlink_progress=true uplink_progress=" + uplinkProgressed, "watchdog", runtimeWatchdogRecoveries.get(), tcpHandshakeStalls.get(), "", -1); continue; } runtimeWatchdogStaleRounds++; - if (rdpStale > 0 && now - lastRuntimeWatchdogRecoveryAt >= RUNTIME_WATCHDOG_RECOVERY_COOLDOWN_MS) { + if (priorityStale > 0 && now - lastRuntimeWatchdogRecoveryAt >= RUNTIME_WATCHDOG_RECOVERY_COOLDOWN_MS) { runtimeWatchdogStaleRounds = 0; - tcpHandshakeStalls.addAndGet(rdpStale); + tcpHandshakeStalls.addAndGet(priorityStale); runtimeWatchdogRecoveries.incrementAndGet(); lastRuntimeWatchdogRecoveryAt = now; - recoverPacketRelayRuntime(clusterId, vpnConnectionId, "rdp_tcp_handshake_stall stale=" + rdpStale); + recoverPacketRelayRuntime(clusterId, vpnConnectionId, "priority_tcp_handshake_stall stale=" + priorityStale); continue; } boolean relayOpen = isPacketWebSocketRelayOpen(); @@ -1808,11 +2104,11 @@ public class RapVpnService extends VpnService { return staleTCPHandshakeCount(RUNTIME_WATCHDOG_STALE_SYNACK_MS, false); } - private int staleRdpTCPHandshakeCount() { - return staleTCPHandshakeCount(RUNTIME_WATCHDOG_RDP_STALE_SYNACK_MS, true); + private int stalePriorityTCPHandshakeCount() { + return staleTCPHandshakeCount(RUNTIME_WATCHDOG_PRIORITY_STALE_SYNACK_MS, true); } - private int staleTCPHandshakeCount(int staleAfterMs, boolean rdpOnly) { + private int staleTCPHandshakeCount(int staleAfterMs, boolean priorityOnly) { long now = System.currentTimeMillis(); int stale = 0; synchronized (pendingTcpHandshakes) { @@ -1829,7 +2125,7 @@ public class RapVpnService extends VpnService { remove.add(entry.getKey()); continue; } - if (rdpOnly && !entry.getKey().contains("|3389|")) { + if (priorityOnly && !isPriorityTCPFlowKey(entry.getKey())) { continue; } if (age >= staleAfterMs) { @@ -2309,8 +2605,11 @@ public class RapVpnService extends VpnService { int ihl = (packet[0] & 0x0f) * 4; int tcpHeaderLength = ((packet[ihl + 12] >> 4) & 0x0f) * 4; int tcpPayloadLength = Math.max(0, ipTotalLength - ihl - tcpHeaderLength); - boolean rdp = flow.srcPort == 3389 || flow.dstPort == 3389; - return rdp || syn || fin || rst || (ack && tcpPayloadLength == 0) || (psh && tcpPayloadLength <= 96); + return syn || fin || rst || (ack && tcpPayloadLength == 0) || (psh && tcpPayloadLength <= 96); + } + + private boolean isPriorityTCPFlowKey(String key) { + return key != null && !key.isEmpty(); } private boolean clampIPv4TCPMSS(byte[] packet, int length, int maxMss) { @@ -2619,6 +2918,10 @@ public class RapVpnService extends VpnService { } private boolean sendUplinkBatchWithRetry(String clusterId, String vpnConnectionId, List batch, int workerIndex) { + Manager manager = fabricVpnManager; + if (manager != null) { + return sendFabricUplinkBatch(manager, batch, workerIndex); + } Exception lastError = null; lastUplinkSendErrorMessage = ""; int relayAttempts = Math.max(1, activePacketRelayUrlsByProfile == null ? 1 : activePacketRelayUrlsByProfile.size()); @@ -2677,6 +2980,108 @@ public class RapVpnService extends VpnService { return false; } + private boolean sendFabricUplinkBatch(Manager manager, List batch, int workerIndex) { + if (manager == null || batch == null || batch.isEmpty()) { + return false; + } + try { + for (byte[] packet : batch) { + if (packet != null && packet.length > 0) { + manager.sendPacket(packet); + } + } + return true; + } catch (Throwable e) { + lastUplinkSendErrorMessage = compactException(e); + writeRuntimeDetail("fabric_send_failed", "fabric QUIC send failed worker=" + workerIndex + " error=" + e.getMessage(), "fabric", -1, 1, e.getClass().getSimpleName(), workerIndex); + return false; + } + } + + private void pumpFabricDownlinkToQueue(long runtimeId) { + long fetchedPackets = 0; + long errors = 0; + while (isRuntimeActive(runtimeId)) { + Manager manager = fabricVpnManager; + if (manager == null) { + sleepQuietly(50); + continue; + } + try { + byte[] packet = manager.receivePacket(DOWNLINK_POLL_MS_MAX); + if (packet == null || packet.length == 0) { + if (fetchedPackets > 0) { + writeRuntimeDetail("idle", fabricSnapshot(), "fabric_downlink", fetchedPackets, errors, ""); + } + continue; + } + if (!isIPv4Packet(packet)) { + recordDownlinkDrop(packet.length); + continue; + } + int length = effectiveIPv4Length(packet, packet.length); + if (length <= 0) { + errors++; + recordDownlinkDrop(packet.length); + writeRuntimeDetail("length_drop", packetSummary(packet, packet.length), "fabric_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) { + recordDownlinkDrop(length); + writeRuntimeDetail("dest_drop", packetSummary(packet, length), "fabric_downlink", fetchedPackets, errors, "DEST_MISMATCH"); + continue; + } + relaxedDownlinkDestinationValidation = true; + } + 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), "fabric_downlink", fetchedPackets, errors, "CHECKSUM_NORMALIZE"); + continue; + } + recordInboundTCPHandshake(packet, length); + if (offerDownlinkPacket(packet, length)) { + fetchedPackets++; + if (fetchedPackets <= 5 || fetchedPackets % 25 == 0) { + writeRuntimeStatus("fabric_downlink", "queued " + packetSummary(packet, length), 0, 0, downlinkReceivedPackets.get(), errors); + } + } else if (running) { + errors++; + recordDownlinkDrop(length); + writeRuntimeDetail("queue_drop", packetSummary(packet, length), "fabric_downlink", fetchedPackets, errors, "QUEUE_FULL"); + } + } catch (Throwable e) { + if (!isRuntimeActive(runtimeId)) { + return; + } + errors++; + writeRuntimeStatus("degraded", "fabric downlink receive failed: " + e.getMessage(), 0, 0, fetchedPackets, errors); + writeRuntimeDetail("receive_failed", "fabric receive failed: " + e.getMessage(), "fabric_downlink", fetchedPackets, errors, e.getClass().getSimpleName()); + sleepQuietly(100); + } + } + } + + private String fabricSnapshot() { + Manager manager = fabricVpnManager; + if (manager == null) { + return "fabric runtime not connected"; + } + try { + return manager.snapshotJSON(); + } catch (Exception e) { + return "fabric snapshot failed: " + e.getMessage(); + } + } + private String lastWebSocketRelayError() { VpnPacketWebSocketRelay relay = packetWebSocketRelay; if (relay == null) { @@ -3407,7 +3812,7 @@ public class RapVpnService extends VpnService { } } - private String compactException(Exception e) { + private String compactException(Throwable e) { if (e == null) { return ""; } @@ -3974,11 +4379,15 @@ public class RapVpnService extends VpnService { String dataplaneEntryNodeId = ""; String dataplaneExitNodeId = ""; String dataplaneSelectedTransport = ""; + 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; final Set configNotes = new LinkedHashSet<>(); final Set dnsServers = new LinkedHashSet<>(); final Set dnsProbeDomains = new LinkedHashSet<>(); diff --git a/clients/android/app/src/main/java/su/cin/rapvpn/RdpActivity.java b/clients/android/app/src/main/java/su/cin/rapvpn/RdpActivity.java deleted file mode 100644 index fd6b3d3..0000000 --- a/clients/android/app/src/main/java/su/cin/rapvpn/RdpActivity.java +++ /dev/null @@ -1,209 +0,0 @@ -package su.cin.rapvpn; - -import android.app.Activity; -import android.graphics.Bitmap; -import android.graphics.BitmapFactory; -import android.os.Bundle; -import android.util.Base64; -import android.view.MotionEvent; -import android.view.View; -import android.widget.FrameLayout; -import android.widget.ImageView; -import android.widget.TextView; - -import org.json.JSONObject; - -import java.nio.ByteBuffer; -import java.nio.ByteOrder; -import java.nio.charset.StandardCharsets; -import java.util.UUID; - -import okhttp3.OkHttpClient; -import okhttp3.Request; -import okhttp3.Response; -import okhttp3.WebSocket; -import okhttp3.WebSocketListener; - -public class RdpActivity extends Activity { - static final String EXTRA_SESSION_RESULT = "session_result"; - static final String EXTRA_GATEWAY_URL = "gateway_url"; - static final String EXTRA_RESOURCE_NAME = "resource_name"; - - private final OkHttpClient http = new OkHttpClient(); - private ImageView desktop; - private TextView overlay; - private WebSocket webSocket; - private int desktopWidth = 1; - private int desktopHeight = 1; - - @Override - protected void onCreate(Bundle savedInstanceState) { - super.onCreate(savedInstanceState); - getWindow().getDecorView().setSystemUiVisibility( - View.SYSTEM_UI_FLAG_FULLSCREEN - | View.SYSTEM_UI_FLAG_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_IMMERSIVE_STICKY - | View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN - | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION - | View.SYSTEM_UI_FLAG_LAYOUT_STABLE); - - FrameLayout root = new FrameLayout(this); - root.setBackgroundColor(0xff05090c); - desktop = new ImageView(this); - desktop.setScaleType(ImageView.ScaleType.FIT_CENTER); - desktop.setBackgroundColor(0xff05090c); - desktop.setOnTouchListener((view, event) -> { - sendTouch(event); - return true; - }); - overlay = new TextView(this); - overlay.setTextColor(0xffffffff); - overlay.setTextSize(14); - overlay.setBackgroundColor(0x66000000); - overlay.setPadding(14, 10, 14, 10); - overlay.setText("Подключение..."); - root.addView(desktop, new FrameLayout.LayoutParams(-1, -1)); - root.addView(overlay, new FrameLayout.LayoutParams(-2, -2)); - setContentView(root); - connect(); - } - - @Override - protected void onDestroy() { - if (webSocket != null) { - webSocket.close(1000, "activity closed"); - } - super.onDestroy(); - } - - private void connect() { - try { - JSONObject result = new JSONObject(getIntent().getStringExtra(EXTRA_SESSION_RESULT)); - JSONObject token = result.getJSONObject("attach_token"); - String attachToken = token.getString("token"); - String gatewayUrl = getIntent().getStringExtra(EXTRA_GATEWAY_URL); - String url = gatewayUrl + "?attach_token=" + attachToken; - runOnUiThread(() -> overlay.setText(getIntent().getStringExtra(EXTRA_RESOURCE_NAME))); - Request request = new Request.Builder().url(url).build(); - webSocket = http.newWebSocket(request, new WebSocketListener() { - @Override - public void onOpen(WebSocket webSocket, Response response) { - runOnUiThread(() -> overlay.setText("Подключено")); - } - - @Override - public void onMessage(WebSocket webSocket, String text) { - handleEnvelope(text); - } - - @Override - public void onFailure(WebSocket webSocket, Throwable t, Response response) { - runOnUiThread(() -> overlay.setText("Ошибка: " + t.getMessage())); - } - - @Override - public void onClosed(WebSocket webSocket, int code, String reason) { - runOnUiThread(() -> overlay.setText("Отключено")); - } - }); - } catch (Exception ex) { - overlay.setText("Ошибка запуска: " + ex.getMessage()); - } - } - - private void handleEnvelope(String text) { - try { - JSONObject envelope = new JSONObject(text); - String type = envelope.optString("type"); - if ("session.state".equals(type)) { - JSONObject payload = envelope.optJSONObject("payload"); - String state = payload == null ? "" : payload.optString("state", ""); - if (!state.isEmpty() && !"active".equals(state)) { - runOnUiThread(() -> overlay.setText("Сессия: " + state)); - } - return; - } - if (!"session.frame".equals(type)) { - return; - } - JSONObject payload = envelope.optJSONObject("payload"); - if (payload == null) { - return; - } - String frameData = payload.optString("frame_data", ""); - int width = payload.optInt("frame_width", payload.optInt("desktop_width", 0)); - int height = payload.optInt("frame_height", payload.optInt("desktop_height", 0)); - byte[] bytes = Base64.decode(frameData, Base64.DEFAULT); - Bitmap bitmap = decodeFrame(bytes, width, height, payload.optString("frame_format", "")); - if (bitmap != null) { - desktopWidth = Math.max(1, width); - desktopHeight = Math.max(1, height); - runOnUiThread(() -> { - desktop.setImageBitmap(bitmap); - overlay.setText(""); - }); - } - } catch (Exception ex) { - runOnUiThread(() -> overlay.setText("Кадр: " + ex.getMessage())); - } - } - - private Bitmap decodeFrame(byte[] bytes, int width, int height, String format) { - Bitmap compressed = BitmapFactory.decodeByteArray(bytes, 0, bytes.length); - if (compressed != null) { - return compressed; - } - if (width <= 0 || height <= 0 || bytes.length < width * height * 4) { - return null; - } - int[] colors = new int[width * height]; - ByteBuffer buffer = ByteBuffer.wrap(bytes).order(ByteOrder.LITTLE_ENDIAN); - for (int i = 0; i < colors.length; i++) { - int b = buffer.get() & 0xff; - int g = buffer.get() & 0xff; - int r = buffer.get() & 0xff; - int a = buffer.get() & 0xff; - colors[i] = (a << 24) | (r << 16) | (g << 8) | b; - } - return Bitmap.createBitmap(colors, width, height, Bitmap.Config.ARGB_8888); - } - - private void sendTouch(MotionEvent event) { - if (webSocket == null || desktop.getWidth() <= 0 || desktop.getHeight() <= 0) { - return; - } - String action; - switch (event.getActionMasked()) { - case MotionEvent.ACTION_DOWN: - action = "down"; - break; - case MotionEvent.ACTION_UP: - action = "up"; - break; - case MotionEvent.ACTION_MOVE: - action = "move"; - break; - default: - return; - } - double x = Math.max(0, Math.min(1, event.getX() / Math.max(1f, desktop.getWidth()))); - double y = Math.max(0, Math.min(1, event.getY() / Math.max(1f, desktop.getHeight()))); - try { - JSONObject payload = new JSONObject(); - payload.put("correlation_id", UUID.randomUUID().toString()); - payload.put("client_captured_at", java.time.Instant.now().toString()); - payload.put("kind", "mouse"); - payload.put("action", action); - payload.put("button", "left"); - payload.put("normalized_x", x); - payload.put("normalized_y", y); - payload.put("surface_width", desktopWidth); - payload.put("surface_height", desktopHeight); - JSONObject envelope = new JSONObject(); - envelope.put("type", "input"); - envelope.put("payload", payload); - webSocket.send(envelope.toString().getBytes(StandardCharsets.UTF_8).length > 0 ? envelope.toString() : "{}"); - } catch (Exception ignored) { - } - } -} diff --git a/docs/architecture/CLUSTER_NODE_ADMIN_FOUNDATION.md b/docs/architecture/CLUSTER_NODE_ADMIN_FOUNDATION.md index 9476eb1..f7ee761 100644 --- a/docs/architecture/CLUSTER_NODE_ADMIN_FOUNDATION.md +++ b/docs/architecture/CLUSTER_NODE_ADMIN_FOUNDATION.md @@ -88,6 +88,16 @@ Native host process responsible for node identity, enrollment, certificates, hea Service Workload: A workload executed on a node. It may be native or containerized. Examples: `rdp-worker`, `vnc-worker`, `entry-node`, `relay-node`, `file-storage-cache`. +Public/Admin HTTPS Ingress: +A service-edge role that listens on TCP `80`/`443` for browser/API HTTPS and +forwards accepted requests into the QUIC-only fabric service channel. It is not +an authority service and does not imply permission to manage the cluster. + +Admin UI Runtime: +A scoped admin service runtime. Global admin runtime may run only on +platform-owner trusted nodes; cluster, organization, and user portal runtimes +receive only their scoped projections. + Capability: What a node can technically do. Example: `can_run_rdp_worker`. @@ -162,6 +172,13 @@ policy, approvals, and audit. 20. Node-agent is the local supervisor for health, restart, update, and rollback of node services, but Control Plane owns rollout policy and durable schema migration orchestration. +21. HTTP/HTTPS is an external service edge only. Fabric node-to-node transport + remains QUIC-only. +22. A node that accepts `443` does not own management authority. Admin authority + belongs to signed roles, scoped claims, policy, and trusted runtime nodes. +23. Global admin runtime, policy authority, and audit sink must run only on + platform-owner controlled nodes. Organization and cluster portals must not + expose unrelated tenants, clusters, or internal mesh topology. ## Existing Node Management Semantics diff --git a/docs/architecture/DISTRIBUTED_AUTHORITY_AUDIT_2026-05-16.md b/docs/architecture/DISTRIBUTED_AUTHORITY_AUDIT_2026-05-16.md new file mode 100644 index 0000000..8ace597 --- /dev/null +++ b/docs/architecture/DISTRIBUTED_AUTHORITY_AUDIT_2026-05-16.md @@ -0,0 +1,96 @@ +# Distributed Authority Audit 2026-05-16 + +Status: target architecture is distributed, but the live test cluster still has +bootstrap central authority pieces that must be removed before production trust. + +## Fixed Requirements + +- No single management/API/storage/update service is allowed to own cluster + truth. +- Control, storage, update, route authority, observer, and update-cache are node + roles in the fabric. +- A service endpoint can serve signed state, but cannot create trusted state by + itself. +- Node identity is cryptographic. IP addresses, DNS names, and NAT addresses are + endpoint candidates only. +- Nodes must publish real signed candidates for reachable interfaces, + STUN/ICE-reflexive addresses, passive reverse channels, and relay fallback. +- Nodes must verify signed control data locally before applying it. + +## Live Cluster Findings + +- The live cluster has one active `cluster_authorities` row: + `rap-ca-ed25519-09877466aa9b6b58b0f312b0b313ea33`. +- Its metadata says `storage=database_signer` and + `production_target=external_cluster_signer_or_hsm`. +- Release metadata for recent node-agent versions is signed, but signed by the + same database-backed authority. +- Synthetic mesh configs are signed and node-agent verifies them against the + pinned cluster authority. +- Node enrollment pins cluster authority into `identity.json`. +- Before this audit, host-agent update plans were carried with signatures but + host-agent did not locally reject unsigned plans when a pinned authority was + present. + +## Changes Made In This Audit + +- The fabric docs now declare distributed authority and quorum as mandatory. +- Node/fabric endpoints must be explicit `host:port`; DNS-only service names are + rejected as fabric endpoints. +- `home-1` no longer advertises `smoke.cin.su` as a fabric endpoint. It now + advertises its real interface candidate `quic://192.168.200.85:18080`. +- Host-agent now verifies `node_update_plan` authority signatures when + `identity.json` contains a pinned cluster authority public key. +- Unsigned update plans are rejected in that pinned-authority mode. +- Added `rap.cluster_authority.quorum.v1` and + `rap.cluster_authority.quorum_envelope.v1` contracts to both agent and + backend authority packages. +- Host-agent can now verify quorum-signed update plans when `identity.json` + contains a pinned quorum descriptor. +- Backend update plans now include an `authority_quorum` envelope when the + cluster authority metadata contains a quorum descriptor. If that configured + quorum cannot be satisfied, the update plan is not issued. +- Node bootstrap now carries `cluster_authority_quorum`; the approval authority + payload signs the quorum descriptor hash, and node-agent persists the + descriptor into `identity.json` after verifying the signed hash. +- Published `rap-node-agent` and `rap-host-agent` release + `0.2.284-quorumauthority`. +- Canaried `home-1` to `rap-node-agent 0.2.284-quorumauthority` and + `rap-host-agent 0.2.284-quorumauthority`; both reported healthy/noop after + update. +- Published `rap-node-agent` and `rap-host-agent` release + `0.2.285-quorumbootstrap`. +- Canaried `home-1` to `rap-node-agent 0.2.285-quorumbootstrap` and + `rap-host-agent 0.2.285-quorumbootstrap`; both reported current=target/noop. + `ifcm-rufms-s-mo1cr` was intentionally not updated because it is behind NAT + and still needs fabric/update-cache artifact reachability before further + rollout. + +## Remaining Production Blockers + +- Replace `database_signer` with quorum authority: + M-of-N signatures from nodes or hardware/offline keys with + `control-authority` / `update-authority` roles. +- Store authority descriptors and role certificates as replicated signed state, + not only database rows. +- Require quorum envelopes for the remaining high-risk mutations: role + mutation, release creation, update policy mutation, route lease issuance, + relay/rendezvous lease issuance, storage placement, and authority rotation. + Node update plans and bootstrap quorum pinning now have the first contract + hooks, but production still needs real M-of-N signers. +- Add node-side verification of release metadata in addition to update-plan + verification; update-plan verification is now enforced by host-agent when a + pinned authority or pinned quorum descriptor exists. +- Add update-cache mirror selection through fabric endpoint candidates instead + of a single HTTP origin. +- Add signed endpoint-candidate epochs so peer directory gossip can survive API + replica loss. +- Add revocation/fencing epochs for compromised authority keys, nodes, and + update artifacts. + +## Acceptance Rule + +The cluster is not production-trust-ready while a single `database_signer` can +create authoritative cluster mutations. It may remain as a development bootstrap +signer only when every signed payload clearly identifies it as bootstrap and +nodes can be configured to reject it in production mode. diff --git a/docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md b/docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md index dbd49f7..b6874e5 100644 --- a/docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md +++ b/docs/architecture/DISTRIBUTED_FABRIC_NODE_PROTOCOL_PLAN.md @@ -62,6 +62,88 @@ route and stream semantics. 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. +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. + A web/API endpoint is only an access replica for a signed state log, not the + owner of cluster truth. +10. IP addresses and DNS names are never authority. Nodes announce signed + endpoint candidates for every usable interface, public/reflexive address, + local segment address, reverse channel, and relay fallback. Neighbors select + the usable candidate locally by policy, reachability, latency, load, and + trust. + +## Distributed Control And Trust + +The target fabric behaves like a distributed network, not a client/server +management product. The cluster has a replicated signed state log and many +service replicas. Any node with the right role can serve API, storage, update, +or route-coordinator duties, but no single replica can mutate cluster authority +alone. + +Required trust model: + +- Every node has a long-lived node identity key and short-lived role + certificates. The node identity is cryptographic; the current IP, hostname, + NAT address, or container name is only an endpoint candidate. +- Cluster authority is threshold-based. Root or high-risk changes require M-of-N + signatures from authorized control-authority nodes or hardware/offline + operator keys. +- Role certificates are scoped by action, organization/tenant, service, + partition, validity window, and allowed delegation depth. +- Update releases, route leases, peer-directory epochs, storage shard placement, + node approvals, role changes, and authority rotations are signed records in + the state log. +- A node accepts control data only when it can verify signatures, epoch/fencing, + expiry, target cluster, target node or role scope, and monotonic generation. +- A compromised API replica can withhold or delay data, but cannot forge updates, + route authority, new certificates, node roles, or cluster ownership. +- Bootstrap may use a temporary centralized signer for development, but + production mode must mark that signer as non-authoritative unless quorum + signatures are present. + +Authority levels: + +- `root-authority`: rotates cluster root and quorum membership. Offline or + hardware-backed where possible. Rarely online. +- `control-authority`: approves node join, role changes, policy epochs, and + route-authority membership through quorum. +- `route-authority`: signs short-lived route leases and relay/rendezvous + assignments for a shard or partition. +- `update-authority`: signs release metadata, compatibility, artifact hashes, + rollback windows, and staged rollout policy. +- `storage-authority`: signs storage shard manifests, replication factors, + retention policy, and recovery epochs. +- `observer-authority`: can sign telemetry observations only; it cannot mutate + routing, roles, updates, or secrets. + +Required anti-takeover controls: + +- No bearer admin token may grant fabric-wide mutation without a signed authority + envelope. +- No node may accept unsigned update metadata or an artifact whose hash is not + signed by update-authority quorum. +- No node may accept unsigned route changes for production channels. +- No node may promote itself into control, storage, update, relay, or route + authority roles without a quorum-signed role certificate. +- Authority and role certificates must have short validity, explicit scopes, and + revocation/fencing epochs. +- Nodes must pin the cluster root/quorum descriptor and reject unexpected root + changes unless the old quorum signs the transition or an offline recovery + policy is invoked. + +Endpoint state is also distributed: + +- Nodes publish signed endpoint-candidate sets containing local interfaces, + public/reflexive STUN/ICE candidates, NAT group/local segment identifiers, + relay fallback, and passive reverse-channel availability. +- Endpoint candidates expire quickly. When a node changes IP, it reconnects + passively to any reachable fabric peer or API replica and publishes a new + signed candidate epoch. +- Peers keep using cached valid candidates and route leases while refreshing + from any reachable replica or neighbor gossip path. +- Neighbor selection is local and latency/load-aware; the state log announces + facts and policy, not a forced single next hop. ## Node Roles diff --git a/docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md b/docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md new file mode 100644 index 0000000..925b236 --- /dev/null +++ b/docs/architecture/FABRIC_FIRST_TRANSPORT_AND_STRESS_PLAN.md @@ -0,0 +1,845 @@ +# Fabric-First Transport And Stress Plan + +Status: fabric-first implementation baseline is active. QUIC-only transport, +route planning, runtime reroute/failover, pressure accounting, shared-host +stress gates, 1000-channel load, failure/degradation gates, and a 30-minute +real-byte soak are implemented and verified. Remaining work is wider real +topology coverage as the cluster grows. + +This project is now fabric-first. Work on service payloads, service adapter +expansion, and Android VPN transport is paused until the fabric transport layer +is complete and proven under real load. + +## Goal + +The fabric is a distributed QUIC overlay over the current IPv4 network. Nodes +may have public addresses, sit behind NAT, or represent a whole local segment +behind one NAT. The fabric must expose a single logical transport layer where +nodes can reach each other directly, through local segment paths, through +passive outbound tunnels, or through relay hops without changing the data-plane +protocol. + +QUIC is the only data-plane protocol. Direct, LAN, relay, reverse, and +ICE-selected paths are route modes inside the same QUIC fabric, not alternative +transports. + +The fabric must not depend on one management service for authority. API, +storage, update-cache, route-coordinator, observer, and authority duties are +roles inside the mesh. A reachable API endpoint can distribute signed state, but +it cannot be the source of truth by itself. Nodes accept control data, +configuration, route leases, update plans, and role changes only when the +signatures, quorum rules, scopes, epochs, and expiry windows verify locally. + +## Required Fabric Behavior + +- Address channels by `node_id`, `pool_id`, or service target, not by raw IP. +- Keep endpoint candidates for public QUIC, LAN QUIC, reverse/passive QUIC, + relay QUIC, and future ICE-derived QUIC paths. +- Treat DNS names such as web/admin/API domains as service endpoints only, not + node identity or fabric authority. +- Require node-published endpoint candidates to include explicit `host:port`, + reachability, connectivity mode, NAT/local-segment metadata, source, and + freshness. +- Prefer local segment paths for nodes that share a NAT/local network. +- Keep outbound passive QUIC control/data adjacencies from NATed nodes to + reachable public or relay nodes. +- Build logical channels over shared QUIC adjacencies instead of opening one + physical QUIC connection per channel. +- Maintain primary, warm standby, and fallback route sets per channel. +- Rebuild a channel when an intermediate hop fails. +- Switch to another pool member when the target is a pool and the current + endpoint fails. +- Reroute slow channels when a faster path exists and the reroute will not harm + aggregate fabric throughput. +- Spread channels across available routes so the shortest path is not saturated + while other nodes are idle. +- Isolate channels with per-channel flow control, traffic classes, backpressure, + quotas, and fairness scheduling. +- Report per-node, per-link, per-route, and per-channel load and failure causes. + +## Service Channel Boundary + +The fabric is the only component that builds and maintains transport channels. +VPN, RDP, SSH, web ingress, file transfer, and future adapters are applications +above the fabric. They must not select raw QUIC endpoints, pin exit nodes as a +transport concern, open fallback transports, or implement route repair. + +Every service starts by submitting a fabric service channel request: + +```json +{ + "schema_version": "rap.fabric_service_channel_request.v1", + "channel_id": "vpn-session-or-service-session-id", + "source_role": "vpn-client | rdp-client | service-adapter", + "service_class": "vpn_packets | rdp | ssh | file_transfer | web", + "target": { + "kind": "pool", + "pool_ids": ["home-ipv4"], + "service_role": "ipv4-egress" + }, + "traffic": { + "mode": "duplex", + "application_protocol_agnostic": true, + "flow_distribution": "latency_and_load_aware" + }, + "resilience": { + "min_active_paths": 1, + "warm_standby_paths": 1, + "failover": "pool_member_or_next_authorized_pool", + "reroute_on": ["route_failure", "latency_regression", "loss_regression", "backpressure"] + } +} +``` + +The fabric responds with a signed route bundle containing a short-lived +`rap.fabric_route_lease.v1`. The lease names the target pool, primary path, +warm standby paths, multipath hints, and rebuild policy. Physical endpoint +candidates are visible only to the fabric runtime as lease material; service +adapters do not rank, pin, or fail over endpoints themselves. A service adapter +receives only a duplex channel handle and service metadata: + +- Android VPN: TUN packet reader/writer only. +- `ipv4-egress`: NAT/ordinary IPv4 exit only. +- RDP: protocol/session adapter only; server address, protocol, credentials, + rendering, and clipboard are RDP service metadata, not fabric routing. + +Temporary compatibility fields such as `exit_candidates` may exist only inside +the fabric route bundle consumed by the fabric runtime. Service code must treat +them as opaque and must not schedule routes from them. + +The VPN client runtime accepts only `fabric_service_channel_request` plus +`fabric_route_bundle.route_lease`. The Android service may keep a deprecated +diagnostic endpoint cache, but packet routing must come from the lease. If a +path fails, slows down, or its target pool member dies, the fabric lease/rebuild +policy is the authority; the VPN service continues writing packets to the +channel and does not switch protocols. + +## Distributed Authority Requirements + +- No single control-plane/API/storage/update node can mutate the cluster alone. +- Cluster root and high-risk role changes require threshold signatures from + authorized control-authority keys. +- Update releases require signed metadata, signed artifact hashes, compatibility + constraints, rollout scope, and rollback windows; mirrors may serve bytes but + cannot change what is trusted. +- Route leases, relay leases, rendezvous assignments, peer-directory epochs, and + endpoint candidate epochs are signed and short-lived. +- Nodes cache the last valid signed state and continue routing through peers, + relay fallbacks, and passive reverse channels when API replicas are down. +- A compromised replica may delay or omit data, but must not be able to forge + role assignment, route authority, update authority, storage placement, or node + ownership. +- Development `database_signer` mode is not production authority. Production + acceptance requires quorum-signed envelopes for node join, role mutation, + mesh config, route leases, update plans, and release metadata. + +## Implementation Layers + +1. Discovery layer: STUN/ICE, LAN candidates, public candidates, reverse + tunnels, relay candidates. +2. Fabric adjacency layer: long-lived QUIC neighbor sessions with capacity, + health, and pressure metrics. +3. Routing layer: latency-aware and load-aware route sets with relay fallback + and pool failover. +4. Channel layer: millions of logical channels with independent lifecycle, + flow control, and statistics. + +## Stress Requirements + +The fabric is not accepted by ping tests. It must pass real byte-transfer load: + +- 1000 concurrent streams from different source nodes to different destination + nodes. +- Mixed long-lived and short-lived channels. +- Aggressive create/delete churn. +- many-to-one, one-to-many, and many-to-many traffic. +- direct, LAN, relay, multi-hop, and reverse tunnel paths. +- endpoint pool failover under load. +- intermediate relay/node failure and route rebuild under load. +- induced latency, packet loss, bandwidth caps, and route saturation. +- control/interactive traffic surviving bulk traffic. +- no sustained overload of one path when alternatives exist. +- no goroutine, memory, stream, or file descriptor leak after churn. + +## Required Stress Report + +Every stress run must produce machine-readable JSON with: + +- topology and scenario profile; +- channel setup/teardown counts and latency; +- total and per-channel throughput; +- per-node and per-route capacity pressure; +- p50/p95/p99 latency where measured; +- backpressure, rejection, and queue-depth counters; +- route switch and failover events; +- target pool failover events; +- QUIC connection and logical channel counts; +- final pass/fail verdict against SLO thresholds. + +The first executable harness is `agents/rap-node-agent/cmd/fabric-loadtest`. +It supports in-process multi-node QUIC targets, short logical channel churn, +pool failover, target failure injection, and JSON reports. + +Example local pool-failover run: + +```powershell +go run ./cmd/fabric-loadtest -mode all -nodes 4 -streams 400 -concurrency 64 -bytes-per-stream 262144 -payload-size 16384 -fail-target 0 -fail-after 1ms -timeout 60s +``` + +The local harness is not a replacement for distributed host testing. It is the +first acceptance gate for protocol limits, channel lifecycle churn, pool +failover semantics, and reporting shape before running the same workload across +the shared test Docker host. + +Distributed shared-host smoke: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts/fabric/fabric-loadtest-docker-smoke.ps1 -DockerContext test-docker -TuneUdpBuffers -Nodes 4 -Streams 400 -Concurrency 64 -BytesPerStream 262144 -PayloadSize 16384 -FailTarget 0 -FailAfter 100ms +``` + +The distributed smoke builds/runs separate server and client containers on the +shared Docker host, sends real QUIC fabric frames across the Docker network, +kills one target node during load, and expects all channels assigned to that +target to fail over to the remaining pool. + +The smoke summary includes the strict loadtest verdict plus `route_pressure` +and `transport_snapshot`; the script fails when the client verdict is not +`pass` and carries `verdict_reasons` into the thrown error. + +`-TuneUdpBuffers` applies runtime host sysctls through a privileged one-shot +container before the run and records the observed values in the summary: +`net.core.rmem_max`, `net.core.wmem_max`, `net.core.rmem_default`, and +`net.core.wmem_default`. + +Degraded-target and latency-aware admission run: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts/fabric/fabric-loadtest-docker-smoke.ps1 -DockerContext test-docker -TuneUdpBuffers -Nodes 4 -Streams 300 -Concurrency 64 -BytesPerStream 131072 -PayloadSize 16384 -FailTarget -1 -ImpairTarget 0 -ImpairDelay 100ms -ImpairLoss 0.5% -ProbeTargets -MaxTargetRttMs 80 +``` + +This applies `tc netem` to one target, probes every target before mass channel +placement, excludes targets above the RTT threshold, and reports per-target +setup/duration percentiles. This is the first executable gate for +latency-aware placement; live channel migration after mid-stream degradation is +the next routing-layer gate. + +Mid-stream migration gate: + +```powershell +powershell -ExecutionPolicy Bypass -File scripts/fabric/fabric-loadtest-docker-smoke.ps1 -DockerContext test-docker -TuneUdpBuffers -Nodes 4 -Streams 80 -Concurrency 16 -BytesPerStream 8388608 -PayloadSize 65536 -FailTarget -1 -ImpairTarget 0 -ImpairDelay 200ms -ImpairLoss 0.5% -ImpairAfter 50ms -MigrateSlowStreams -MaxAckMs 30 +``` + +This starts channels normally, applies `tc netem` after traffic is already in +flight, and expects slow logical streams to continue their remaining bytes on a +different target. The report exposes `migration_events`, `max_ack_ms`, +`ack_p95_ms`, `ack_p99_ms`, `route_attempts_total`, `reroute_causes`, and +per-target stats. + +Production fabric-core migration boundary: + +- `FabricChannelRouter` opens channels on the best route from a `FabricRouteSet`. +- Live `FabricChannelObservation` values update counters and trigger reroute on + route failure, ACK latency threshold, or capacity pressure. +- Reroutes switch route binding and pool target where applicable, increment + `RerouteCount`, and emit `FabricChannelRouteEvent`. +- `MinRerouteInterval` provides hysteresis so a noisy path does not cause route + flapping. +- `FabricChannelRuntime` binds the router to live QUIC fabric sessions for + reliable byte payloads: it opens the logical stream, sends frames, measures + ACK latency, reports observations to the router, and continues remaining + payloads on a rerouted QUIC route after connect failure or slow ACKs. +- QUIC logical session close cancels the stream read side before closing the + write side, so high-churn short sessions release reader goroutines promptly + instead of waiting for stream read deadlines. +- Server-side QUIC stream handlers close their write side when the handler + exits. This returns QUIC stream credit promptly during high-churn short + sessions and prevents the last worker window from stalling on stream open. +- Production request/response forwarding now builds a `FabricRouteSet` from all + QUIC endpoint candidates for the next hop, sends the envelope over the chosen + QUIC route, and reroutes to warm standby/fallback QUIC candidates on connect + failure or response timeout. +- The legacy HTTP production forward carrier has been removed from the mesh + runtime API. Production forwarding now exposes a single QUIC transport + implementation; HTTP handlers remain only as node-local API surfaces and test + harness entry points. +- Production route choice includes live per-route active-channel pressure, so + concurrent forwarding requests can spread across equivalent QUIC candidates + instead of concentrating on the first/shortest route until it is saturated. +- Production forwarding also keeps per-route health quarantine. A QUIC route + that fails connect or response is marked unhealthy for a bounded retry window, + skipped by subsequent channel scheduling, exposed in route-health snapshots, + and restored automatically after the retry window or a successful send. +- `FabricRoutePressureTracker` provides shared active-channel accounting for + both production request/response forwarding and bulk `FabricChannelRuntime` + traffic, so different traffic surfaces can make route decisions against the + same live load signal. +- Route pressure is observable through `FabricRoutePressureSnapshot`, including + current active channels, max active channels, total acquire/release counts, + and last acquired/released route IDs. Bulk runtime results and production + QUIC forwarding snapshots expose this data for stress reports. +- `fabric-loadtest` reports route IDs per stream attempt, global + `route_pressure`, and per-target `max_active_channels`, so stress runs can + verify channel distribution and release accounting after churn. +- `FabricRouteSetForPeerEndpointCandidates` converts QUIC endpoint candidates + into production route sets for direct, LAN, ICE/STUN-derived, reverse + outbound, and relay fallback modes. Non-QUIC candidates are rejected instead + of becoming alternate transports. +- Node-agent discovery now advertises multiple QUIC candidates in one heartbeat + instead of collapsing to one address: operator/public QUIC, listener QUIC, + 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. +- 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 + capacity pressure; HTTP/WebSocket labels are treated as rejected legacy + candidates rather than alternate transports. +- `FabricTransportForTarget` no longer accepts a WebSocket carrier. Transport + selection can return only `QUICFabricTransport`; unsupported labels fail with + a QUIC-required error. +- Explicit transport labels are authoritative. A legacy label such as `relay` + or `outbound_reverse` is rejected even when the endpoint string uses a + `quic://` scheme; configs must use `relay_quic` and `reverse_quic`. +- Node-agent config loading rejects legacy advertised transport labels and + HTTP/WebSocket advertised endpoint schemes for mesh, STUN-reflexive, and relay + fabric endpoints. Bad endpoint posture fails before heartbeat publication. +- Host-agent install/runtime validation rejects legacy mesh advertise transport + labels and HTTP/WebSocket advertise endpoints before they can be passed into a + node-agent Docker runtime. +- JSON-advertised endpoint candidates and scoped synthetic config route + recovery surfaces are hard-fail QUIC-only: endpoint candidates, recovery + seeds, and rendezvous leases reject legacy transport labels and + HTTP/WebSocket endpoint schemes instead of silently downgrading or dropping + entries. +- Rendezvous relay leases and peer-connection intents now use `relay_quic` as + the transport label. `relay_control` remains only a telemetry/control-state + name for rendezvous admission counters, not a data-plane transport option. +- Peer connection health probing is aligned with the QUIC fabric: QUIC endpoint + candidates are probed with QUIC session setup, pinned certificate metadata is + honored, and HTTP/WebSocket endpoint schemes are rejected instead of being + used as peer health transport. +- Node-agent synthetic runtime no longer installs an HTTP peer transport as an + inter-node carrier, and the shared mesh runtime package no longer exports an + HTTP peer transport implementation. Any HTTP synthetic motion is confined to + explicit legacy smoke harness code while fabric acceptance uses QUIC loadtest + gates. +- Control-plane and debug JSON mesh config loading is validated after + conversion into runtime structures. Peer endpoint candidates, recovery seeds, + rendezvous leases, and selected relay endpoints in route decisions must use + QUIC labels/endpoints before they can update node runtime state. +- Scoped synthetic mesh configs also reject legacy `peer_endpoints` directly, + in addition to QUIC-only checks for endpoint candidates, recovery seeds, and + rendezvous leases. +- The old fabric-session WebSocket endpoint is no longer exposed by + `FabricSessionEnabled` alone. It requires an explicit legacy test harness flag + and is not part of the node-agent fabric transport surface. +- Same local segment or same NAT group is treated as a LAN route by the planner, + so a whole cluster piece behind one NAT can prefer private addresses between + its own nodes while still maintaining outbound/relay visibility to the rest + of the fabric. +- Heartbeat telemetry includes `fabric_runtime_report` with QUIC-only status, + route-set counts, QUIC candidate totals, rejected legacy/non-QUIC candidate + totals by transport label, route pressure, QUIC listener state, goroutines, + heap usage, and the next recommended soak gate. +- `FabricOverlayTransport` is the generic service-neutral send facade over + route sets, `FabricChannelRuntime`, shared route pressure, and QUIC sessions. + New traffic classes should enter the fabric through this layer or an + equivalent runtime integration, not through HTTP/WebSocket fallbacks. +- `FabricChannelRuntime` uses the same route health quarantine as production + forwarding. Connect failures, stream send failures, and missing ACKs mark a + route unhealthy for a bounded retry window, so later channels for any traffic + class avoid that route until it recovers. +- `FabricOverlayTransport` exposes route pressure and route health snapshots, + and node heartbeat runtime metadata reports production route health plus the + current quarantined route count. +- Scheduler resource guardrails include `HardMaxRoutePressure`: when enabled, + a route whose projected active-channel pressure exceeds the threshold is not + admitted. This makes overload prevention enforceable in route choice rather + than only observable after the fact. +- The loadtest verdict fails on route-pressure leaks, acquire/release mismatch, + missing acquire accounting, active channels above configured concurrency, or + target distribution collapse/skew when multiple targets are healthy. +- Continuous soak aggregation is bounded: `fabric-loadtest` keeps exact + counters, per-target totals, route-mode counts, error/reroute totals, and + bounded latency samples, while `stream_samples` is capped to diagnostic + examples. Long 30-120 minute runs should not retain one result object per + logical channel. +- `fabric-loadtest` also keeps bounded `error_samples`, so high-volume churn + reports preserve representative failed logical channels even when the first + retained `stream_samples` are all successful. +- Mixed topology verdicts require route-mode coverage when at least four + healthy targets are present. A `mixed-public-nat-lan-relay` or + `nat-lan-relay` run fails if it does not exercise `lan_quic`, `ice_quic`, + `reverse_quic`, and `relay_quic`. +- Loadtest verdicts also fail on legacy route-mode labels. Seeing `relay`, + `outbound_reverse`, `direct_http`, `direct_https`, `direct_tcp_tls`, `ws`, + `wss`, or `websocket` in route-mode telemetry is treated as a transport-layer + violation even if payload delivery succeeds. +- Healthy multi-target verdicts check both stream distribution and byte + distribution. This prevents a run from passing with equal channel counts but + most bulk bytes concentrated on one target or route. +- Healthy multi-target verdicts also check route-pressure distribution through + per-route `max_active` values. A run fails if live concurrent channel load + collapses onto one target/route while alternatives are healthy. +- Successful logical channels must receive one ACK per transmitted data frame. + `fabric-loadtest` reports `ack_mismatched_streams`, per-target + `acks_received`, and fails verdict when any stream is marked successful with + fewer ACKs than sent frames. +- ACK payloads carry the SHA-256 checksum of the received data-frame payload. + `fabric-loadtest` validates the checksum for every ACK and fails verdict with + `ack_integrity_errors` when the acknowledged bytes do not match the sent + payload. +- Failover accounting separates `abandoned_frames` from true ACK mismatch. A + frame sent on a route that dies before ACK is counted as abandoned and the + unacknowledged byte range is retransmitted on the next pool member; verdict + still fails when non-abandoned frames are missing ACKs. +- Loadtest data frames use deterministic per-frame payloads derived from stream + index, logical stream ID, sequence, and byte offset. This makes checksum ACKs + validate each frame identity instead of repeatedly validating one shared + buffer pattern. +- Mixed bulk/control stress is supported with `-control-every`, + `-control-bytes-per-stream`, and `-max-control-ack-p95-ms`. Reports include + `control_streams`, `bulk_streams`, `control_ack_p95_ms`, and + `bulk_ack_p95_ms`; verdict fails when control ACK p95 exceeds the configured + SLO. +- Verified shared-host mixed smoke: + `powershell -ExecutionPolicy Bypass -File scripts/fabric/fabric-loadtest-docker-smoke.ps1 -DockerContext test-docker -Nodes 2 -Streams 40 -Concurrency 8 -BytesPerStream 65536 -PayloadSize 8192 -FailTarget -1 -ControlEvery 5 -ControlBytesPerStream 4096 -MaxControlAckP95Ms 100`. + The run produced 40/40 successful streams, 8 control streams, + `control_ack_p95_ms=1`, `bulk_ack_p95_ms=2`, + `route_pressure.active_total=0`, and matching acquire/release counts. +- Verified shared-host mixed failover stress: + `powershell -ExecutionPolicy Bypass -File scripts/fabric/fabric-loadtest-docker-smoke.ps1 -DockerContext test-docker -SkipBuild -TuneUdpBuffers -Nodes 4 -Streams 1000 -Concurrency 128 -BytesPerStream 1048576 -PayloadSize 65536 -FailTarget 0 -FailAfter 100ms -ControlEvery 20 -ControlBytesPerStream 4096 -MaxControlAckP95Ms 100`. + Latest run `fabric-loadtest-20260516-160751` produced 1000/1000 successful + streams, 250 failover events after the planned target kill, 50 control + streams, `control_ack_p95_ms=3`, `bulk_ack_p95_ms=6`, `ack_p95_ms=6`, + `ack_p99_ms=8`, `route_attempts_total=1250`, + `route_pressure.active_total=0`, `max_active_total=128`, and matching + acquire/release counts. Full JSON artifacts are written under + `artifacts/fabric-loadtest`. +- Verified shared-host mixed degradation/migration stress: + `powershell -ExecutionPolicy Bypass -File scripts/fabric/fabric-loadtest-docker-smoke.ps1 -DockerContext test-docker -TuneUdpBuffers -Nodes 4 -Streams 200 -Concurrency 32 -BytesPerStream 8388608 -PayloadSize 65536 -FailTarget -1 -ImpairTarget 0 -ImpairDelay 200ms -ImpairLoss 0.5% -ImpairAfter 50ms -MigrateSlowStreams -MaxAckMs 30 -ControlEvery 10 -ControlBytesPerStream 4096 -MaxControlAckP95Ms 100`. + The run produced 200/200 successful streams, 9 migration events, + 20 control streams, `control_ack_p95_ms=2`, `bulk_ack_p95_ms=7`, + `route_pressure.active_total=0`, `max_active_total=32`, and matching + acquire/release counts. +- Latest shared-host degradation/migration gate: + `fabric-loadtest-20260516-160710` with 160 streams, 32 concurrency, 4 MiB + bulk streams, 180 ms + 0.5% induced impairment after 50 ms produced 160/160 + successful streams, 12 slow-ACK migrations, degraded-target quarantine, + `control_ack_p95_ms=3`, `bulk_ack_p95_ms=180`, + `route_pressure.active_total=0`, `max_active_total=32`, and matching + acquire/release counts. +- Short shared-host soak gate: + `fabric-loadtest-20260516-160943` with `-Duration 45s`, 1200 streams, + 96 concurrency, four healthy targets, and mixed control/bulk traffic produced + 1200/1200 successful streams, even 300/300/300/300 target distribution, + `channel_opens=1200`, `channel_closes=1200`, `channel_leaks=0`, + `control_ack_p95_ms=4`, `ack_p95_ms=5`, `ack_p99_ms=8`, + `route_pressure.active_total=0`, `max_active_total=96`, and matching + acquire/release counts. +- Continuous soak mode is now explicit: add `-Soak -Duration 30m` or + `-Soak -Duration 120m` to the Docker runner. In soak mode workers keep + creating and closing logical channels until the duration expires, instead of + stopping after a fixed stream list. This is the required gate for memory, + goroutine, file descriptor, QUIC stream, and route-pressure stability. +- Soak duration stops new logical channel creation but does not cancel channels + already in flight. In-flight channels drain under their per-channel + `-StreamTimeout`; the outer `-ClientTimeout` remains the hard scenario + guardrail. This prevents the final active window from being counted as + failed streams just because the soak timer expired. +- Recommended real-topology soak command: + `powershell -ExecutionPolicy Bypass -File scripts/fabric/fabric-loadtest-docker-smoke.ps1 -DockerContext test-docker -SkipBuild -TuneUdpBuffers -Nodes 4 -Streams 1000 -Concurrency 128 -BytesPerStream 1048576 -PayloadSize 65536 -TopologyProfile mixed-public-nat-lan-relay -Soak -Duration 30m -ResourceSampleInterval 10s -MaxGoroutineDelta 64 -MaxHeapDeltaMB 512 -FailTarget -1 -ControlEvery 20 -ControlBytesPerStream 4096 -MaxControlAckP95Ms 100`. +- Soak reports include `resource_samples` and `resource_summary` with + goroutine start/end/max/delta, heap allocation start/end/max/delta, heap + objects, open file descriptor start/end/max/delta, GC delta, max active QUIC + streams, and max active route load. + Optional verdict gates `-MaxGoroutineDelta` and `-MaxHeapDeltaMB` fail the + run if resource drift exceeds the configured budget. +- Optional file descriptor verdict gates `-MaxOpenFDDelta` and `-MaxOpenFDs` + are passed through the Docker runner to `fabric-loadtest` as + `-max-open-fd-delta` and `-max-open-fds`. On Linux containers these read + `/proc/self/fd` and fail the run if descriptor count drifts or peaks beyond + the configured budget. +- Optional throughput SLO gate `-MinThroughputMbps` is passed through the Docker + runner to `fabric-loadtest` as `-min-throughput-mbps`. It fails verdict when + useful data-plane throughput falls below the configured Mbps floor. +- Optional short-session churn SLO gate `-MinChannelChurnPerSec` is passed + through the Docker runner to `fabric-loadtest` as + `-min-channel-churn-per-sec`. It fails verdict when logical channel + open/close throughput falls below the configured channel-per-second floor. +- Each logical channel has a per-channel timeout through `-StreamTimeout` + in the Docker runner and `-stream-timeout` in `fabric-loadtest`. This keeps a + wedged channel from holding a worker slot until the whole client run times + out, preserving channel isolation under churn. +- Each data frame has an ACK timeout through `-AckTimeout` in the Docker runner + and `-ack-timeout` in `fabric-loadtest`. A missing ACK triggers reroute/pool + retry without waiting for the full channel timeout. +- Optional overall ACK latency gates `-MaxAckP95Ms` and `-MaxAckP99Ms` are + passed through the Docker runner to `fabric-loadtest` as + `-max-ack-p95-ms` and `-max-ack-p99-ms`. They fail healthy runs when + aggregate data-plane ACK latency exceeds the configured SLO, independently + of slow-route migration thresholds. +- Optional per-target ACK latency gate `-MaxTargetAckMs` is passed through the + Docker runner to `fabric-loadtest` as `-max-target-ack-ms`. It fails healthy + runs when any target route reports a `target_stats[*].max_ack_ms` above the + configured SLO. +- Optional channel setup latency gates `-MaxSetupP95Ms` and `-MaxSetupP99Ms` + are passed through the Docker runner to `fabric-loadtest` as + `-max-setup-p95-ms` and `-max-setup-p99-ms`. They fail healthy runs when + logical channel open/setup latency exceeds the configured SLO before payload + transfer starts. +- Optional reroute latency gates `-MaxRerouteP95Ms` and `-MaxRerouteP99Ms` + are passed through the Docker runner to `fabric-loadtest` as + `-max-reroute-p95-ms` and `-max-reroute-p99-ms`. They measure repeat channel + setup latency after pool failover or slow-route migration and fail the run + when route rebuild exceeds the configured SLO. +- Docker shared-host summaries also include `container_stats` from + `docker stats --no-stream` for each fabric server/client container that is + still running at the end of the scenario. This records CPU percent, memory + usage, memory percent, network IO, block IO, and PID count per node before + cleanup. +- Long soak runs can add `-ContainerStatsSampleInterval 10s` to collect + periodic Docker container stats while traffic is in flight. The runner writes + samples to `container_stats_samples_path`, includes + `container_stats_samples_count` and `container_stats_sample_summary`, and + records per-container memory/PID start, end, max, and delta values. +- Optional container resource verdict gates `-MaxContainerMemoryMiB` and + `-MaxContainerPids` fail the Docker scenario when any running fabric + container exceeds the configured memory or PID budget at the final snapshot + or at any periodic sample peak. +- Verified short continuous soak: + `fabric-loadtest-20260516-163206` used `-Soak -Duration 20s`, + mixed public/NAT/LAN/relay profile, 32 concurrency, and mixed control/bulk + traffic. It produced 4000/4000 successful logical channels, + `channel_opens=4035`, `channel_closes=4035`, `channel_leaks=0`, + `route_pressure.active_total=0`, `max_active_total=32`, + `control_ack_p95_ms=2`, `ack_p95_ms=4`, resource sample count 12, + goroutine delta -18, max active streams 32, max active route load 32, and + matching acquire/release counts. +- Verified 60-second high-churn continuous soak with graceful drain: + `fabric-loadtest-20260516-174505` rebuilt the Docker image after changing + soak duration to stop generation and let in-flight channels drain. The + 4-node mixed-topology run used 128 concurrency, `-Duration 60s`, + `-StreamTimeout 15s`, periodic resource/container sampling, mixed + control/bulk traffic, throughput and churn SLOs. It produced 438740/438740 + successful logical channels, `channel_churn_per_sec=7310`, + `throughput_bps=3473632858`, `ack_p95_ms=5`, `ack_p99_ms=6`, + `control_ack_p95_ms=3`, `channel_opens=438740`, + `channel_closes=438740`, `channel_leaks=0`, `open_failures=0`, + `goroutines_delta=-1`, `open_fds_delta=4`, all four route modes, clean + route-pressure accounting, and verdict `pass`. +- Verified pool failover soak with ACK timeout and abandoned-frame accounting: + `fabric-loadtest-20260516-175622` rebuilt the Docker image with ACK timeout, + target quarantine, and abandoned-frame accounting, then killed target 0 after + 3 seconds during a 30-second mixed-topology soak. It produced 136194/136194 + successful logical channels, `failed_streams=0`, `failover_events=82`, + `abandoned_frames=75`, `ack_mismatched_streams=0`, + `ack_integrity_errors=0`, `channel_churn_per_sec=4543`, + `throughput_bps=2156155314`, `reroute_latency_p99_ms=9`, + `channel_leaks=0`, clean route-pressure accounting, and verdict `pass`. +- Verified container stats gate: + `fabric-loadtest-20260516-163854` produced a passing 2-node mixed-topology + smoke with `-MaxContainerMemoryMiB 128 -MaxContainerPids 64` and included + `container_stats` for both fabric server containers, with memory usage around + 4-6 MiB per server and server PID counts 7-9. A negative control run with + `-MaxContainerMemoryMiB 1` failed as expected with + `container_memory_mib=...>1` verdict reasons. +- Verified periodic container stats sampling: + `fabric-loadtest-20260516-164259` used `-Soak -Duration 8s`, + `-ContainerStatsSampleInterval 2s`, mixed public/NAT/LAN/relay profile, and + `-MaxContainerMemoryMiB 128 -MaxContainerPids 64`. It produced 2000/2000 + successful logical channels, `channel_opens=2009`, `channel_closes=2009`, + `channel_leaks=0`, even 1000/1000 target distribution, 400 control streams, + `ack_p95_ms=1`, `route_pressure.active_total=0`, matching acquire/release + counts, final server memory around 12-13 MiB, and periodic sample peaks for + the client and both servers in + `fabric-loadtest-20260516-164259-container-stats-samples.json`. +- Verified high-churn goroutine drain after QUIC close cancellation: + `fabric-loadtest-20260516-164502` rebuilt the Docker image and repeated the + 2-node mixed-topology continuous soak with `-MaxGoroutineDelta 64`, + `-MaxHeapDeltaMB 128`, `-ContainerStatsSampleInterval 2s`, + `-MaxContainerMemoryMiB 128`, and `-MaxContainerPids 64`. It produced + 2000/2000 successful logical channels, `channel_opens=2009`, + `channel_closes=2009`, `channel_leaks=0`, even 1000/1000 target + distribution, `control_ack_p95_ms=1`, `ack_p95_ms=1`, + `route_pressure.active_total=0`, matching acquire/release counts, and + `goroutines_delta=-2`. +- Verified file descriptor gate: + `fabric-loadtest-20260516-164725` rebuilt the Docker image and repeated the + 2-node mixed-topology continuous soak with `-MaxOpenFDDelta 8` and + `-MaxOpenFDs 128` in addition to goroutine, heap, container memory, and PID + gates. It produced 2000/2000 successful logical channels, + `channel_leaks=0`, `route_pressure.active_total=0`, matching + acquire/release counts, `open_fds_start=15`, `open_fds_end=9`, + `open_fds_max=19`, and `open_fds_delta=-6`. +- Verified bounded soak aggregation: + `fabric-loadtest-20260516-165051` rebuilt the Docker image after changing + soak result storage to an aggregate collector. The 2-node mixed-topology soak + produced 2000/2000 successful logical channels, even 1000/1000 target + distribution, `channel_leaks=0`, `route_pressure.active_total=0`, matching + acquire/release counts, `goroutines_delta=0`, `open_fds_delta=1`, verdict + `pass`, and only 25 retained `stream_samples` in the full report. +- Verified mixed route-mode coverage gate: + `fabric-loadtest-20260516-165308` rebuilt the Docker image with the route + coverage verdict and ran a 4-node mixed-topology soak. It produced 4000/4000 + successful logical channels, even 1000/1000/1000/1000 target distribution, + `channel_leaks=0`, `route_pressure.active_total=0`, matching + acquire/release counts, and observed all required route modes: + `lan_quic`, `ice_quic`, `reverse_quic`, and `relay_quic`. +- Verified ACK integrity gate: + `fabric-loadtest-20260516-165544` rebuilt the Docker image with the ACK + mismatch verdict and repeated the 4-node mixed-topology soak. It produced + 4000/4000 successful logical channels, `ack_mismatched_streams=0`, per-target + `frames_sent=6600` and `acks_received=6600`, all four route modes, clean + channel/route pressure accounting, and verdict `pass`. +- Verified ACK checksum integrity gate: + `fabric-loadtest-20260516-165926` rebuilt the Docker image with ACK payload + checksums and repeated the 4-node mixed-topology soak. It produced 4000/4000 + successful logical channels, `ack_mismatched_streams=0`, + `ack_integrity_errors=0`, 26400 total data frames, 26400 ACKs, all four route + modes, clean channel/route pressure accounting, and verdict `pass`. +- Verified unique per-frame payload integrity: + `fabric-loadtest-20260516-170150` rebuilt the Docker image after switching + loadtest traffic from a shared payload buffer to deterministic per-frame + payloads. The 4-node mixed-topology soak produced 4000/4000 successful + logical channels, `ack_mismatched_streams=0`, `ack_integrity_errors=0`, 26400 + data frames, 26400 ACKs, all four route modes, clean channel/route pressure + accounting, and verdict `pass`. +- Verified throughput SLO gate: + `fabric-loadtest-20260516-170512` rebuilt the Docker image with + `-MinThroughputMbps 100` and repeated the 4-node mixed-topology soak. It + produced 4000/4000 successful logical channels, `throughput_bps=212479668`, + `ack_mismatched_streams=0`, `ack_integrity_errors=0`, all four route modes, + clean channel/route pressure accounting, and verdict `pass`. +- Verified short-session churn SLO gate: + `fabric-loadtest-20260516-173320` rebuilt the Docker image with + `-MinChannelChurnPerSec 200`, then ran a 4-node mixed-topology high-churn + short-session smoke with 1000 one-frame logical channels. It produced + 1000/1000 successful logical channels, `channel_churn_per_sec=9478`, + `channel_opens=1000`, `channel_closes=1000`, `channel_leaks=0`, even target + stream distribution, all four route modes, clean route-pressure accounting, + and verdict `pass`. +- Verified high-churn QUIC stream-credit regression gate: + `fabric-loadtest-20260516-174046` rebuilt the Docker image after closing the + server-side QUIC stream on handler exit and ran a 4-node mixed-topology burst + of 5000 one-frame short logical channels at 128 concurrency with + `-MinChannelChurnPerSec 300` and `-StreamTimeout 15s`. It produced 5000/5000 + successful logical channels, `channel_churn_per_sec=21124`, + `channel_opens=5000`, `channel_closes=5000`, `channel_leaks=0`, + `open_failures=0`, `ack_mismatched_streams=0`, `ack_integrity_errors=0`, + even 1250/1250/1250/1250 target distribution, all four route modes, clean + route-pressure accounting, and verdict `pass`. +- Verified target byte distribution gate: + `fabric-loadtest-20260516-170731` rebuilt the Docker image with byte + distribution verdicts and repeated the 4-node mixed-topology soak. It + produced 4000/4000 successful logical channels, even 1000/1000/1000/1000 + stream distribution, exactly 53,248,000 bytes per target, + `throughput_bps=212488911`, all four route modes, clean channel/route + pressure accounting, and verdict `pass`. +- Verified overall ACK latency SLO gate: + `fabric-loadtest-20260516-171001` rebuilt the Docker image with + `-MaxAckP95Ms 20` and `-MaxAckP99Ms 50` and repeated the 4-node + mixed-topology soak. It produced 4000/4000 successful logical channels, + `ack_p95_ms=2`, `ack_p99_ms=3`, `ack_mismatched_streams=0`, + `ack_integrity_errors=0`, all four route modes, clean channel/route pressure + accounting, and verdict `pass`. +- Verified route-pressure distribution gate: + `fabric-loadtest-20260516-171216` rebuilt the Docker image with + route-pressure distribution verdicts and repeated the 4-node mixed-topology + soak. It produced 4000/4000 successful logical channels, even target stream + and byte distribution, per-route `max_active` values of 13/12/13/13, + `route_pressure.active_total=0`, matching acquire/release counts, and + verdict `pass`. +- Verified per-target ACK latency gate: + `fabric-loadtest-20260516-171454` rebuilt the Docker image with + `-MaxTargetAckMs 20` and repeated the 4-node mixed-topology soak. It produced + 4000/4000 successful logical channels, per-target `max_ack_ms` values of + 6/5/7/9, `ack_p95_ms=3`, `ack_p99_ms=5`, all four route modes, clean + channel/route pressure accounting, and verdict `pass`. +- Verified channel setup latency SLO gate: + `fabric-loadtest-20260516-171937` rebuilt the Docker image with + `-MaxSetupP95Ms 20` and `-MaxSetupP99Ms 50`, then repeated the 4-node + mixed-topology soak with ACK, throughput, FD, goroutine, heap, container + memory, and PID gates enabled. It produced 4000/4000 successful logical + channels, `setup_latency_p95_ms=0`, `ack_p95_ms=3`, `ack_p99_ms=3`, + `throughput_bps=212572631`, even target stream/byte distribution, all four + route modes, clean channel/route pressure accounting, and verdict `pass`. +- Verified reroute latency SLO gate: + `fabric-loadtest-20260516-172652` rebuilt the Docker image with + `-MaxRerouteP95Ms 100` and `-MaxRerouteP99Ms 200`, then ran a 4-node + mixed-topology pool-failover stress with target 0 killed during load. It + produced 400/400 successful logical channels, 100 pool failover events, + `reroute_latency_p95_ms=1`, `reroute_latency_p99_ms=2`, + `route_attempts_total=500`, `ack_p95_ms=6`, `ack_p99_ms=8`, + `throughput_bps=3863633075`, clean channel/route pressure accounting, and + verdict `pass`. +- Mixed topology profile gate: + `fabric-loadtest-20260516-162037` used + `-TopologyProfile mixed-public-nat-lan-relay` with 400 streams, 64 + concurrency, four targets, and mixed control/bulk traffic. It produced + 400/400 successful streams, 100 streams per target, route-mode reporting for + `lan_quic`, `ice_quic`, `reverse_quic`, and `relay_quic`, + `control_ack_p95_ms=2`, `ack_p95_ms=7`, `channel_leaks=0`, + `route_pressure.active_total=0`, and matching acquire/release counts. +- Verified strict QUIC route-mode gate: + `fabric-loadtest-20260516-182550` rebuilt the loadtest image with legacy + route-mode verdicts and ran the 4-node mixed topology profile. It produced + 400/400 successful logical channels, observed only `lan_quic`, `ice_quic`, + `reverse_quic`, and `relay_quic`, kept `ack_mismatched_streams=0`, + `ack_integrity_errors=0`, `channel_leaks=0`, clean route-pressure accounting, + and verdict `pass`. +- `fabric-loadtest` now also treats the configured target list as part of the + acceptance surface: every target must be `quic://...`. Empty targets, bare + `host:port`, HTTP(S), and WS/WSS targets produce a failing + `non_quic_targets=...` verdict reason. Client mode also rejects those targets + before dialing, so a bad stress command cannot accidentally exercise a + non-QUIC path and only discover it after the run. +- The shared Docker runner `scripts/fabric/fabric-loadtest-docker-smoke.ps1` + now has matching guardrails: it refuses local Docker Desktop contexts such as + `default`/`desktop-linux` and validates generated targets before launch so the + real-load smoke remains tied to the shared test Docker host and QUIC-only + endpoints. +- Shared Docker validation after those guardrails: + `fabric-loadtest-20260516-190049` rebuilt the Docker image on `test-docker` + and ran 4 QUIC targets with 120 streams. It produced 120/120 successful + logical channels, `ack_p95_ms=3`, `setup_latency_p95_ms=21`, clean + open/close and route-pressure accounting, QUIC-only targets, and verdict + `pass`. +- Shared Docker mixed-topology failover validation: + `fabric-loadtest-20260516-190137` reused the image on `test-docker`, killed + target 0 after 100ms, and ran 400 streams over the mixed public/NAT/LAN/relay + profile. It produced 400/400 successful logical channels, 100 pool failover + events, `route_attempts_total=500`, route modes `ice_quic`, + `reverse_quic`, and `relay_quic` after the failed target was removed, + `ack_p95_ms=8`, `setup_latency_p95_ms=51`, clean channel/route-pressure + accounting, and verdict `pass`. +- Shared Docker mixed-topology route coverage validation: + `fabric-loadtest-20260516-190207` ran the same 4-target mixed profile without + target failure. It produced 400/400 successful logical channels, exactly 100 + streams per target, observed `lan_quic`, `ice_quic`, `reverse_quic`, and + `relay_quic`, kept `ack_integrity_errors=0`, `channel_leaks=0`, + `route_pressure.active_total=0`, and verdict `pass`. +- Load balancing under pool failover is now an acceptance gate. The first + stricter shared-host rebuild, `fabric-loadtest-20260516-190704`, intentionally + failed because all failed-target retries moved to the nearest live target, + producing `target_byte_distribution_skew` and + `route_pressure_distribution_skew`. The retry selector was then changed to + spread failed-slot retries across the currently usable target set instead of + selecting the next target in ring order. +- Verified load-aware retry routing after the fix: + `fabric-loadtest-20260516-191028` rebuilt on `test-docker`, killed target 0 + after 100ms, and repeated the 4-target mixed profile. It produced 400/400 + successful logical channels, 100 pool failover events, surviving-target stream + distribution of 134/133/133, surviving route-pressure max-active values of + 30/25/27, `ack_p95_ms=4`, `reroute_latency_p95_ms=1`, clean acquire/release + accounting, and verdict `pass`. +- Verified 1000-channel mixed-topology stress: + `fabric-loadtest-20260516-193414` ran 1000 logical channels on `test-docker` + with 128 concurrency, mixed control/bulk traffic, and the + `mixed-public-nat-lan-relay` profile. It produced 1000/1000 successful + logical channels, exact 250/250/250/250 target distribution, observed all four + QUIC route modes (`lan_quic`, `ice_quic`, `reverse_quic`, `relay_quic`), + `throughput_bps=3629522849`, `channel_churn_per_sec=1919`, + `ack_p95_ms=6`, clean channel/route-pressure accounting, and verdict `pass`. +- Verified 1000-channel pool-failover stress: + `fabric-loadtest-20260516-193444` killed target 0 after 100ms and ran 1000 + logical channels with 128 concurrency. It produced 1000/1000 successful + logical channels, 250 pool failover events, surviving-target distribution of + 334/333/333, `route_attempts_total=1250`, `ack_p95_ms=7`, clean + acquire/release accounting, and verdict `pass`. +- Verified latency-degradation migration: + `fabric-loadtest-20260516-193515` applied `tc netem delay 80ms` to target 1, + enabled slow-stream migration with `-MaxAckMs 20`, and ran 400 mixed-profile + channels. It observed the impaired target in `degraded_targets`, produced + 64 slow-ACK migrations, moved completed streams onto healthy targets with + distribution 134/133/133, kept `channel_leaks=0`, `ack_integrity_errors=0`, + clean route-pressure accounting, and verdict `pass`. +- Shared Docker runner resource-sample fallback was verified with + `fabric-loadtest-20260516-190325`: short runs now still persist + `container_stats_samples_path` and a minimal per-container sample summary + from final Docker stats when the background sampler has no time to emit + samples. +- Added `scripts/fabric/fabric-acceptance-summary.ps1` to aggregate recent + `*-summary.json` artifacts into an acceptance report. It captures verdicts, + target distribution, route modes, churn, failover/migration counts, latency + SLOs, resource evidence, and keeps intentional failed runs visible as + regression evidence for gates such as route-pressure skew detection. +- The first 30-minute soak attempt (`fabric-loadtest-20260516-193558`) exposed + a runner defect instead of a fabric defect: server containers were still + started with a fixed `-timeout 10m`, so the three surviving servers exited + around minute 10 while the client expected a 30-minute run. The Docker runner + now exposes `-ServerTimeout` and defaults it to `-ClientTimeout`, so long soak + server lifetimes match the client run. +- The next soak attempt (`fabric-loadtest-20260516-194816`) passed the 10-minute + server-timeout boundary but exposed another long-run behavior: a healthy + surviving target could stay out of placement after a transient degradation + mark. `fabric-loadtest` now uses a bounded `target_quarantine_ttl` for + placement while still preserving historical `degraded_targets` observations + in the report. The Docker runner exposes this as `-TargetQuarantineTTL`. +- `fabric-loadtest-20260516-200241` then exposed a soak-loop issue: it reported + `pass` with 432869/432869 logical channels and clean accounting, but finished + after about 95 seconds despite `config.duration=30m`. The cause was worker + shutdown on per-stream `context deadline exceeded`; soak workers now only exit + on the parent run context or the configured soak stop time, not on one + channel's timeout. +- `fabric-loadtest-20260516-200939` and `fabric-loadtest-20260516-201331` + confirmed the soak loop fix by running full 3-minute preflights, but they + failed the zero-failed-stream gate under target-kill injection. The issue was + policy: the known killed target re-entered placement too quickly via the + short transient quarantine TTL, causing some channels to spend their stream + budget on a hard-dead endpoint. `fabric-loadtest` now separates transient + `target_quarantine_ttl` from `failure_quarantine_ttl`, and the Docker runner + exposes `-FailureQuarantineTTL`. +- Verified 30-minute long-duration soak: + `fabric-loadtest-20260516-202532` ran on `test-docker` for 1800.010 seconds + with 4 QUIC targets, 128 concurrency, mixed control/bulk traffic, 64 KiB per + logical channel, 10-second resource and container samples, and the + `mixed-public-nat-lan-relay` profile. It produced 15,074,556/15,074,556 + successful logical channels, 895,308,005,376 bytes, `throughput_bps=3979124146`, + `channel_churn_per_sec=8374`, exact 3,768,639 streams per target, all four + QUIC route modes, `ack_p95_ms=5`, `ack_p99_ms=6`, `channel_leaks=0`, + matching 15,074,556 channel opens/closes, `route_pressure.active_total=0`, + 458 container-stat samples, bounded memory/PID use, and verdict `pass`. +- Verified real-node host-to-host QUIC smoke: + `home-1` ran the standalone `fabric-loadtest` client against a temporary + QUIC server on `test-docker` at `quic://docker-test.cin.su:19443`. The run + created 1000 short logical channels at 128 concurrency, mixed control and + bulk traffic, sent 59,392,000 bytes, received 3700/3700 ACKs, produced + `throughput_bps=1177445403`, `channel_churn_per_sec=2478`, + `ack_p95_ms=12`, `ack_p99_ms=21`, `setup_latency_p95_ms=118`, zero failed + streams, zero channel leaks, and verdict `pass`. The report is saved as + `artifacts/fabric-real-nodes/home-1-to-test-docker-20260516-181649.json`. +- Published and registered node-agent release `0.2.280-fabricsession` with + linux binary/native and Docker image artifacts. The release is intentionally + not assigned to live node update policies yet because current live node + workload/env posture still advertises legacy `direct_http` and HTTP/HTTPS + mesh endpoints. Before rollout, node configs must be migrated to + `quic://...` endpoints, QUIC advertise labels, and enabled QUIC listener env + such as `RAP_MESH_QUIC_FABRIC_ENABLED=true` plus + `RAP_MESH_QUIC_FABRIC_LISTEN_ADDR`. +- Loadtest degraded-target quarantine is observable through `degraded_targets`. + When `-impair-target` and slow-stream migration are enabled, verdict fails if + no degraded target is observed or if degraded targets do not produce migration + events. A shared-host validation run with 120 streams reported + `degraded_targets = { impaired_target: "slow_ack" }`, 5 migration events, + `control_ack_p95_ms=3`, and clean acquire/release accounting. +- Channel lifecycle accounting is explicit in `fabric-loadtest` through + `channel_opens`, `channel_closes`, and `channel_leaks`. Verdict fails on + open/close mismatch, active stream leaks, or mismatch between route-pressure + acquire counts and QUIC stream opens. +- The next validation step is broader real mixed public/NAT/LAN topology across + separate physical or VM hosts. The shared Docker host has verified the route + model, stress gates, 30-minute stability, memory, goroutine, file descriptor, + container resource, and route-pressure accounting. A true external NAT lab + should now validate the same gates with independent NAT devices, public nodes, + and local NAT-side cluster segments. + +Initial SLO examples: + +- `channel_setup_p95_ms < 200` +- `reroute_p95_ms < 1000` +- `control_latency_p99_ms < 100 under bulk load` +- `packet_loss_after_recovery < 0.1%` +- `no_route_pressure_over_90_percent_when_alternatives_exist` +- `no_channel_table_growth_after_churn` diff --git a/docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md b/docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md index f27e2c6..2d53f95 100644 --- a/docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md +++ b/docs/architecture/SECURE_ACCESS_FABRIC_TARGET.md @@ -204,6 +204,8 @@ Examples: - `vnc-worker` wraps a future VNC client/runtime. - `vpn-exit` handles exit routing. - `vpn-connector` handles private network reachability. +- `vpn-client` runs on an end-user device, including Android, as a normal farm node. +- `ipv4-egress` marks a node/service that can send authorized VPN packet traffic to ordinary IPv4 networks. - `video-relay` handles media optimized paths. Rules: @@ -293,6 +295,41 @@ Responsibilities: - applies route, DNS, and egress restrictions - reports traffic and health telemetry +### `ipv4-egress` + +Fabric-only IPv4 exit service. It is assigned to nodes that may forward authorized VPN packet channels from the mesh to ordinary IPv4 networks. + +Responsibilities: + +- accepts VPN packet channels only through the fabric service channel +- advertises exit pool membership, region, route policy, and health +- enforces user, organization, cluster, and owner visibility policy before accepting traffic +- participates in latency-aware and load-aware exit selection +- supports failover between nodes in the same exit pool without changing the Android client protocol +- does not expose legacy VPN protocols as the steady-state data plane + +### `vpn-client` + +Client-side VPN node role. On Android the installed application is a node-agent/runtime with this role, then the VPN client service is started locally and joins the farm like any other node. + +Responsibilities: + +- joins the mesh using the current QUIC fabric transport +- requests the list of visible IPv4 exit pools and nodes according to the current user's access level +- creates VPN packet channels to the selected `ipv4-egress`/`vpn-exit` pool +- switches to another authorized exit when the selected exit fails or becomes slow +- keeps old protocol compatibility out of the runtime data plane; old nodes may only use legacy download/update paths long enough to fetch the new agent +- exposes its local IPv4 ingress as service configuration: on Android this is the + `VpnService` TUN, and on Linux/Docker it may also include explicit TCP/UDP + listen ports that are mapped into VPN packet channels. + +Rules: + +- A VPN client does not use a dedicated entry node. It is itself a mesh node. +- The farm builds the route from the client node to an authorized exit pool. +- Exits are addressed as pools. A pool may contain one node, but that is a degraded redundancy posture and should be visible as a risk. +- The control plane may issue policy and signed route authority, but it must not become the packet entry point for the VPN client. + ### `vpn-connector` Connector to private networks. diff --git a/docs/architecture/WEB_INGRESS_AND_ADMIN_UI_MODEL.md b/docs/architecture/WEB_INGRESS_AND_ADMIN_UI_MODEL.md index ea33ea2..dc1265b 100644 --- a/docs/architecture/WEB_INGRESS_AND_ADMIN_UI_MODEL.md +++ b/docs/architecture/WEB_INGRESS_AND_ADMIN_UI_MODEL.md @@ -1,13 +1,13 @@ # Web Ingress and Admin UI Model -Status: target architecture clarification. Documentation only. +Status: target architecture and implementation contract. This document defines how HTTP/HTTPS web entry, Admin UI, dynamic page composition, and cluster configuration responsibilities are separated in the Secure Access Fabric. -It does not implement code, APIs, UI pages, mesh runtime, VPN runtime, or RDP -changes. +The fabric node-to-node transport remains QUIC-only. HTTP/HTTPS is allowed only +as an external client-facing service edge. ## Purpose @@ -16,33 +16,41 @@ The platform needs a clear distinction between: - Web Service as the HTTP/HTTPS entry layer - Control Plane as the owner of cluster configuration and policy - Admin UI as a safe, scoped user interface over Control Plane APIs +- Fabric Transport as the internal QUIC-only node-to-node substrate The Web layer must never become the owner of cluster state, policy, topology, secrets, node identity, or routing authority. ## Layer Ownership -### Web Service / Web Ingress +### Public HTTPS Ingress -Web Service is an edge service. +Public HTTPS Ingress is an edge service. It may run on a public Internet node, +including a small/slow node intended only to accept browser traffic and pass it +into the fabric. -Suggested role names: +Role names: -- `web-ingress` -- `admin-web-entry` -- `admin-web-shell` +- `public-ingress` +- `admin-ingress` Responsibilities: -- accept HTTP/HTTPS +- listen on TCP `80` only for ACME challenges, health checks, and HTTPS + redirects +- listen on TCP `443` for browser/API HTTPS - terminate TLS or sit behind the approved TLS terminator -- serve Admin UI shell/static assets -- proxy browser/API traffic to Control API +- serve only approved static UI shells and safe public metadata +- validate SNI/Host, request size, rate limits, and edge policy +- map the request to an allowed platform, cluster, organization, or user portal + scope +- forward accepted traffic into the fabric through an authorized fabric service + channel - apply edge controls such as headers, rate limits, request size limits, and future WAF rules - expose only approved public/admin endpoints -Web Service must not: +Public HTTPS Ingress must not: - own cluster configuration - directly mutate PostgreSQL @@ -51,6 +59,39 @@ Web Service must not: - store node identity or certificates as source of truth - expose internal mesh topology to browser clients - execute cluster decisions locally +- hold platform/global admin authority keys +- infer authorization from the fact that it accepted TCP `443` +- become a general relay for arbitrary HTTP inside the fabric + +The node that accepts HTTPS is not the node that automatically owns or executes +admin logic. It is only a service edge. + +### Fabric Transport + +Fabric Transport is the internal node-to-node layer. + +Rules: + +- node-to-node traffic uses QUIC only +- no HTTP fallback between fabric nodes +- STUN/ICE/rendezvous/relay are fabric transport mechanisms, not browser/API + protocols +- any service traffic accepted on `443` is converted into a scoped fabric + service channel before it crosses the mesh +- direct links, relay links, and route-health observations must remain separate + in diagnostics +- a fabric route proves reachability, not administrative authority + +If a public ingress receives a request for an admin surface, the request flow is: + +```text +Browser HTTPS + -> public/admin ingress on 443 + -> tenant/cluster/platform scope selection + -> signed fabric service channel over QUIC + -> authorized admin/runtime service node + -> Control Plane authorization and policy +``` ### Control Plane @@ -77,9 +118,23 @@ only. Cluster configuration is changed only through Control Plane services and APIs. The Web layer is a presentation and ingress layer over those APIs. -### Admin UI +### Admin UI Runtime -Admin UI is a client application served through Web Ingress. +Admin UI Runtime is the service that serves and executes the admin surface. It +may run on any node explicitly assigned the matching runtime role. + +Role names: + +- `global-admin-runtime` +- `cluster-admin-runtime` +- `organization-portal-runtime` +- `user-portal-runtime` +- `identity-runtime` +- `policy-authority` +- `audit-sink` + +Admin UI is a client application served through Public HTTPS Ingress or Admin UI +Runtime according to deployment policy. It renders safe Control Plane projections and submits user actions to Control Plane APIs. @@ -95,7 +150,7 @@ Admin UI must not: viewer - contain executable cluster logic -## Admin Endpoint Placement +## Admin Endpoint Placement And Trust Admin UI endpoint placement is explicit and must not be inferred from storage. @@ -110,6 +165,8 @@ Scopes: - Organization Admin Panel: tenant-safe projection for one organization. It must expose only allowed resources, service endpoints, sessions, policies, and safe status. +- User Portal: personal/account scope. It must expose only the authenticated + user's resources, sessions, devices, and profile actions. Rules: @@ -118,19 +175,29 @@ Rules: - Storage nodes distribute/cache scoped configuration and snapshots only. - Admin/web ingress is a separate service role and requires explicit Control Plane assignment. +- Public Internet ingress is not enough to run a global panel. +- `global-admin-runtime`, `policy-authority`, and `audit-sink` may run only on + platform-owner trusted nodes. +- `cluster-admin-runtime` may run only on nodes authorized for that cluster. +- `organization-portal-runtime` and `user-portal-runtime` may run on broader + infrastructure, but they receive only scoped projections. - Cluster-local admin endpoints require valid TLS/cert policy, signed scoped snapshots, current node health, and sufficient role coverage. - Platform Owner Console remains the owner-level view even when cluster-local admin endpoints exist. - Organization Admin Panel must never expose intermediate mesh topology, storage shards, peer caches, route caches, or unrelated cluster data. +- A request entering through an organization-bound ingress must be rejected if it + asks for another organization, another cluster outside its contract, global + topology, or platform-owner data. ## Request Flow ```text Admin Browser - -> Web Ingress / Admin Web Shell - -> Control API + -> Public/Admin HTTPS Ingress + -> Fabric Service Channel over QUIC + -> Admin UI Runtime / Control API -> PostgreSQL source of truth -> signed scoped snapshots / config distribution -> rap-node-agent @@ -266,6 +333,18 @@ Organization admin must not see: - secrets - unrelated cluster internals +Ingress-bound projections: + +- A platform-owner ingress may expose platform navigation only after platform + authorization, MFA/step-up, and policy checks. +- A cluster-bound ingress may expose only that cluster's admin surface and + cluster-scoped safe diagnostics. +- An organization-bound ingress may expose only the organization projection and + organization-safe service endpoints. +- A user portal ingress may expose only the user's personal/account projection. +- Host/SNI alone is not authorization; it only selects the maximum possible + projection before server-side authorization narrows it further. + ## Service Adapter UI Extensions Service adapters may need configuration UI. @@ -361,22 +440,258 @@ High-risk actions include: ## Deployment Model +### Current Test Entry + +The current shared Docker test stand exposes the Platform Owner Control Panel at +`http://docker-test.cin.su:18080/` (`http://192.168.200.61:18080/`). This is a +temporary lab HTTP edge served by `rap_web_admin` from +`/tmp/rap-web-admin/html` on `test-docker`. + +This entry is not the production authority model. It is allowed only for the +shared test stand while the HTTPS admin-ingress runtime is being completed. The +target production entry is: + +```text +Browser HTTPS on 443 + -> node with explicit admin-ingress/public-ingress role + -> signed web-ingress envelope + -> QUIC fabric service channel + -> authorized admin/portal runtime node + -> Control API projection/authorization +``` + +The browser-facing ingress may be a small public node, but it must not become +the management authority. Platform/global admin runtime remains limited to +platform-owner trusted nodes. Cluster, organization, and user panels receive +only their scoped projections. + +The legacy Fabric map with separate `inputs`, `cluster nodes`, and `egress +zones` is retired for the transport-layer view. The Fabric panel must show +actual direct/fresh QUIC neighbor links, one-way/passive direction, stale/problem +state, relay/route-health annotations, and web-ingress runtime readiness. It +must not render old entry/egress zone columns as if they were transport +topology. + Possible deployment modes: -- Web Ingress and Control API in the same deployment for small/test installs +- Public/Admin HTTPS Ingress and Control API in the same deployment for + small/test installs - Web Ingress separated from Control API for production - multiple Web Ingress nodes for regional/admin access - Web Ingress behind Caddy/Nginx/enterprise ingress - Admin UI shell served from Web Ingress while APIs remain on Control API +- Internet ingress on a low-capacity node that forwards scoped channels to a + trusted admin runtime elsewhere in the fabric +- global admin runtime only on platform-owner controlled nodes +- cluster admin runtime on cluster-authorized nodes +- organization/user portal runtime on tenant-safe nodes with scoped data Even when deployed together, ownership remains separate: -- Web Ingress is entry/presentation +- Public/Admin HTTPS Ingress is entry/presentation +- Fabric Transport is QUIC-only service-channel delivery - Control API is authorization/domain logic - PostgreSQL is source of truth - Fabric Storage/Config Storage is scoped distribution/cache - node-agent consumes scoped desired state +## Required Roles + +The platform recognizes these web/admin placement roles: + +| Role | Scope | Purpose | +| --- | --- | --- | +| `public-ingress` | cluster or organization | Listen on 80/443, terminate/validate HTTPS, forward scoped service channels. | +| `admin-ingress` | platform or cluster | HTTPS edge for admin surfaces. It does not own authority. | +| `global-admin-runtime` | platform trusted nodes only | Platform-owner console/runtime. | +| `cluster-admin-runtime` | cluster | Cluster admin console/runtime for one cluster. | +| `organization-portal-runtime` | organization | Tenant-safe organization administration. | +| `user-portal-runtime` | user/organization | Personal account/resource portal. | +| `identity-runtime` | platform/cluster | Authentication, session, MFA, step-up and token issuance. | +| `policy-authority` | platform trusted nodes only | Authorization/policy decisions and signed claims. | +| `audit-sink` | platform trusted nodes only | Durable mutation/security audit ingestion. | + +Legacy `entry-node` remains a generic client ingress/service edge role for +non-admin product services. It must not imply admin authority. + +## Fabric Service Classes + +Admin and portal traffic uses explicit fabric service classes. This prevents +admin traffic from being disguised as VPN/RDP/file/video traffic and gives the +routing layer clear QoS, role, and audit semantics. + +| Service class | Required runtime roles | Projection | +| --- | --- | --- | +| `platform_admin` | `admin-ingress`, `global-admin-runtime`, `identity-runtime`, `policy-authority`, `audit-sink` | Platform-owner console. | +| `cluster_admin` | `admin-ingress`, `cluster-admin-runtime`, `identity-runtime`, `policy-authority`, `audit-sink` | One cluster. | +| `organization_portal` | `public-ingress`, `organization-portal-runtime`, `identity-runtime`, `policy-authority`, `audit-sink` | One organization. | +| `user_portal` | `public-ingress`, `user-portal-runtime`, `identity-runtime`, `policy-authority`, `audit-sink` | One authenticated user/account scope. | + +Default channels for these classes are `control`, `interactive`, and +`reliable`. They are latency-sensitive control-plane/service traffic, not bulk +data transfer. + +## Desired Workload Contract + +Ingress nodes are configured through normal node desired workloads. The first +runtime stage is a contract probe: node-agent validates the policy and reports a +workload status, but it does not open `80`/`443` until the real ingress runtime +stage is enabled. + +Example platform/cluster admin ingress workload: + +```json +{ + "service_type": "admin-ingress", + "desired_state": "enabled", + "runtime_mode": "native", + "config": { + "listen_http_port": 80, + "listen_https_port": 443, + "tls_mode": "terminate", + "scope": "platform", + "service_classes": ["platform_admin", "cluster_admin"] + } +} +``` + +Example organization/user public ingress workload: + +```json +{ + "service_type": "public-ingress", + "desired_state": "enabled", + "runtime_mode": "native", + "config": { + "listen_http_port": 80, + "listen_https_port": 443, + "tls_mode": "terminate", + "scope": "organization", + "service_classes": ["organization_portal", "user_portal"] + } +} +``` + +Contract-probe status requirements: + +- `fabric_transport` is `quic_only` +- `http_between_fabric_nodes` is `false` +- `authority_service` is `false` +- `fabric_service_channel_required` is `true` +- `ports_opened_by_stub` is `false` +- invalid service classes or non-80/443 ports report `degraded` +- real listener startup requires both workload config + `real_listener_enabled=true` and node-agent process gate + `RAP_WEB_INGRESS_RUNTIME_ENABLED=true` +- without the process gate, a real-listener request reports + `web_ingress_real_listener_gate_disabled` +- the first handler stage returns schema + `rap.web_ingress.runtime_response.v1`; it redirects HTTP to HTTPS, exposes + health, validates service class/scope, and blocks payload forwarding with + `fabric_service_channel_binding_not_implemented` until the QUIC service + channel binding is implemented +- node-agent owns a web-ingress listener lifecycle manager. When the real + listener gate is enabled, it starts the HTTP redirect listener and starts + HTTPS only when `tls_cert_file` and `tls_key_file` are present in workload + config. Without TLS files the listener status is `partial` and service + payload remains blocked. +- HTTPS handler has a `FabricBinder` boundary. Valid requests become + `rap.web_ingress.fabric_request.v1` records with method, path, query, host, + derived scope, service class, safe headers, bounded body, and observed + timestamp. Runtime derives fabric scope from service class + (`platform_admin` -> `platform`, `cluster_admin` -> `cluster`, + `organization_portal` -> `organization`, `user_portal` -> `user`) before + signing/forwarding the request. + Dangerous browser headers such as `Authorization`, `Cookie`, `Set-Cookie`, + and service-channel tokens are not forwarded as ordinary proxy headers. + The binder must convert the request into a signed/scoped fabric service + channel envelope; if no binder is present, ingress returns + `fabric_service_channel_binding_not_implemented`. +- The first concrete binder emits + `rap.web_ingress.fabric_service_channel_envelope.v1`. The envelope contains + the safe request projection, base64-encoded body, scope, service class, + observed timestamp, and envelope timestamp. It is serialized as canonical JSON + for signing, then passed to an `EnvelopeSigner` and `EnvelopeSender`. + `EnvelopeSigner` owns node/service-channel signature policy. `EnvelopeSender` + owns delivery into the QUIC fabric service channel and route selection. This + keeps HTTP edge handling separated from mesh internals while making the + security boundary explicit and testable. +- The initial signer implementation is Ed25519 over the canonical envelope + bytes. The signer can derive `key_id` from the public key fingerprint or use + an explicitly configured key id. Production deployment must bind this key to + the node identity/service-channel authority policy before enabling real + browser traffic. +- The initial mesh sender adapter can submit the signed envelope through the + existing reliable fabric channel runtime using `control` traffic class and a + configured route set to an admin/portal runtime node or pool. At this stage it + returns a delivery-accepted response with route/channel metrics. Full + request/response admin API streaming remains a later runtime step and must + stay on the same QUIC fabric channel model. +- The fabric channel runtime now also has a request/response path for web + ingress: it opens a QUIC stream, sends the signed envelope as `FrameData`, and + waits for a `FrameData` response on the same stream and sequence. Route + failures or response timeouts use the same latency-aware reroute path as + reliable delivery. Runtime HTTP responses use + `rap.web_ingress.fabric_runtime_response.v1` with status code, safe headers, + and body/body_b64. If a runtime response is not in that schema, ingress + reports delivery-accepted metrics instead of treating arbitrary payload as an + HTTP response. +- QUIC fabric server reserves `WebIngressForwardQUICStreamID` for web ingress + request/response forwarding. The server invokes a web-ingress forward handler + with the signed envelope payload and returns a wrapper containing either + runtime payload or an error on the same stream/sequence. +- Admin/portal runtime nodes have a signed-envelope receiver contract. The + receiver verifies `rap.web_ingress.signed_fabric_service_channel_envelope.v1`, + Ed25519 signature, trusted key id, scope, service class, and timestamp skew + before calling the local runtime handler. The local handler returns + `rap.web_ingress.fabric_runtime_response.v1`; unsafe response headers are + filtered before the payload is returned to the ingress edge. +- Node-agent exposes explicit runtime key policy inputs while the final signed + config-snapshot distribution is being wired: + `RAP_WEB_INGRESS_SIGNING_PRIVATE_KEY`, + `RAP_WEB_INGRESS_SIGNING_KEY_ID`, and + `RAP_WEB_INGRESS_TRUSTED_KEYS_JSON`. Trusted keys JSON may be either + `{"key_id":"public_key_b64"}` or an array of + `{"key_id":"...","public_key":"..."}` objects. Without trusted keys the + web-ingress receiver handler is not installed. Runtime receiver placement can + be narrowed with `RAP_WEB_INGRESS_RUNTIME_SERVICE_CLASSES`, a comma-separated + allow-list of `platform_admin`, `cluster_admin`, `organization_portal`, and + `user_portal`; this is a temporary explicit node-local policy until signed + role snapshots drive receiver placement. +- Heartbeat metadata includes `web_ingress_runtime_receiver_report` when QUIC + fabric or web-ingress key policy is configured. The report exposes the + signed-envelope schema, QUIC stream id, trusted key count, receiver + service-class allow-list, handler installation state, status/reason + (`ready`, `degraded`, or `blocked`), and QUIC endpoint readiness so the + fabric panel can show whether a node can currently receive admin/portal + runtime traffic and why it cannot. +- QUIC listener/reverse-transport handler configuration is sensitive to the + web-ingress trusted key policy and runtime service-class allow-list. If either + policy changes, node-agent restarts or refreshes the QUIC fabric handler + binding so stale key trust or stale receiver placement is not kept in memory. +- The first local admin runtime dispatcher is intentionally read-only. It + handles `/healthz`, `/readyz`, and `*/ui-manifest` requests after signed + envelope verification. It returns `rap.web_ingress.admin_runtime_response.v1` + with a safe `rap.web_ingress.ui_manifest.v1` projection that lists sections + and read-only actions for the requested service class. It rejects invalid + `scope`/`service_class` pairs before using either the local fallback or the + Control API projection client. Mutations return + `control_api_mutation_binding_not_implemented`; unknown read projections + return `control_api_projection_binding_not_implemented` until the dispatcher + is wired to the real Control API authorization/projection layer. +- The dispatcher now has a `ControlAPIProjectionClient` boundary. When bound, + read-only GET/HEAD requests are sent to the Control API projection endpoint + and returned as `rap.web_ingress.control_api_projection_response.v1`. + Backend exposes the first read-only projection endpoint at + `/api/v1/clusters/{cluster_id}/nodes/{node_id}/admin-runtime/projection`. + It returns safe manifest/projection payloads, marks audit as required, and + rejects mutation methods and invalid `scope`/`service_class` combinations. + Requests must use schema + `rap.web_ingress.control_api_projection_request.v1`; agent accepts responses + only with schema `rap.web_ingress.control_api_projection_response.v1`. + This is the first Control API binding slice; it is not yet a full + authorization/session/audit implementation. + ## Future Stages Suggested staged work: @@ -417,8 +732,9 @@ This document does not authorize: ## Result / Decision WEB is an ingress and presentation layer, not a cluster configuration owner. -Cluster configuration belongs to the Control Plane and is persisted in -PostgreSQL. Dynamic admin pages are allowed only as safe, scoped, +Fabric remains QUIC-only internally; HTTP/HTTPS exists only at the external +client edge. Cluster configuration belongs to the Control Plane and is persisted +in PostgreSQL. Dynamic admin pages are allowed only as safe, scoped, schema-driven projections over Control Plane APIs. They must not embed secrets, internal topology, peer caches, route caches, or arbitrary executable code. diff --git a/scripts/android/README.md b/scripts/android/README.md index 281c62c..c926f6e 100644 --- a/scripts/android/README.md +++ b/scripts/android/README.md @@ -105,12 +105,12 @@ pwsh -ExecutionPolicy Bypass -File scripts\android\fast-release-android-apk.ps1 По умолчанию результат будет (для release-сборки из CI/рабочего процесса это самые нужные имена): -- `dist/downloads/rap-android-rdp-vpn-latest-release.apk` -- `dist/releases//rap-android-rdp-vpn--release.apk` -- `dist/downloads/rap-android-rdp-vpn-build.json` -- `web-admin/deploy/html/downloads/rap-android-rdp-vpn-latest-release.apk` -- `web-admin/deploy/html/downloads/releases//rap-android-rdp-vpn--release.apk` -- `web-admin/deploy/html/downloads/rap-android-rdp-vpn-build.json` +- `dist/downloads/rap-android-vpn-latest-release.apk` +- `dist/releases//rap-android-vpn--release.apk` +- `dist/downloads/rap-android-vpn-build.json` +- `web-admin/deploy/html/downloads/rap-android-vpn-latest-release.apk` +- `web-admin/deploy/html/downloads/releases//rap-android-vpn--release.apk` +- `web-admin/deploy/html/downloads/rap-android-vpn-build.json` Эти файлы и папки игнорируются git для `dist`, поэтому `web-admin` артефакты должны публиковаться отдельным шагом инфраструктуры. @@ -132,7 +132,7 @@ pwsh -ExecutionPolicy Bypass -File scripts\android\build-android-apk.ps1 -Androi ## Что важно для работы с админ-панелью - Веб-панель уже ожидает файл: - `downloads/rap-android-rdp-vpn-latest-release.apk` + `downloads/rap-android-vpn-latest-release.apk` - Поэтому скрипт публикует APK в `web-admin/deploy/html/downloads`, чтобы новый артефакт был сразу доступен для скачивания пользователем после сборки и для автообновления узлов. diff --git a/scripts/android/build-android-apk.ps1 b/scripts/android/build-android-apk.ps1 index 908a0e7..2c7be3b 100644 --- a/scripts/android/build-android-apk.ps1 +++ b/scripts/android/build-android-apk.ps1 @@ -106,7 +106,7 @@ function Publish-Artifact { $latestPath = Join-Path $PublishRoot $LatestFileName $versionPath = Join-Path $versionDir $VersionFileName - $metaPath = Join-Path $PublishRoot "rap-android-rdp-vpn-build.json" + $metaPath = Join-Path $PublishRoot "rap-android-vpn-build.json" Copy-Item -Path $SourcePath -Destination $latestPath -Force Copy-Item -Path $SourcePath -Destination $versionPath -Force @@ -352,8 +352,8 @@ try { } $buildSucceeded = $true - $latestFileName = "rap-android-rdp-vpn-latest-$buildTypeNormalized.apk" - $versionFileName = "rap-android-rdp-vpn-$versionName-$buildTypeNormalized.apk" + $latestFileName = "rap-android-vpn-latest-$buildTypeNormalized.apk" + $versionFileName = "rap-android-vpn-$versionName-$buildTypeNormalized.apk" $publishedPathPrefix = "downloads" $publishDirs = @( diff --git a/scripts/android/rebuild-and-publish-android-apk.ps1 b/scripts/android/rebuild-and-publish-android-apk.ps1 index f42b494..f7ef3d3 100644 --- a/scripts/android/rebuild-and-publish-android-apk.ps1 +++ b/scripts/android/rebuild-and-publish-android-apk.ps1 @@ -82,4 +82,4 @@ Run-Step "Сборка и публикация Android APK" { } Write-Host "" -Write-Host "Готово. APK опубликован для веб-панели по ссылке: downloads/rap-android-rdp-vpn-latest-$BuildType.apk" +Write-Host "Готово. APK опубликован для веб-панели по ссылке: downloads/rap-android-vpn-latest-$BuildType.apk" diff --git a/scripts/android/release-android-apk.ps1 b/scripts/android/release-android-apk.ps1 index 579503c..29b09da 100644 --- a/scripts/android/release-android-apk.ps1 +++ b/scripts/android/release-android-apk.ps1 @@ -102,7 +102,7 @@ if ($SkipPortalVerify) { } Run-Step "Проверка манифеста веб-панели" { - $manifestPath = Join-Path $RepoRoot "web-admin\deploy\html\downloads\rap-android-rdp-vpn-build.json" + $manifestPath = Join-Path $RepoRoot "web-admin\deploy\html\downloads\rap-android-vpn-build.json" if (-not (Test-Path $manifestPath)) { Fail "Локальный манифест не найден: $manifestPath" } @@ -117,7 +117,7 @@ Run-Step "Проверка манифеста веб-панели" { Write-Host "Sha256: $($manifest.published.sha256)" if (-not [string]::IsNullOrWhiteSpace($PortalVerifyBaseUrl)) { - $manifestUrl = "$PortalVerifyBaseUrl/downloads/rap-android-rdp-vpn-build.json?_cb=$(Get-Date -Format 'yyyyMMddHHmmss')" + $manifestUrl = "$PortalVerifyBaseUrl/downloads/rap-android-vpn-build.json?_cb=$(Get-Date -Format 'yyyyMMddHHmmss')" try { $remoteManifest = Invoke-RestMethod -Uri $manifestUrl -Method Get if (-not $remoteManifest.version -or -not $remoteManifest.version.name -or $remoteManifest.version.name -ne $manifest.version.name) { diff --git a/scripts/fabric/fabric-acceptance-summary.ps1 b/scripts/fabric/fabric-acceptance-summary.ps1 new file mode 100644 index 0000000..f758d64 --- /dev/null +++ b/scripts/fabric/fabric-acceptance-summary.ps1 @@ -0,0 +1,112 @@ +param( + [string]$ArtifactDir = "", + [int]$Limit = 20, + [string]$OutputPath = "" +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).ProviderPath +if ($ArtifactDir.Trim() -eq "") { + $ArtifactDir = Join-Path $repoRoot "artifacts\fabric-loadtest" +} +if ($OutputPath.Trim() -eq "") { + $OutputPath = Join-Path $ArtifactDir ("fabric-acceptance-summary-" + (Get-Date -Format "yyyyMMdd-HHmmss") + ".json") +} + +function Get-PropertyValue { + param([object]$Item, [string]$Name, $Default = $null) + if ($null -eq $Item -or -not ($Item.PSObject.Properties.Name -contains $Name)) { + return $Default + } + return $Item.$Name +} + +function Convert-RouteModes { + param([object]$TargetStats) + $modes = [ordered]@{} + if ($null -eq $TargetStats) { + return $modes + } + foreach ($targetName in $TargetStats.PSObject.Properties.Name) { + $stats = $TargetStats.$targetName + $routeModes = Get-PropertyValue -Item $stats -Name "route_modes" + if ($null -eq $routeModes) { + continue + } + foreach ($mode in $routeModes.PSObject.Properties.Name) { + if (-not $modes.Contains($mode)) { + $modes[$mode] = 0 + } + $modes[$mode] += [int]$routeModes.$mode + } + } + return $modes +} + +function Convert-TargetDistribution { + param([object]$TargetStreams) + $out = [ordered]@{} + if ($null -eq $TargetStreams) { + return $out + } + foreach ($name in $TargetStreams.PSObject.Properties.Name) { + $out[$name] = [int]$TargetStreams.$name + } + return $out +} + +$files = @(Get-ChildItem -LiteralPath $ArtifactDir -Filter "*-summary.json" -File | Sort-Object LastWriteTime -Descending | Select-Object -First $Limit) +$runs = @() +foreach ($file in $files) { + try { + $summary = Get-Content -LiteralPath $file.FullName -Raw | ConvertFrom-Json + } + catch { + continue + } + $runs += [pscustomobject]@{ + run_id = Get-PropertyValue -Item $summary -Name "run_id" -Default $file.BaseName + verdict = Get-PropertyValue -Item $summary -Name "verdict" + verdict_reasons = Get-PropertyValue -Item $summary -Name "verdict_reasons" + docker_context = Get-PropertyValue -Item $summary -Name "docker_context" + topology_profile = Get-PropertyValue -Item $summary -Name "topology_profile" + soak = [bool](Get-PropertyValue -Item $summary -Name "soak" -Default $false) + total_streams = [int64](Get-PropertyValue -Item $summary -Name "total_streams" -Default 0) + successful_streams = [int64](Get-PropertyValue -Item $summary -Name "successful_streams" -Default 0) + failed_streams = [int64](Get-PropertyValue -Item $summary -Name "failed_streams" -Default 0) + failover_events = [int64](Get-PropertyValue -Item $summary -Name "failover_events" -Default 0) + migration_events = [int64](Get-PropertyValue -Item $summary -Name "migration_events" -Default 0) + channel_opens = [int64](Get-PropertyValue -Item $summary -Name "channel_opens" -Default 0) + channel_closes = [int64](Get-PropertyValue -Item $summary -Name "channel_closes" -Default 0) + channel_leaks = [int64](Get-PropertyValue -Item $summary -Name "channel_leaks" -Default 0) + throughput_bps = [int64](Get-PropertyValue -Item $summary -Name "throughput_bps" -Default 0) + channel_churn_per_sec = [int64](Get-PropertyValue -Item $summary -Name "channel_churn_per_sec" -Default 0) + ack_p95_ms = [int64](Get-PropertyValue -Item $summary -Name "ack_p95_ms" -Default 0) + ack_p99_ms = [int64](Get-PropertyValue -Item $summary -Name "ack_p99_ms" -Default 0) + setup_latency_p95_ms = [int64](Get-PropertyValue -Item $summary -Name "setup_latency_p95_ms" -Default 0) + reroute_latency_p95_ms = [int64](Get-PropertyValue -Item $summary -Name "reroute_latency_p95_ms" -Default 0) + route_modes = Convert-RouteModes -TargetStats (Get-PropertyValue -Item $summary -Name "target_stats") + target_streams = Convert-TargetDistribution -TargetStreams (Get-PropertyValue -Item $summary -Name "target_streams") + container_stats_samples_count = [int](Get-PropertyValue -Item $summary -Name "container_stats_samples_count" -Default 0) + summary_path = $file.FullName + } +} + +$failed = @($runs | Where-Object { $_.verdict -ne "pass" }) +$report = [pscustomobject]@{ + schema_version = "rap.fabric_acceptance_summary.v1" + generated_at = (Get-Date).ToUniversalTime().ToString("o") + artifact_dir = (Resolve-Path $ArtifactDir).ProviderPath + runs_considered = $runs.Count + pass_count = @($runs | Where-Object { $_.verdict -eq "pass" }).Count + fail_count = $failed.Count + all_considered_runs_passed = ($failed.Count -eq 0 -and $runs.Count -gt 0) + runs = $runs +} + +New-Item -ItemType Directory -Force -Path (Split-Path -Parent $OutputPath) | Out-Null +$json = $report | ConvertTo-Json -Depth 30 +Set-Content -LiteralPath $OutputPath -Value $json -Encoding UTF8 +$json diff --git a/scripts/fabric/fabric-live-production-burst.ps1 b/scripts/fabric/fabric-live-production-burst.ps1 new file mode 100644 index 0000000..a991852 --- /dev/null +++ b/scripts/fabric/fabric-live-production-burst.ps1 @@ -0,0 +1,172 @@ +param( + [int]$PerPair = 20, + [int]$TimeoutSeconds = 20, + [string]$AgentDir = "agents\rap-node-agent", + [string]$DockerHost = "test-docker" +) + +$ErrorActionPreference = "Stop" +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).ProviderPath +$agentRoot = Join-Path $repoRoot $AgentDir +$exePath = Join-Path $agentRoot "tmp\fabric-production-smoke.exe" +New-Item -ItemType Directory -Force (Split-Path $exePath) | Out-Null + +Push-Location $agentRoot +try { + go build -o $exePath ./cmd/fabric-production-smoke +} finally { + Pop-Location +} + +$clusterID = "cfc0743d-d960-49fb-9de8-96e063d5e4aa" +$pairs = @( + @{ + name = "home-2-to-home-3" + srcName = "home-2" + route = "a2c2e529-05e6-4e26-9b9e-0ca4f135cbbf" + src = "a6777ebe-44b0-4f4f-95ad-d6bd7caceb8e" + dst = "fab50dc4-ce2f-4f53-a3c3-2fa210530baa" + path = "a6777ebe-44b0-4f4f-95ad-d6bd7caceb8e,fab50dc4-ce2f-4f53-a3c3-2fa210530baa" + }, + @{ + name = "usa-los-1-to-ifcm" + srcName = "usa-los-1" + route = "e8a7a16e-be85-4129-baa3-70bd2d275aad" + src = "b829ffde-690b-47ab-9522-0f22ab42596d" + dst = "f3c95cb7-a189-4dbb-b5d7-5ff93ba9c040" + path = "b829ffde-690b-47ab-9522-0f22ab42596d,f3c95cb7-a189-4dbb-b5d7-5ff93ba9c040" + } +) + +function Invoke-FabricSqlJson { + param([string]$Sql) + + $encoded = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($Sql)) + $output = ssh $DockerHost "printf '%s' '$encoded' | base64 -d | docker exec -i rap_test_postgres psql -U rap_user -d remote_access_platform -t -A -F ''" + $json = ($output -join "`n").Trim() + if ([string]::IsNullOrWhiteSpace($json)) { + throw "SQL query returned no data" + } + return $json | ConvertFrom-Json +} + +$nodeNames = (($pairs | ForEach-Object { $_.srcName }) | Sort-Object -Unique | ForEach-Object { "'$($_.Replace("'", "''"))'" }) -join "," +$endpointRows = Invoke-FabricSqlJson @" +select coalesce(json_agg(row_to_json(t)), '[]'::json) +from ( + select n.name, + h.metadata #>> '{mesh_endpoint_report,peer_endpoint}' as endpoint, + h.metadata #>> '{mesh_endpoint_report,endpoint_candidates,0,metadata,tls_cert_sha256}' as cert + from nodes n + join lateral ( + select * + from node_heartbeats h + where h.node_id = n.id + order by observed_at desc + limit 1 + ) h on true + where n.name in ($nodeNames) +) t; +"@ +$endpointsByName = @{} +foreach ($row in @($endpointRows)) { + $endpointsByName[$row.name] = $row +} +foreach ($pair in $pairs) { + $endpoint = $endpointsByName[$pair.srcName] + if ($null -eq $endpoint -or [string]::IsNullOrWhiteSpace($endpoint.endpoint) -or [string]::IsNullOrWhiteSpace($endpoint.cert)) { + throw "Missing live QUIC endpoint or certificate fingerprint for $($pair.srcName)" + } + $pair["endpoint"] = $endpoint.endpoint + $pair["cert"] = $endpoint.cert +} + +$jobs = @() +foreach ($pair in $pairs) { + for ($i = 0; $i -lt $PerPair; $i++) { + $jobs += Start-Job -ScriptBlock { + param($exePath, $clusterID, $pair, $index, $timeoutSeconds) + $payload = (@{ + kind = "fabric-live-production-burst" + pair = $pair.name + index = $index + } | ConvertTo-Json -Compress) + $payloadB64 = [Convert]::ToBase64String([Text.Encoding]::UTF8.GetBytes($payload)) + $args = @( + "-endpoint", $pair.endpoint, + "-peer-cert-sha256", $pair.cert, + "-cluster-id", $clusterID, + "-route-id", $pair.route, + "-source-node-id", $pair.src, + "-destination-node-id", $pair.dst, + "-current-hop-node-id", $pair.src, + "-next-hop-node-id", $pair.dst, + "-route-path", $pair.path, + "-channel", "fabric_control", + "-timeout", "$($timeoutSeconds)s", + "-payload-b64", $payloadB64 + ) + $output = & $exePath @args 2>&1 + [pscustomobject]@{ + pair = $pair.name + index = $index + exit = $LASTEXITCODE + output = ($output -join "`n") + } + } -ArgumentList $exePath, $clusterID, $pair, $i, $TimeoutSeconds + } +} + +$raw = $jobs | Wait-Job | Receive-Job +$jobs | Remove-Job + +$results = foreach ($item in $raw) { + $ok = $false + $elapsed = $null + $errorText = $null + try { + $json = $item.output | ConvertFrom-Json + $ok = [bool]$json.ok + $elapsed = [int64]$json.elapsed_ms + $errorText = [string]$json.error + } catch { + $errorText = $item.output + } + [pscustomobject]@{ + pair = $item.pair + index = $item.index + exit = $item.exit + ok = $ok + elapsed_ms = $elapsed + error = $errorText + } +} + +$summary = $results | Group-Object pair | ForEach-Object { + $items = @($_.Group) + $latencies = @($items | Where-Object { $_.ok -and $null -ne $_.elapsed_ms } | ForEach-Object { $_.elapsed_ms } | Sort-Object) + $p95 = $null + $max = $null + if ($latencies.Count -gt 0) { + $p95 = $latencies[[Math]::Min($latencies.Count - 1, [int][Math]::Ceiling($latencies.Count * 0.95) - 1)] + $max = $latencies[-1] + } + [pscustomobject]@{ + pair = $_.Name + total = $items.Count + ok = @($items | Where-Object ok).Count + failed = @($items | Where-Object { -not $_.ok }).Count + p95_ms = $p95 + max_ms = $max + } +} + +[pscustomobject]@{ + schema_version = "rap.fabric_live_production_burst.v1" + generated_at = (Get-Date).ToUniversalTime().ToString("o") + total = $results.Count + ok = @($results | Where-Object ok).Count + failed = @($results | Where-Object { -not $_.ok }).Count + summary = $summary + failures = @($results | Where-Object { -not $_.ok } | Select-Object -First 10) +} | ConvertTo-Json -Depth 6 diff --git a/scripts/fabric/fabric-loadtest-docker-smoke.ps1 b/scripts/fabric/fabric-loadtest-docker-smoke.ps1 new file mode 100644 index 0000000..5f651e8 --- /dev/null +++ b/scripts/fabric/fabric-loadtest-docker-smoke.ps1 @@ -0,0 +1,839 @@ +param( + [string]$DockerContext = "test-docker", + [string]$ImageTag = "rap-fabric-loadtest:dev", + [int]$Nodes = 4, + [int]$Streams = 400, + [int]$Concurrency = 64, + [int64]$BytesPerStream = 262144, + [string]$Duration = "", + [string]$ClientTimeout = "10m", + [string]$ServerTimeout = "", + [string]$StreamTimeout = "30s", + [string]$AckTimeout = "2s", + [string]$TargetQuarantineTTL = "30s", + [string]$FailureQuarantineTTL = "5m", + [string]$TopologyProfile = "", + [switch]$Soak, + [int]$ControlEvery = 0, + [int64]$ControlBytesPerStream = 4096, + [int64]$MaxControlAckP95Ms = 100, + [int]$MaxGoroutineDelta = 0, + [int64]$MaxHeapDeltaMB = 0, + [int]$MaxOpenFDDelta = 0, + [int]$MaxOpenFDs = 0, + [int64]$MinThroughputMbps = 0, + [int64]$MinChannelChurnPerSec = 0, + [int64]$MaxContainerMemoryMiB = 0, + [int]$MaxContainerPids = 0, + [int]$PayloadSize = 16384, + [int]$FailTarget = 0, + [string]$FailAfter = "1s", + [int]$ImpairTarget = -1, + [string]$ImpairDelay = "", + [string]$ImpairLoss = "", + [string]$ImpairRate = "", + [string]$ImpairAfter = "", + [switch]$ProbeTargets, + [int64]$MaxTargetRttMs = 0, + [switch]$MigrateSlowStreams, + [int64]$MaxAckMs = 0, + [int64]$MaxAckP95Ms = 0, + [int64]$MaxAckP99Ms = 0, + [int64]$MaxTargetAckMs = 0, + [int64]$MaxSetupP95Ms = 200, + [int64]$MaxSetupP99Ms = 0, + [int64]$MaxRerouteP95Ms = 0, + [int64]$MaxRerouteP99Ms = 0, + [string]$ResourceSampleInterval = "1s", + [string]$ContainerStatsSampleInterval = "", + [string]$ContainerStatsSamplesPath = "", + [int]$UdpBufferBytes = 7500000, + [string]$ArtifactDir = "", + [string]$ReportPath = "", + [string]$SummaryPath = "", + [switch]$TuneUdpBuffers, + [switch]$KeepRunning, + [switch]$SkipBuild +) + +Set-StrictMode -Version Latest +$ErrorActionPreference = "Stop" + +$repoRoot = (Resolve-Path (Join-Path $PSScriptRoot "..\..")).ProviderPath +$runId = "fabric-loadtest-" + (Get-Date -Format "yyyyMMdd-HHmmss") +$networkName = "rap-$runId-net" +$serverPrefix = "rap-$runId-server-" +$clientName = "rap-$runId-client" +if ($ArtifactDir.Trim() -eq "") { + $ArtifactDir = Join-Path $repoRoot "artifacts\fabric-loadtest" +} +if ($ReportPath.Trim() -eq "") { + $ReportPath = Join-Path $ArtifactDir "$runId-report.json" +} +if ($SummaryPath.Trim() -eq "") { + $SummaryPath = Join-Path $ArtifactDir "$runId-summary.json" +} +if ($ContainerStatsSamplesPath.Trim() -eq "") { + $ContainerStatsSamplesPath = Join-Path $ArtifactDir "$runId-container-stats-samples.json" +} +if ($ServerTimeout.Trim() -eq "") { + $ServerTimeout = $ClientTimeout +} + +function Invoke-Docker { + param([string[]]$Arguments) + docker --context $DockerContext @Arguments + if ($LASTEXITCODE -ne 0) { + throw "docker --context $DockerContext $($Arguments -join ' ') failed with exit code $LASTEXITCODE" + } +} + +function Invoke-DockerText { + param([string[]]$Arguments) + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + $output = docker --context $DockerContext @Arguments 2>&1 | ForEach-Object { $_.ToString() } + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } + if ($LASTEXITCODE -ne 0) { + throw "docker --context $DockerContext $($Arguments -join ' ') failed with exit code $LASTEXITCODE`n$($output -join [Environment]::NewLine)" + } + return $output +} + +function Assert-SharedDockerContext { + $normalized = $DockerContext.Trim().ToLowerInvariant() + if ($normalized -eq "" -or $normalized -eq "default" -or $normalized -eq "desktop-linux" -or $normalized -eq "docker-desktop") { + throw "fabric loadtest must use the shared Docker host context, not local Docker Desktop. Use -DockerContext test-docker, docker-test, or test-ubuntu." + } +} + +function Assert-QuicTargets { + param([string[]]$Targets) + $invalid = @() + foreach ($target in $Targets) { + $trimmed = ([string]$target).Trim() + if ($trimmed -eq "" -or -not $trimmed.ToLowerInvariant().StartsWith("quic://")) { + if ($trimmed -eq "") { + $invalid += "" + } + else { + $invalid += $trimmed + } + } + } + if ($invalid.Count -gt 0) { + throw "fabric loadtest targets must be QUIC-only: $($invalid -join ', ')" + } +} + +function Cleanup { + if ($KeepRunning) { + return + } + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + docker --context $DockerContext rm -f $clientName 2>$null | Out-Null + for ($i = 0; $i -lt $Nodes; $i++) { + docker --context $DockerContext rm -f "$serverPrefix$i" 2>$null | Out-Null + } + docker --context $DockerContext network rm $networkName 2>$null | Out-Null + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } +} + +function Invoke-HostSysctl { + param([string[]]$Arguments) + $dockerArgs = @( + "run", "--rm", + "--privileged", + "--network", "host", + "--entrypoint", "sysctl", + $ImageTag + ) + $Arguments + return Invoke-DockerText -Arguments $dockerArgs +} + +function Set-HostUdpBuffers { + Invoke-HostSysctl -Arguments @( + "-w", + "net.core.rmem_max=$UdpBufferBytes", + "net.core.wmem_max=$UdpBufferBytes", + "net.core.rmem_default=$UdpBufferBytes", + "net.core.wmem_default=$UdpBufferBytes" + ) | Out-Null +} + +function Get-HostUdpBuffers { + $lines = Invoke-HostSysctl -Arguments @( + "net.core.rmem_max", + "net.core.wmem_max", + "net.core.rmem_default", + "net.core.wmem_default" + ) + $values = [ordered]@{} + foreach ($line in $lines) { + if ($line -match '^(?[^=]+)=\s*(?\d+)$') { + $values[$Matches["key"].Trim()] = [int64]$Matches["value"] + } + } + return $values +} + +function Set-ContainerImpairment { + param([string]$ContainerName) + $netem = @() + if ($ImpairDelay.Trim() -ne "") { + $netem += @("delay", $ImpairDelay.Trim()) + } + if ($ImpairLoss.Trim() -ne "") { + $netem += @("loss", $ImpairLoss.Trim()) + } + if ($ImpairRate.Trim() -ne "") { + $netem += @("rate", $ImpairRate.Trim()) + } + if ($netem.Count -eq 0) { + return @() + } + $previousErrorActionPreference = $ErrorActionPreference + $ErrorActionPreference = "Continue" + try { + docker --context $DockerContext exec $ContainerName tc qdisc del dev eth0 root 2>$null | Out-Null + } + finally { + $ErrorActionPreference = $previousErrorActionPreference + } + Invoke-Docker -Arguments (@("exec", $ContainerName, "tc", "qdisc", "add", "dev", "eth0", "root", "netem") + $netem) + return Invoke-DockerText -Arguments @("exec", $ContainerName, "tc", "qdisc", "show", "dev", "eth0") +} + +function Get-FabricContainerStats { + $names = @($clientName) + for ($i = 0; $i -lt $Nodes; $i++) { + $names += "$serverPrefix$i" + } + $stats = @() + foreach ($name in $names) { + $inspect = $null + try { + $inspectText = Invoke-DockerText -Arguments @("inspect", $name) + $inspect = @(($inspectText -join [Environment]::NewLine) | ConvertFrom-Json) + } + catch { + continue + } + if ($null -eq $inspect -or $inspect.Count -eq 0 -or $inspect[0].State.Running -ne $true) { + continue + } + try { + $line = @(Invoke-DockerText -Arguments @("stats", "--no-stream", "--format", "{{json .}}", $name)) + if ($line.Count -gt 0) { + $item = ($line[0] | ConvertFrom-Json) + $memoryUsageMiB = Convert-DockerMemoryUsageToMiB -MemoryUsage $item.MemUsage + $pidCount = Convert-DockerPidsToInt -Pids $item.PIDs + $stats += [pscustomobject]@{ + name = $item.Name + id = $item.ID + cpu_percent = $item.CPUPerc + memory_percent = $item.MemPerc + memory_usage = $item.MemUsage + memory_usage_mib = $memoryUsageMiB + net_io = $item.NetIO + block_io = $item.BlockIO + pids = $item.PIDs + pids_count = $pidCount + role = $(if ($item.Name -eq $clientName) { "client" } else { "server" }) + } + } + } + catch { + $stats += [pscustomobject]@{ + name = $name + error = $_.Exception.Message + } + } + } + return $stats +} + +function Convert-DockerMemoryUsageToMiB { + param([string]$MemoryUsage) + if ($MemoryUsage.Trim() -eq "") { + return $null + } + $used = ($MemoryUsage -split '/')[0].Trim() + if ($used -notmatch '^(?[0-9.]+)\s*(?[A-Za-z]+)$') { + return $null + } + $value = [double]$Matches["value"] + switch ($Matches["unit"].ToLowerInvariant()) { + "b" { return [math]::Round($value / 1MB, 3) } + "kb" { return [math]::Round($value / 1024, 3) } + "kib" { return [math]::Round($value / 1024, 3) } + "mb" { return [math]::Round($value, 3) } + "mib" { return [math]::Round($value, 3) } + "gb" { return [math]::Round($value * 1024, 3) } + "gib" { return [math]::Round($value * 1024, 3) } + default { return $null } + } +} + +function Convert-DockerPidsToInt { + param([string]$Pids) + $value = 0 + if ([int]::TryParse($Pids, [ref]$value)) { + return $value + } + return $null +} + +function Convert-DurationToMilliseconds { + param([string]$Duration, [int]$DefaultMilliseconds) + $value = $Duration.Trim() + if ($value -eq "") { + return $DefaultMilliseconds + } + if ($value -match '^(?[0-9]+)ms$') { + return [int]$Matches["n"] + } + if ($value -match '^(?[0-9]+)s$') { + return [int]$Matches["n"] * 1000 + } + if ($value -match '^(?[0-9]+)m$') { + return [int]$Matches["n"] * 60 * 1000 + } + if ($value -match '^(?[0-9]+)$') { + return [int]$Matches["n"] * 1000 + } + return $DefaultMilliseconds +} + +function Start-ContainerStatsSampler { + param([string[]]$Names) + if ($ContainerStatsSampleInterval.Trim() -eq "") { + return $null + } + $intervalMs = Convert-DurationToMilliseconds -Duration $ContainerStatsSampleInterval -DefaultMilliseconds 10000 + if ($intervalMs -le 0) { + return $null + } + return Start-Job -ScriptBlock { + param($dockerContext, $names, $intervalMs) + while ($true) { + $observedAt = (Get-Date).ToUniversalTime().ToString("o") + foreach ($name in $names) { + $running = docker --context $dockerContext inspect -f "{{.State.Running}}" $name 2>$null + if ($LASTEXITCODE -ne 0 -or $running -ne "true") { + continue + } + $line = docker --context $dockerContext stats --no-stream --format "{{json .}}" $name 2>$null + if ($LASTEXITCODE -ne 0 -or $null -eq $line -or $line.Trim() -eq "") { + continue + } + try { + $item = $line | ConvertFrom-Json + [pscustomobject]@{ + observed_at = $observedAt + name = $item.Name + id = $item.ID + cpu_percent = $item.CPUPerc + memory_percent = $item.MemPerc + memory_usage = $item.MemUsage + net_io = $item.NetIO + block_io = $item.BlockIO + pids = $item.PIDs + role = $(if ($item.Name -like "*-client") { "client" } else { "server" }) + } | ConvertTo-Json -Compress + } + catch { + } + } + Start-Sleep -Milliseconds $intervalMs + } + } -ArgumentList $DockerContext, $Names, $intervalMs +} + +function Stop-ContainerStatsSampler { + param($Job) + if ($null -eq $Job) { + return @() + } + Stop-Job -Job $Job -ErrorAction SilentlyContinue | Out-Null + $lines = @(Receive-Job -Job $Job -ErrorAction SilentlyContinue) + Remove-Job -Job $Job -Force -ErrorAction SilentlyContinue | Out-Null + $samples = @() + foreach ($line in $lines) { + if ($null -eq $line -or $line.ToString().Trim() -eq "") { + continue + } + try { + $sample = $line.ToString() | ConvertFrom-Json + $sample | Add-Member -NotePropertyName memory_usage_mib -NotePropertyValue (Convert-DockerMemoryUsageToMiB -MemoryUsage $sample.memory_usage) -Force + $sample | Add-Member -NotePropertyName pids_count -NotePropertyValue (Convert-DockerPidsToInt -Pids $sample.pids) -Force + $samples += $sample + } + catch { + } + } + return $samples +} + +function Get-ContainerStatsSampleSummary { + param([object[]]$Samples) + if ($null -eq $Samples -or $Samples.Count -eq 0) { + return $null + } + $byName = @{} + foreach ($sample in $Samples) { + if ($sample.name -eq $null) { + continue + } + if (-not $byName.ContainsKey($sample.name)) { + $byName[$sample.name] = [ordered]@{ + name = $sample.name + role = $sample.role + sample_count = 0 + memory_usage_mib_start = $sample.memory_usage_mib + memory_usage_mib_end = $sample.memory_usage_mib + memory_usage_mib_max = $sample.memory_usage_mib + pids_start = $sample.pids_count + pids_end = $sample.pids_count + pids_max = $sample.pids_count + } + } + $entry = $byName[$sample.name] + $entry.sample_count++ + $entry.memory_usage_mib_end = $sample.memory_usage_mib + if ($null -ne $sample.memory_usage_mib -and ($null -eq $entry.memory_usage_mib_max -or $sample.memory_usage_mib -gt $entry.memory_usage_mib_max)) { + $entry.memory_usage_mib_max = $sample.memory_usage_mib + } + $entry.pids_end = $sample.pids_count + if ($null -ne $sample.pids_count -and ($null -eq $entry.pids_max -or $sample.pids_count -gt $entry.pids_max)) { + $entry.pids_max = $sample.pids_count + } + } + $summary = @() + foreach ($entry in $byName.Values) { + $entry.memory_usage_mib_delta = if ($null -ne $entry.memory_usage_mib_start -and $null -ne $entry.memory_usage_mib_end) { [math]::Round($entry.memory_usage_mib_end - $entry.memory_usage_mib_start, 3) } else { $null } + $entry.pids_delta = if ($null -ne $entry.pids_start -and $null -ne $entry.pids_end) { $entry.pids_end - $entry.pids_start } else { $null } + $summary += [pscustomobject]$entry + } + return $summary +} + +function Get-ContainerStatsSampleVerdictReasons { + param([object[]]$SampleSummary) + $reasons = @() + if ($null -eq $SampleSummary) { + return $reasons + } + foreach ($stat in $SampleSummary) { + if ($MaxContainerMemoryMiB -gt 0 -and $null -ne $stat.memory_usage_mib_max -and $stat.memory_usage_mib_max -gt $MaxContainerMemoryMiB) { + $reasons += "container_memory_mib_peak=$($stat.name):$($stat.memory_usage_mib_max)>$MaxContainerMemoryMiB" + } + if ($MaxContainerPids -gt 0 -and $null -ne $stat.pids_max -and $stat.pids_max -gt $MaxContainerPids) { + $reasons += "container_pids_peak=$($stat.name):$($stat.pids_max)>$MaxContainerPids" + } + } + return $reasons +} + +function Get-ContainerStatsVerdictReasons { + param([object[]]$ContainerStats) + $reasons = @() + foreach ($stat in $ContainerStats) { + if ($stat.PSObject.Properties.Name -contains "error") { + $reasons += "container_stats_error=$($stat.name)" + continue + } + if ($MaxContainerMemoryMiB -gt 0 -and $null -ne $stat.memory_usage_mib -and $stat.memory_usage_mib -gt $MaxContainerMemoryMiB) { + $reasons += "container_memory_mib=$($stat.name):$($stat.memory_usage_mib)>$MaxContainerMemoryMiB" + } + if ($MaxContainerPids -gt 0 -and $null -ne $stat.pids_count -and $stat.pids_count -gt $MaxContainerPids) { + $reasons += "container_pids=$($stat.name):$($stat.pids_count)>$MaxContainerPids" + } + } + return $reasons +} + +try { + Assert-SharedDockerContext + + if (-not $SkipBuild) { + Invoke-Docker -Arguments @( + "build", + "-f", "$repoRoot\agents\rap-node-agent\Dockerfile.fabric-loadtest", + "-t", $ImageTag, + $repoRoot + ) + } + + if ($TuneUdpBuffers) { + Set-HostUdpBuffers + } + $udpBuffers = Get-HostUdpBuffers + + Cleanup + Invoke-Docker -Arguments @("network", "create", $networkName) | Out-Null + + for ($i = 0; $i -lt $Nodes; $i++) { + $name = "$serverPrefix$i" + Invoke-Docker -Arguments @( + "run", "-d", + "--name", $name, + "--network", $networkName, + "--cap-add", "NET_ADMIN", + $ImageTag, + "-mode", "server", + "-listen", "0.0.0.0:19443", + "-timeout", $ServerTimeout, + "-concurrency", ([string]$Concurrency) + ) | Out-Null + } + + Start-Sleep -Seconds 3 + + $impairment = @() + if ($ImpairTarget -ge 0 -and $ImpairTarget -lt $Nodes) { + if ($ImpairAfter.Trim() -eq "") { + $impairment = Set-ContainerImpairment -ContainerName "$serverPrefix$ImpairTarget" + } + else { + $impairment = @("scheduled target=$serverPrefix$ImpairTarget after=$ImpairAfter delay=$ImpairDelay loss=$ImpairLoss rate=$ImpairRate") + Start-Job -ScriptBlock { + param($dockerContext, $containerName, $delay, $impairDelay, $impairLoss, $impairRate) + if ($delay -match '^(\d+)ms$') { + Start-Sleep -Milliseconds ([int]$Matches[1]) + } + elseif ($delay -match '^(\d+)s$') { + Start-Sleep -Seconds ([int]$Matches[1]) + } + elseif ($delay -match '^(\d+)$') { + Start-Sleep -Seconds ([int]$Matches[1]) + } + docker --context $dockerContext exec $containerName tc qdisc del dev eth0 root 2>$null | Out-Null + $args = @("exec", $containerName, "tc", "qdisc", "add", "dev", "eth0", "root", "netem") + if ($impairDelay.Trim() -ne "") { $args += @("delay", $impairDelay.Trim()) } + if ($impairLoss.Trim() -ne "") { $args += @("loss", $impairLoss.Trim()) } + if ($impairRate.Trim() -ne "") { $args += @("rate", $impairRate.Trim()) } + docker --context $dockerContext @args | Out-Null + } -ArgumentList $DockerContext, "$serverPrefix$ImpairTarget", $ImpairAfter, $ImpairDelay, $ImpairLoss, $ImpairRate | Out-Null + } + } + + $targets = @() + for ($i = 0; $i -lt $Nodes; $i++) { + $targets += "quic://$serverPrefix$i`:19443" + } + Assert-QuicTargets -Targets $targets + $targetList = ($targets -join ",") + + if ($FailTarget -ge 0 -and $FailTarget -lt $Nodes) { + Start-Job -ScriptBlock { + param($dockerContext, $containerName, $delay) + if ($delay -match '^(\d+)ms$') { + Start-Sleep -Milliseconds ([int]$Matches[1]) + } + elseif ($delay -match '^(\d+)s$') { + Start-Sleep -Seconds ([int]$Matches[1]) + } + elseif ($delay -match '^(\d+)$') { + Start-Sleep -Seconds ([int]$Matches[1]) + } + docker --context $dockerContext rm -f $containerName | Out-Null + } -ArgumentList $DockerContext, "$serverPrefix$FailTarget", $FailAfter | Out-Null + } + + $clientArgs = @( + "run", "--rm", + "--name", $clientName, + "--network", $networkName, + $ImageTag, + "-mode", "client", + "-targets", $targetList, + "-topology-profile", $TopologyProfile, + "-soak=$($Soak.IsPresent.ToString().ToLowerInvariant())", + "-streams", ([string]$Streams), + "-concurrency", ([string]$Concurrency), + "-bytes-per-stream", ([string]$BytesPerStream), + "-control-every", ([string]$ControlEvery), + "-control-bytes-per-stream", ([string]$ControlBytesPerStream), + "-max-control-ack-p95-ms", ([string]$MaxControlAckP95Ms), + "-payload-size", ([string]$PayloadSize), + "-resource-sample-interval", $ResourceSampleInterval, + "-stream-timeout", $StreamTimeout, + "-ack-timeout", $AckTimeout, + "-target-quarantine-ttl", $TargetQuarantineTTL, + "-failure-quarantine-ttl", $FailureQuarantineTTL, + "-fail-target", ([string]$FailTarget), + "-impair-target", ([string]$ImpairTarget), + "-pool-failover=true", + "-timeout", $ClientTimeout + ) + if ($ProbeTargets) { + $clientArgs += "-probe-targets=true" + } + if ($MaxTargetRttMs -gt 0) { + $clientArgs += @("-max-target-rtt-ms", ([string]$MaxTargetRttMs)) + } + if ($MigrateSlowStreams) { + $clientArgs += "-migrate-slow-streams=true" + } + if ($MaxAckMs -gt 0) { + $clientArgs += @("-max-ack-ms", ([string]$MaxAckMs)) + } + if ($MaxAckP95Ms -gt 0) { + $clientArgs += @("-max-ack-p95-ms", ([string]$MaxAckP95Ms)) + } + if ($MaxAckP99Ms -gt 0) { + $clientArgs += @("-max-ack-p99-ms", ([string]$MaxAckP99Ms)) + } + if ($MaxTargetAckMs -gt 0) { + $clientArgs += @("-max-target-ack-ms", ([string]$MaxTargetAckMs)) + } + if ($MaxSetupP95Ms -gt 0) { + $clientArgs += @("-max-setup-p95-ms", ([string]$MaxSetupP95Ms)) + } + if ($MaxSetupP99Ms -gt 0) { + $clientArgs += @("-max-setup-p99-ms", ([string]$MaxSetupP99Ms)) + } + if ($MaxRerouteP95Ms -gt 0) { + $clientArgs += @("-max-reroute-p95-ms", ([string]$MaxRerouteP95Ms)) + } + if ($MaxRerouteP99Ms -gt 0) { + $clientArgs += @("-max-reroute-p99-ms", ([string]$MaxRerouteP99Ms)) + } + if ($MaxGoroutineDelta -gt 0) { + $clientArgs += @("-max-goroutine-delta", ([string]$MaxGoroutineDelta)) + } + if ($MaxHeapDeltaMB -gt 0) { + $clientArgs += @("-max-heap-delta-mb", ([string]$MaxHeapDeltaMB)) + } + if ($MaxOpenFDDelta -gt 0) { + $clientArgs += @("-max-open-fd-delta", ([string]$MaxOpenFDDelta)) + } + if ($MaxOpenFDs -gt 0) { + $clientArgs += @("-max-open-fds", ([string]$MaxOpenFDs)) + } + if ($MinThroughputMbps -gt 0) { + $clientArgs += @("-min-throughput-mbps", ([string]$MinThroughputMbps)) + } + if ($MinChannelChurnPerSec -gt 0) { + $clientArgs += @("-min-channel-churn-per-sec", ([string]$MinChannelChurnPerSec)) + } + if ($Duration.Trim() -ne "") { + $clientArgs += @("-duration", $Duration.Trim()) + } + $containerNamesForSampling = @($clientName) + for ($i = 0; $i -lt $Nodes; $i++) { + $containerNamesForSampling += "$serverPrefix$i" + } + $containerStatsSamplerJob = Start-ContainerStatsSampler -Names $containerNamesForSampling + $containerStatsSamples = @() + try { + $clientOutput = Invoke-DockerText -Arguments $clientArgs + } + finally { + $containerStatsSamples = @(Stop-ContainerStatsSampler -Job $containerStatsSamplerJob) + } + + $rawText = ($clientOutput -join [Environment]::NewLine) + $jsonStart = $rawText.IndexOf("{") + if ($jsonStart -lt 0) { + throw "fabric loadtest client did not emit JSON: $rawText" + } + $jsonText = $rawText.Substring($jsonStart) + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ReportPath) | Out-Null + Set-Content -LiteralPath $ReportPath -Value $jsonText -Encoding UTF8 + $report = $jsonText | ConvertFrom-Json + $verdictReasons = $null + if ($report.PSObject.Properties.Name -contains "verdict_reasons") { + $verdictReasons = $report.verdict_reasons + } + $targetStats = $null + if ($report.PSObject.Properties.Name -contains "target_stats") { + $targetStats = $report.target_stats + } + $targetProbes = $null + if ($report.PSObject.Properties.Name -contains "target_probes") { + $targetProbes = $report.target_probes + } + $excludedTargets = $null + if ($report.PSObject.Properties.Name -contains "excluded_targets") { + $excludedTargets = $report.excluded_targets + } + $migrationEvents = 0 + if ($report.PSObject.Properties.Name -contains "migration_events") { + $migrationEvents = $report.migration_events + } + $routePressure = $null + if ($report.PSObject.Properties.Name -contains "route_pressure") { + $routePressure = $report.route_pressure + } + $transportSnapshot = $null + if ($report.PSObject.Properties.Name -contains "transport_snapshot") { + $transportSnapshot = $report.transport_snapshot + } + $degradedTargets = $null + if ($report.PSObject.Properties.Name -contains "degraded_targets") { + $degradedTargets = $report.degraded_targets + } + $ackP95 = $null + if ($report.PSObject.Properties.Name -contains "ack_p95_ms") { + $ackP95 = $report.ack_p95_ms + } + $ackP99 = $null + if ($report.PSObject.Properties.Name -contains "ack_p99_ms") { + $ackP99 = $report.ack_p99_ms + } + $rerouteLatencyP95 = $null + if ($report.PSObject.Properties.Name -contains "reroute_latency_p95_ms") { + $rerouteLatencyP95 = $report.reroute_latency_p95_ms + } + $rerouteLatencyP99 = $null + if ($report.PSObject.Properties.Name -contains "reroute_latency_p99_ms") { + $rerouteLatencyP99 = $report.reroute_latency_p99_ms + } + $rerouteCauses = $null + if ($report.PSObject.Properties.Name -contains "reroute_causes") { + $rerouteCauses = $report.reroute_causes + } + $resourceSummary = $null + if ($report.PSObject.Properties.Name -contains "resource_summary") { + $resourceSummary = $report.resource_summary + } + $ackMismatchedStreams = 0 + if ($report.PSObject.Properties.Name -contains "ack_mismatched_streams") { + $ackMismatchedStreams = $report.ack_mismatched_streams + } + $ackIntegrityErrors = 0 + if ($report.PSObject.Properties.Name -contains "ack_integrity_errors") { + $ackIntegrityErrors = $report.ack_integrity_errors + } + $abandonedFrames = 0 + if ($report.PSObject.Properties.Name -contains "abandoned_frames") { + $abandonedFrames = $report.abandoned_frames + } + $controlAckP95 = $null + if ($report.PSObject.Properties.Name -contains "control_ack_p95_ms") { + $controlAckP95 = $report.control_ack_p95_ms + } + $bulkAckP95 = $null + if ($report.PSObject.Properties.Name -contains "bulk_ack_p95_ms") { + $bulkAckP95 = $report.bulk_ack_p95_ms + } + $containerStats = Get-FabricContainerStats + if ($ContainerStatsSampleInterval.Trim() -ne "" -and $containerStatsSamples.Count -eq 0 -and $containerStats.Count -gt 0) { + $observedAt = (Get-Date).ToUniversalTime().ToString("o") + foreach ($stat in $containerStats) { + if ($stat.PSObject.Properties.Name -contains "error") { + continue + } + $containerStatsSamples += [pscustomobject]@{ + observed_at = $observedAt + name = $stat.name + id = $stat.id + cpu_percent = $stat.cpu_percent + memory_percent = $stat.memory_percent + memory_usage = $stat.memory_usage + memory_usage_mib = $stat.memory_usage_mib + net_io = $stat.net_io + block_io = $stat.block_io + pids = $stat.pids + pids_count = $stat.pids_count + role = $stat.role + } + } + } + $containerStatsSampleSummary = Get-ContainerStatsSampleSummary -Samples $containerStatsSamples + if ($containerStatsSamples.Count -gt 0) { + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $ContainerStatsSamplesPath) | Out-Null + Set-Content -LiteralPath $ContainerStatsSamplesPath -Value ($containerStatsSamples | ConvertTo-Json -Depth 20) -Encoding UTF8 + } + $containerVerdictReasons = @(Get-ContainerStatsVerdictReasons -ContainerStats $containerStats) + $containerSampleVerdictReasons = @(Get-ContainerStatsSampleVerdictReasons -SampleSummary $containerStatsSampleSummary) + $combinedVerdictReasons = @() + if ($null -ne $verdictReasons) { + $combinedVerdictReasons += $verdictReasons + } + $combinedVerdictReasons += $containerVerdictReasons + $combinedVerdictReasons += $containerSampleVerdictReasons + $summaryVerdict = $report.verdict + if ($containerVerdictReasons.Count -gt 0 -or $containerSampleVerdictReasons.Count -gt 0) { + $summaryVerdict = "fail" + } + $summary = [pscustomobject]@{ + run_id = $runId + report_path = $ReportPath + summary_path = $SummaryPath + docker_context = $DockerContext + nodes = $Nodes + topology_profile = $TopologyProfile + soak = $Soak.IsPresent + targets = $targets + total_streams = $report.total_streams + successful_streams = $report.successful_streams + failed_streams = $report.failed_streams + ack_mismatched_streams = $ackMismatchedStreams + ack_integrity_errors = $ackIntegrityErrors + abandoned_frames = $abandonedFrames + failover_events = $report.failover_events + migration_events = $migrationEvents + channel_opens = $report.channel_opens + channel_closes = $report.channel_closes + channel_leaks = $report.channel_leaks + channel_churn_per_sec = $report.channel_churn_per_sec + bytes_sent = $report.bytes_sent + throughput_bps = $report.throughput_bps + setup_latency_p95_ms = $report.setup_latency_p95_ms + channel_open_p95_ms = $report.channel_open_p95_ms + stream_duration_p95_ms = $report.stream_duration_p95_ms + ack_p95_ms = $ackP95 + ack_p99_ms = $ackP99 + reroute_latency_p95_ms = $rerouteLatencyP95 + reroute_latency_p99_ms = $rerouteLatencyP99 + route_attempts_total = $report.route_attempts_total + reroute_causes = $rerouteCauses + resource_summary = $resourceSummary + container_stats = $containerStats + container_stats_samples_path = $(if ($containerStatsSamples.Count -gt 0) { $ContainerStatsSamplesPath } else { $null }) + container_stats_samples_count = $containerStatsSamples.Count + container_stats_sample_summary = $containerStatsSampleSummary + control_streams = $report.control_streams + bulk_streams = $report.bulk_streams + control_ack_p95_ms = $controlAckP95 + bulk_ack_p95_ms = $bulkAckP95 + verdict = $summaryVerdict + verdict_reasons = $combinedVerdictReasons + udp_buffers = $udpBuffers + impairment = $impairment + target_probes = $targetProbes + excluded_targets = $excludedTargets + degraded_targets = $degradedTargets + target_streams = $report.target_streams + target_bytes = $report.target_bytes + target_stats = $targetStats + route_pressure = $routePressure + transport_snapshot = $transportSnapshot + } + $summaryJson = $summary | ConvertTo-Json -Depth 20 + New-Item -ItemType Directory -Force -Path (Split-Path -Parent $SummaryPath) | Out-Null + Set-Content -LiteralPath $SummaryPath -Value $summaryJson -Encoding UTF8 + $summaryJson + if ($summaryVerdict -ne "pass") { + $reasonText = "" + if ($combinedVerdictReasons.Count -gt 0) { + $reasonText = ($combinedVerdictReasons -join "; ") + } + throw "fabric loadtest verdict failed: $summaryVerdict $reasonText" + } +} +finally { + Cleanup +} diff --git a/web-admin/deploy/html/assets/index-BeytaWKC.css b/web-admin/deploy/html/assets/index-BeytaWKC.css deleted file mode 100644 index 747bfa3..0000000 --- a/web-admin/deploy/html/assets/index-BeytaWKC.css +++ /dev/null @@ -1 +0,0 @@ -:root{color:#182018;--ink:#182018;--muted:#667064;--paper:#fffcefe6;--paper-strong:#fffaf0;--line:#1a271b26;--green:#2f6f4f;--amber:#b86f23;--red:#a64235;--steel:#36556c;--blue:#284b8f;--shadow:0 24px 60px #2a32281c;background:#ecede4;font-family:IBM Plex Sans,Aptos,Segoe UI,sans-serif}*{box-sizing:border-box}body{background:radial-gradient(circle at 8% 8%,#b86f2333,#0000 27rem),radial-gradient(circle at 92% 16%,#2f6f4f2b,#0000 28rem),linear-gradient(135deg,#f4f1e7 0%,#dfe5db 58%,#f4f1e7 100%);min-width:320px;min-height:100vh;margin:0}button,input,select,textarea{font:inherit}button{border:1px solid var(--line);color:var(--ink);cursor:pointer;background:#fffaf0;border-radius:999px;padding:.66rem 1rem}button:hover:not(:disabled){transform:translateY(-1px)}button:disabled{cursor:not-allowed;opacity:.52}button.primary{color:#fffaf0;background:linear-gradient(135deg,#203d2f,#4f8565);border-color:#0000;box-shadow:0 14px 32px #244c383d}button.ghost{background:#fffaf085}button.wide{justify-content:center;width:100%}button.danger{color:#fffaf0;background:linear-gradient(135deg,#8f372d,#bd5d4d)}.loginShell{grid-template-columns:minmax(300px,420px);justify-content:center;align-items:center;min-height:100vh;padding:clamp(24px,5vw,72px);display:grid}.loginHero{color:#fffaf0;min-height:560px;box-shadow:var(--shadow);background:linear-gradient(155deg,#101b13fa,#1a382af2),radial-gradient(circle at 20% 20%,#b86f2357,#0000 18rem);border-radius:38px;padding:clamp(28px,5vw,64px)}.loginHero h1{letter-spacing:-.075em;max-width:780px;margin:0 0 20px;font-size:clamp(3rem,8vw,7rem);line-height:.88}.loginHero p{color:#fffaf0c2;max-width:720px;font-size:1.08rem}.loginCard{border:1px solid var(--line);box-shadow:var(--shadow);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);background:#fffcefd6;border-radius:30px;gap:16px;padding:28px;display:grid}.loginCard h1{letter-spacing:-.035em;margin:0 0 2px;font-size:1.55rem}.loginHint{color:var(--muted);margin:0;line-height:1.45}.checkLine{color:var(--ink);grid-template-columns:auto minmax(0,1fr);align-items:center}.checkLine input{width:18px;height:18px}.consoleShell{grid-template-columns:310px minmax(0,1fr);min-height:100vh;display:grid}.portalShell{grid-template-columns:280px minmax(0,1fr);min-height:100vh;display:grid}.sideRail,.portalRail{color:#fffaf0;background:linear-gradient(160deg,#101b13fa,#1a382af5),radial-gradient(circle at 20% 20%,#b86f235c,#0000 16rem);height:100vh;padding:28px;position:sticky;top:0}.portalRail button{width:100%;margin-top:16px}.brandMark{letter-spacing:.08em;background:#fffaf01a;border:1px solid #fffaf038;border-radius:18px;place-items:center;width:58px;height:58px;margin-bottom:28px;font-weight:900;display:inline-grid}.sideKicker,.eyebrow{color:#d59b54;letter-spacing:.16em;text-transform:uppercase;margin:0 0 8px;font-size:.78rem;font-weight:900}.sideRail h1,.portalRail h1{letter-spacing:-.06em;margin:0 0 12px;font-size:clamp(2.3rem,4vw,4.2rem);line-height:.9}.sideText{color:#fffaf0b8;margin:0 0 26px}.railNav{gap:8px;display:grid}.railNav button{color:#fffaf0c2;text-align:left;background:0 0;border-color:#fffaf021;justify-content:flex-start}.railNav button.active{color:#182018;background:#fffaf0}.workspace{width:min(1500px,100vw - 340px);padding:28px 28px 54px}.portalWorkspace{width:min(1280px,100vw - 310px);padding:28px 28px 54px}.topBar,.portalTop{border:1px solid var(--line);box-shadow:var(--shadow);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);background:#fffcefb8;border-radius:30px;grid-template-columns:minmax(0,1fr) minmax(260px,360px) auto minmax(150px,auto) minmax(220px,auto);align-items:center;gap:20px;padding:28px;display:grid}.portalTop{grid-template-columns:minmax(0,1fr) minmax(240px,340px) auto}.clusterPicker{border:1px solid var(--line);background:#ffffff80;border-radius:22px;gap:8px;min-width:0;padding:14px;display:grid}.clusterPicker span{color:var(--muted);overflow-wrap:anywhere;font-size:.82rem;font-weight:800}.refreshStatus{min-width:150px;color:var(--muted);gap:4px;font-size:.78rem;font-weight:800;line-height:1.35;display:grid}.refreshStatus strong{color:var(--text);font-size:.82rem}.refreshStatus span{overflow-wrap:anywhere}.profilePanel{border:1px solid var(--line);background:#ffffff7a;border-radius:22px;grid-template-columns:1fr;gap:8px;min-width:240px;padding:14px;display:grid}.profilePanel span{color:var(--muted);overflow-wrap:anywhere}h2,h3,h4,p{margin-top:0}h2{letter-spacing:-.055em;margin-bottom:8px;font-size:clamp(2rem,4vw,4rem);line-height:.94}h3{margin-bottom:10px;font-size:1.35rem}.muted{color:var(--muted)}.controlBar,.grid,.stack{margin-top:18px}.controlBar{border:1px solid var(--line);background:#fffcef9e;border-radius:22px;grid-template-columns:minmax(220px,.45fr);gap:14px;padding:16px;display:grid}label{color:var(--muted);gap:7px;font-size:.82rem;font-weight:800;display:grid}label small{color:var(--muted);font-weight:600;line-height:1.35}input,select,textarea{border:1px solid var(--line);min-width:0;color:var(--ink);background:#ffffffc7;border-radius:14px;padding:.75rem .85rem}textarea{resize:vertical;min-height:84px;font-family:Cascadia Code,Consolas,monospace;font-size:.86rem}details{gap:12px;margin:14px 0;display:grid}summary{cursor:pointer;color:var(--steel);font-weight:800}.grid{gap:16px;display:grid}.grid.five{grid-template-columns:repeat(5,minmax(140px,1fr))}.grid.three{grid-template-columns:repeat(3,minmax(170px,1fr))}.grid.two{grid-template-columns:repeat(2,minmax(280px,1fr))}.span2{grid-column:span 2}.span3{grid-column:span 3}.stack{gap:14px;display:grid}.card,.metric,.empty,.errorPanel,.noticePanel{border:1px solid var(--line);background:var(--paper);box-shadow:var(--shadow);border-radius:24px}.card,.empty{padding:22px}.metric{min-height:128px;padding:20px}.metric span,.signal span,.stateLine span{color:var(--muted);font-weight:800;display:block}.metric span{margin-bottom:16px}.metric strong{font-size:3.1rem;line-height:1}.green strong{color:var(--green)}.amber strong{color:var(--amber)}.red strong{color:var(--red)}.steel strong{color:var(--steel)}.cardHead,.roleRow,.requestCard,.vpnCard{grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:16px;display:grid}.cardHead.compact{gap:10px}.subPanel{border:1px solid var(--line);background:#ffffff73;border-radius:8px;gap:14px;padding:16px;display:grid}.portalInstallList{gap:10px;margin-top:14px;display:grid}.installTile{border:1px solid var(--line);color:var(--ink);background:#ffffff85;border-radius:18px;gap:6px;padding:16px;text-decoration:none;display:grid}.installTile:hover{transform:translateY(-1px)}.installTile strong{font-size:1.05rem}.installTile span,.installTile small{color:var(--muted);overflow-wrap:anywhere}.primaryInstall{background:#2f6f4f1a;border-color:#2f6f4f59}.portalRoadmap{gap:8px;display:grid}.portalRoadmap span{border:1px solid var(--line);color:var(--muted);background:#ffffff7a;border-radius:14px;padding:10px 12px;font-weight:800}.vpnCard{border-bottom:1px solid var(--line);grid-template-columns:minmax(0,1.2fr) minmax(220px,.8fr) auto;padding:16px 0}.nodeCard{gap:16px;display:grid}.nodeList{gap:8px;margin-top:16px;display:grid}.nodeListGroup,.nodeListRow{border:1px solid var(--line);background:#ffffff6b;border-radius:16px}.nodeListGroup{color:var(--green);justify-content:space-between;align-items:center;gap:10px;padding:10px 12px;display:flex}.nodeListRow{grid-template-columns:minmax(220px,1.1fr) auto minmax(220px,.9fr) minmax(150px,.7fr) auto minmax(180px,.8fr) minmax(130px,.7fr) auto auto;align-items:center;gap:10px;padding:10px;display:grid}.nodeListMain{gap:3px;min-width:0;display:grid}.nodeListMain span{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:.82rem;overflow:hidden}.runtimeBadges{flex-wrap:wrap;gap:5px;min-width:0;display:flex}.runtimeBadges .pill{padding:.24rem .52rem;font-size:.68rem}.nodeEndpointCell{gap:5px;min-width:0;display:grid}.nodeEndpointCell .pill{width:fit-content;max-width:100%;padding:.28rem .58rem;font-size:.72rem}.nodeEndpointCell strong,.nodeEndpointCell small{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.nodeEndpointCell small{color:var(--muted);font-size:.74rem}.functionList{gap:8px;margin:12px 0;display:grid}.functionRow{border:1px solid var(--line);background:#ffffff6b;border-radius:16px;grid-template-columns:minmax(180px,1fr) auto auto auto auto minmax(220px,auto);align-items:center;gap:10px;padding:10px;display:grid}.functionState{min-width:96px;color:var(--ink);background:#102b230f;border-radius:14px;gap:2px;padding:8px 10px;display:grid}.functionState small{color:var(--muted);font-size:.68rem;font-weight:800}.functionState strong{font-size:.76rem}.functionState.good{color:var(--green);background:#24765424}.functionState.info{color:var(--blue);background:#29506f1f}.functionState.warn{color:#8a4f19;background:#af6e2e26}.nodePanel{border:1px solid var(--line);background:#ffffff6b;border-radius:18px;gap:12px;padding:14px;display:grid}.nodeDetails{gap:14px;display:grid}.nodeDetailGrid{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.nodeMetricGrid{grid-template-columns:repeat(4,minmax(0,1fr))}.summaryChips{flex-wrap:wrap;gap:8px;min-width:0;display:flex}.inlineActions{flex-wrap:wrap;align-items:center;gap:6px;display:inline-flex}.stackedText{flex-direction:column;gap:2px;min-width:0;display:flex}.nodeTabs{z-index:2;background:#f9f7eff0;flex-wrap:wrap;gap:8px;padding:8px 0;display:flex;position:sticky;top:0}.nodeTabs button{min-width:92px;padding:.55rem .8rem}.nodeTabs button.active{color:#fffaf0;background:#36556c;border-color:#0000}.rawDetailsGrid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.rawBlock{border:1px solid var(--line);background:#ffffff6b;border-radius:12px;min-width:0}.rawBlock summary{cursor:pointer;padding:10px 12px;font-weight:700}.rawBlock pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;margin:0;padding:0 12px 12px;font-size:.78rem;line-height:1.45;overflow:auto}.segmented{flex-wrap:wrap;gap:8px;display:flex}.segmented button{min-width:84px;padding:.52rem .85rem}.segmented button.active{color:#fffaf0;background:#36556c;border-color:#0000}.stateList{gap:10px;display:grid}.stateLine{grid-template-columns:130px minmax(0,1fr);align-items:baseline;gap:10px;display:grid}.stateLine strong{overflow-wrap:anywhere;min-width:0}.signalStrip{grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;display:grid}.signalStrip.compact{grid-template-columns:repeat(4,minmax(90px,1fr))}.signal{border:1px solid var(--line);background:#ffffff7a;border-radius:18px;padding:14px}.signal strong{margin-top:8px;font-size:1.6rem;display:block}.signalStrip.compact .signal strong{font-size:1rem}.clusterCatalog{gap:14px;display:grid}.clusterCard{border:1px solid var(--line);background:#ffffff6b;border-radius:22px;gap:14px;padding:16px;display:grid}.clusterCard.selected{background:#2f6f4f14;border-color:#2f6f4f73}.clusterCardMain{grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:16px;display:grid}.clusterCardMain h4{margin-bottom:6px;font-size:1.3rem}.clusterCardActions{flex-wrap:wrap;justify-content:flex-end;gap:8px;display:flex}.pill{width:fit-content;color:var(--steel);background:#36556c1a;border-radius:999px;align-items:center;padding:.34rem .72rem;font-size:.78rem;font-weight:900;display:inline-flex}.pill.good{color:var(--green);background:#2f6f4f1f}.pill.info{color:var(--steel);background:#36556c1a}.pill.bad{color:var(--red);background:#b144341f}.pill.warn{color:var(--amber);background:#b86f2324}.membershipList{flex-wrap:wrap;gap:6px;display:flex}.treeList{gap:12px;margin-top:16px;display:grid}.treeBranch{background:#ffffff52;border-left:3px solid #2f6f4f47;border-radius:0 16px 16px 0;gap:8px;padding:12px;display:grid}.treeBranch h4{flex-wrap:wrap;align-items:center;gap:8px;margin:0;display:flex}.treeNodeList{flex-wrap:wrap;gap:6px;display:flex}.telemetryBox{gap:12px;display:grid}.sparkline{border:1px solid var(--line);background:#1820180f;border-radius:16px;align-items:end;gap:3px;height:92px;padding:10px;display:flex}.sparkline span{background:linear-gradient(#4f8565,#203d2f);border-radius:999px 999px 0 0;flex:1;min-width:3px}.topologyShell{gap:14px;display:grid}.topologySvg{border:1px solid var(--line);color:#1820186b;background:radial-gradient(circle,#2f6f4f1f,#0000 18rem),linear-gradient(135deg,#ffffff8f,#fffaf052);border-radius:22px;width:100%;min-height:520px;max-height:78vh}.topologyRing{fill:none;stroke:#2f6f4f24;stroke-width:2px;stroke-dasharray:12 10}.topologyZone{fill:#fffcef70;stroke:#18201814;stroke-width:2px}.topologyZone.ingress{fill:#437a921c}.topologyZone.core{fill:#2f6f4f17}.topologyZone.egress{fill:#b07a321c}.topologyLayerLabel{fill:var(--green);letter-spacing:.08em;text-anchor:middle;text-transform:uppercase;font-size:22px;font-weight:950}.topologyLink{stroke-width:4px;stroke-linecap:round;opacity:.84}.topologyLink.good{color:var(--green);stroke:var(--green)}.topologyLink.weak{color:var(--amber);stroke:var(--amber)}.topologyLink.oneWay{color:var(--amber);stroke:var(--amber);stroke-dasharray:4 7}.topologyLink.bad{color:var(--red);stroke:var(--red);stroke-dasharray:10 8}.topologyLink.stale{color:var(--red);stroke:var(--red);stroke-dasharray:2 8;opacity:.42}.topologyPlacementLink{stroke:var(--steel);stroke-width:3px;stroke-linecap:round;stroke-dasharray:7 8;opacity:.58}.topologyPlacementLink.good{color:var(--green);stroke:var(--green)}.topologyPlacementLink.weak{color:var(--amber);stroke:var(--amber)}.topologyConfiguredLink{color:var(--steel);stroke:var(--steel);stroke-width:2.5px;stroke-linecap:round;stroke-dasharray:4 7;opacity:.72}.topologyLinkLabel,.topologyNodeName,.topologyNodeMeta,.topologyEndpointName,.topologyEndpointMeta,.topologyEmpty{text-anchor:middle;paint-order:stroke;stroke:#fffcefeb;stroke-width:5px;stroke-linejoin:round;font-weight:900}.topologyLinkLabel{fill:var(--muted);font-size:22px}.topologyEndpointRect{fill:#fffceff0;stroke:var(--steel);stroke-width:3px;filter:drop-shadow(0 12px 18px #1820181f)}.topologyEndpointRect.active{stroke:var(--green)}.topologyEndpointRect.disabled{stroke:var(--red)}.topologyEndpointRect.maintenance{stroke:var(--amber)}.topologyEndpointName{fill:var(--ink);font-size:18px}.topologyEndpointMeta{fill:var(--muted);font-size:15px}.topologyNodeCircle{fill:#fffceff0;stroke:var(--steel);stroke-width:4px;filter:drop-shadow(0 14px 22px #18201824)}.topologyNodeCircle.healthy{stroke:var(--green)}.topologyNodeCircle.critical,.topologyNodeCircle.offline,.topologyNodeCircle.failed{stroke:var(--red)}.topologyNodeName{fill:var(--ink);font-size:23px}.topologyNodeMeta{fill:var(--muted);font-size:18px}.topologyEmpty{fill:var(--muted);font-size:24px}.topologyLegend{color:var(--muted);flex-wrap:wrap;align-items:center;gap:12px;font-size:.88rem;display:flex}.topologyLegend span{align-items:center;gap:8px;display:inline-flex}.legendLine{border-top:3px solid var(--steel);width:36px;height:0;display:inline-block}.legendLine.placement{border-top-style:dashed;border-color:var(--green)}.legendLine.observed{border-color:var(--amber)}.legendLine.oneWay{border-top-style:dashed;border-color:var(--amber)}.legendLine.stale,.legendLine.problem{border-top-style:dashed;border-color:var(--red)}.legendLine.configured{border-top-style:dashed;border-color:var(--steel)}.serviceTags{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;display:grid}.serviceTag{border:1px solid var(--line);background:#ffffff7a;border-radius:16px;gap:5px;padding:12px;display:grid}.serviceTag span,.serviceTag small{color:var(--muted)}.serviceTag .pill{color:var(--steel);font-size:.7rem}.serviceTag .pill.good{color:var(--green)}.serviceTag .pill.bad{color:var(--red)}.actions{flex-wrap:wrap;gap:10px;display:flex}.status{color:#fff;background:var(--steel);border-radius:999px;width:fit-content;margin-right:6px;padding:.32rem .7rem;font-size:.78rem;font-weight:900;display:inline-flex}.status.pending,.status.enabled,.status.connecting{background:var(--amber)}.status.active,.status.approved,.status.healthy,.status.connected,.status.current,.status.running,.status.authoritative{background:var(--green)}.status.outdated,.status.no_policy{background:var(--amber)}.status.rejected,.status.failed,.status.critical,.status.missing,.status.offline,.status.stale,.status.unreachable,.status.disabled,.status.revoked{background:var(--red)}.tableWrap{overflow-x:auto}table{border-collapse:collapse;width:100%}th,td{border-bottom:1px solid var(--line);text-align:left;vertical-align:top;padding:.86rem .66rem}th{color:var(--muted);letter-spacing:.1em;text-transform:uppercase;font-size:.74rem}.errorPanel,.noticePanel{margin-top:18px;padding:16px}.errorPanel{color:#fff;background:var(--red)}.noticePanel{color:#fff;background:var(--green)}.noticePanel.goodPanel{background:var(--green)}.noticePanel.warnPanel{background:var(--amber)}.noticePanel.badPanel{background:var(--red)}.noticePanel .muted,.noticePanel .stateLine span{color:#ffffffc7}.formGrid{grid-template-columns:repeat(2,minmax(180px,1fr));gap:12px;margin-bottom:14px;display:grid}.wideLabel{margin-bottom:14px}.checkGrid{grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px;display:grid}.codePreview{border:1px solid var(--line);max-height:320px;color:var(--ink);white-space:pre-wrap;background:#1820180f;border-radius:16px;margin:10px 0 0;padding:14px;overflow:auto}.modalBackdrop{z-index:30;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);background:#1820185c;place-items:center;padding:28px;display:grid;position:fixed;inset:0}.modalCard{border:1px solid var(--line);background:var(--paper-strong);width:min(760px,100%);max-height:min(760px,100vh - 56px);box-shadow:var(--shadow);border-radius:28px;gap:18px;padding:24px;display:grid;overflow:auto}.modalCard.wide{width:min(1040px,100%)}.inlineForm{flex-wrap:wrap;gap:12px;margin:18px 0;display:flex}.inlineForm input,.inlineForm select{flex:320px}.diagnosticCommandPanel{border:1px solid var(--line);background:#ffffff6b;border-radius:18px;gap:12px;margin:12px 0 18px;padding:14px;display:grid}.diagnosticCommandPanel label{gap:6px;display:grid}.diagnosticCommandPanel input{min-width:min(520px,100%)}.secretOnce{background:#b86f231a;border:1px solid #b86f2361;border-radius:18px;gap:8px;margin-top:16px;padding:14px;display:grid}code{overflow-wrap:anywhere;font-family:Cascadia Code,Consolas,monospace}.workloadBlock{border-bottom:1px solid var(--line);gap:10px;padding-bottom:16px;display:grid}@media (width<=1100px){.consoleShell,.portalShell{grid-template-columns:1fr}.sideRail,.portalRail{height:auto;position:relative}.workspace,.portalWorkspace{width:100%}.grid.five,.grid.three,.grid.two,.controlBar,.topBar,.portalTop,.loginShell,.nodeDetailGrid,.rawDetailsGrid,.vpnCard{grid-template-columns:1fr}.loginHero{min-height:auto}.span2,.span3{grid-column:auto}}@media (width<=720px){body{background:#f4f1e7}button,input,select,textarea{font-size:16px}button{min-height:46px}.workspace,.portalWorkspace,.sideRail,.portalRail{padding:18px}.loginShell{align-items:start;padding:16px}.loginCard{border-radius:18px;padding:18px}.portalRail{height:auto;min-height:0}.portalRail .brandMark,.portalRail .sideKicker,.portalRail .sideText{display:none}.portalRail h1{letter-spacing:0;margin:0;font-size:2rem}.portalTop,.card,.metric,.empty,.errorPanel,.noticePanel{border-radius:18px}.portalTop{padding:18px}.portalTop h2{letter-spacing:0;font-size:2rem}.portalInstallList,.stack,.grid{gap:12px}.installTile{border-radius:14px}.metric{min-height:96px}.metric strong{font-size:2.2rem}.formGrid,.signalStrip,.clusterCardMain,.cardHead,.roleRow,.requestCard{grid-template-columns:1fr}} diff --git a/web-admin/deploy/html/assets/index-Cur_BAkX.css b/web-admin/deploy/html/assets/index-Cur_BAkX.css new file mode 100644 index 0000000..3cf7b3f --- /dev/null +++ b/web-admin/deploy/html/assets/index-Cur_BAkX.css @@ -0,0 +1 @@ +:root{color:#182018;--ink:#182018;--muted:#667064;--paper:#fffcefe6;--paper-strong:#fffaf0;--line:#1a271b26;--green:#2f6f4f;--amber:#b86f23;--red:#a64235;--steel:#36556c;--blue:#284b8f;--shadow:0 24px 60px #2a32281c;background:#ecede4;font-family:IBM Plex Sans,Aptos,Segoe UI,sans-serif}*{box-sizing:border-box}body{background:radial-gradient(circle at 8% 8%,#b86f2333,#0000 27rem),radial-gradient(circle at 92% 16%,#2f6f4f2b,#0000 28rem),linear-gradient(135deg,#f4f1e7 0%,#dfe5db 58%,#f4f1e7 100%);min-width:320px;min-height:100vh;margin:0}button,input,select,textarea{font:inherit}button{border:1px solid var(--line);color:var(--ink);cursor:pointer;background:#fffaf0;border-radius:999px;padding:.66rem 1rem}button:hover:not(:disabled){transform:translateY(-1px)}button:disabled{cursor:not-allowed;opacity:.52}button.primary{color:#fffaf0;background:linear-gradient(135deg,#203d2f,#4f8565);border-color:#0000;box-shadow:0 14px 32px #244c383d}button.ghost{background:#fffaf085}button.wide{justify-content:center;width:100%}button.danger{color:#fffaf0;background:linear-gradient(135deg,#8f372d,#bd5d4d)}.loginShell{grid-template-columns:minmax(300px,420px);justify-content:center;align-items:center;min-height:100vh;padding:clamp(24px,5vw,72px);display:grid}.loginHero{color:#fffaf0;min-height:560px;box-shadow:var(--shadow);background:linear-gradient(155deg,#101b13fa,#1a382af2),radial-gradient(circle at 20% 20%,#b86f2357,#0000 18rem);border-radius:38px;padding:clamp(28px,5vw,64px)}.loginHero h1{letter-spacing:-.075em;max-width:780px;margin:0 0 20px;font-size:clamp(3rem,8vw,7rem);line-height:.88}.loginHero p{color:#fffaf0c2;max-width:720px;font-size:1.08rem}.loginCard{border:1px solid var(--line);box-shadow:var(--shadow);-webkit-backdrop-filter:blur(14px);backdrop-filter:blur(14px);background:#fffcefd6;border-radius:30px;gap:16px;padding:28px;display:grid}.loginCard h1{letter-spacing:-.035em;margin:0 0 2px;font-size:1.55rem}.loginHint{color:var(--muted);margin:0;line-height:1.45}.checkLine{color:var(--ink);grid-template-columns:auto minmax(0,1fr);align-items:center}.checkLine input{width:18px;height:18px}.consoleShell{grid-template-columns:310px minmax(0,1fr);min-height:100vh;display:grid}.portalShell{grid-template-columns:280px minmax(0,1fr);min-height:100vh;display:grid}.sideRail,.portalRail{color:#fffaf0;background:linear-gradient(160deg,#101b13fa,#1a382af5),radial-gradient(circle at 20% 20%,#b86f235c,#0000 16rem);height:100vh;padding:28px;position:sticky;top:0}.portalRail button{width:100%;margin-top:16px}.brandMark{letter-spacing:.08em;background:#fffaf01a;border:1px solid #fffaf038;border-radius:18px;place-items:center;width:58px;height:58px;margin-bottom:28px;font-weight:900;display:inline-grid}.sideKicker,.eyebrow{color:#d59b54;letter-spacing:.16em;text-transform:uppercase;margin:0 0 8px;font-size:.78rem;font-weight:900}.sideRail h1,.portalRail h1{letter-spacing:-.06em;margin:0 0 12px;font-size:clamp(2.3rem,4vw,4.2rem);line-height:.9}.sideText{color:#fffaf0b8;margin:0 0 26px}.railNav{gap:8px;display:grid}.railNav button{color:#fffaf0c2;text-align:left;background:0 0;border-color:#fffaf021;justify-content:flex-start}.railNav button.active{color:#182018;background:#fffaf0}.workspace{width:calc(100vw - 310px);padding:28px 28px 54px}.portalWorkspace{width:min(1280px,100vw - 310px);padding:28px 28px 54px}.topBar,.portalTop{border:1px solid var(--line);box-shadow:var(--shadow);-webkit-backdrop-filter:blur(16px);backdrop-filter:blur(16px);background:#fffcefb8;border-radius:30px;grid-template-columns:minmax(0,1fr) minmax(260px,360px) auto minmax(150px,auto) minmax(220px,auto);align-items:center;gap:20px;padding:28px;display:grid}.portalTop{grid-template-columns:minmax(0,1fr) minmax(240px,340px) auto}.clusterPicker{border:1px solid var(--line);background:#ffffff80;border-radius:22px;gap:8px;min-width:0;padding:14px;display:grid}.clusterPicker span{color:var(--muted);overflow-wrap:anywhere;font-size:.82rem;font-weight:800}.refreshStatus{min-width:150px;color:var(--muted);gap:4px;font-size:.78rem;font-weight:800;line-height:1.35;display:grid}.refreshStatus strong{color:var(--text);font-size:.82rem}.refreshStatus span{overflow-wrap:anywhere}.profilePanel{border:1px solid var(--line);background:#ffffff7a;border-radius:22px;grid-template-columns:1fr;gap:8px;min-width:240px;padding:14px;display:grid}.profilePanel span{color:var(--muted);overflow-wrap:anywhere}h2,h3,h4,p{margin-top:0}h2{letter-spacing:-.055em;margin-bottom:8px;font-size:clamp(2rem,4vw,4rem);line-height:.94}h3{margin-bottom:10px;font-size:1.35rem}.muted{color:var(--muted)}.controlBar,.grid,.stack{margin-top:18px}.controlBar{border:1px solid var(--line);background:#fffcef9e;border-radius:22px;grid-template-columns:minmax(220px,.45fr);gap:14px;padding:16px;display:grid}label{color:var(--muted);gap:7px;font-size:.82rem;font-weight:800;display:grid}label small{color:var(--muted);font-weight:600;line-height:1.35}input,select,textarea{border:1px solid var(--line);min-width:0;color:var(--ink);background:#ffffffc7;border-radius:14px;padding:.75rem .85rem}textarea{resize:vertical;min-height:84px;font-family:Cascadia Code,Consolas,monospace;font-size:.86rem}details{gap:12px;margin:14px 0;display:grid}summary{cursor:pointer;color:var(--steel);font-weight:800}.grid{gap:16px;display:grid}.grid.five{grid-template-columns:repeat(5,minmax(140px,1fr))}.grid.three{grid-template-columns:repeat(3,minmax(170px,1fr))}.grid.two{grid-template-columns:repeat(2,minmax(280px,1fr))}.fabricTransportView{gap:16px;margin-top:18px;display:grid}.fabricMapCard{min-height:calc(100vh - 176px)}.fabricMapCard .cardHead{align-items:center}.fabricMapCard .topologyShell{min-height:calc(100vh - 310px)}.fabricMapCard .topologySvg{min-height:calc(100vh - 360px);max-height:none}.fabricDiagnostics{margin-top:10px}.span2{grid-column:span 2}.span3{grid-column:span 3}.stack{gap:14px;display:grid}.card,.metric,.empty,.errorPanel,.noticePanel{border:1px solid var(--line);background:var(--paper);box-shadow:var(--shadow);border-radius:24px}.card,.empty{padding:22px}.metric{min-height:128px;padding:20px}.metric span,.signal span,.stateLine span{color:var(--muted);font-weight:800;display:block}.metric span{margin-bottom:16px}.metric strong{font-size:3.1rem;line-height:1}.green strong{color:var(--green)}.amber strong{color:var(--amber)}.red strong{color:var(--red)}.steel strong{color:var(--steel)}.cardHead,.roleRow,.requestCard,.vpnCard{grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:16px;display:grid}.cardHead.compact{gap:10px}.subPanel{border:1px solid var(--line);background:#ffffff73;border-radius:8px;gap:14px;padding:16px;display:grid}.portalInstallList{gap:10px;margin-top:14px;display:grid}.installTile{border:1px solid var(--line);color:var(--ink);background:#ffffff85;border-radius:18px;gap:6px;padding:16px;text-decoration:none;display:grid}.installTile:hover{transform:translateY(-1px)}.installTile strong{font-size:1.05rem}.installTile span,.installTile small{color:var(--muted);overflow-wrap:anywhere}.primaryInstall{background:#2f6f4f1a;border-color:#2f6f4f59}.portalRoadmap{gap:8px;display:grid}.portalRoadmap span{border:1px solid var(--line);color:var(--muted);background:#ffffff7a;border-radius:14px;padding:10px 12px;font-weight:800}.vpnCard{border-bottom:1px solid var(--line);grid-template-columns:minmax(0,1.2fr) minmax(220px,.8fr) auto;padding:16px 0}.nodeCard{gap:16px;display:grid}.nodeList{gap:8px;margin-top:16px;display:grid}.nodeListGroup,.nodeListRow{border:1px solid var(--line);background:#ffffff6b;border-radius:16px}.nodeListGroup{color:var(--green);justify-content:space-between;align-items:center;gap:10px;padding:10px 12px;display:flex}.nodeListRow{grid-template-columns:minmax(220px,1.1fr) auto minmax(220px,.9fr) minmax(150px,.7fr) auto minmax(180px,.8fr) minmax(130px,.7fr) auto auto;align-items:center;gap:10px;padding:10px;display:grid}.nodeListMain{gap:3px;min-width:0;display:grid}.nodeListMain span{color:var(--muted);text-overflow:ellipsis;white-space:nowrap;font-size:.82rem;overflow:hidden}.runtimeBadges{flex-wrap:wrap;gap:5px;min-width:0;display:flex}.runtimeBadges .pill{padding:.24rem .52rem;font-size:.68rem}.nodeEndpointCell{gap:5px;min-width:0;display:grid}.nodeEndpointCell .pill{width:fit-content;max-width:100%;padding:.28rem .58rem;font-size:.72rem}.nodeEndpointCell strong,.nodeEndpointCell small{text-overflow:ellipsis;white-space:nowrap;overflow:hidden}.nodeEndpointCell small{color:var(--muted);font-size:.74rem}.functionList{gap:8px;margin:12px 0;display:grid}.functionRow{border:1px solid var(--line);background:#ffffff6b;border-radius:16px;grid-template-columns:minmax(180px,1fr) auto auto auto auto minmax(220px,auto);align-items:center;gap:10px;padding:10px;display:grid}.functionState{min-width:96px;color:var(--ink);background:#102b230f;border-radius:14px;gap:2px;padding:8px 10px;display:grid}.functionState small{color:var(--muted);font-size:.68rem;font-weight:800}.functionState strong{font-size:.76rem}.functionState.good{color:var(--green);background:#24765424}.functionState.info{color:var(--blue);background:#29506f1f}.functionState.warn{color:#8a4f19;background:#af6e2e26}.nodePanel{border:1px solid var(--line);background:#ffffff6b;border-radius:18px;gap:12px;padding:14px;display:grid}.nodeDetails{gap:14px;display:grid}.nodeDetailGrid{grid-template-columns:repeat(2,minmax(0,1fr));gap:14px;display:grid}.nodeMetricGrid{grid-template-columns:repeat(4,minmax(0,1fr))}.summaryChips{flex-wrap:wrap;gap:8px;min-width:0;display:flex}.inlineActions{flex-wrap:wrap;align-items:center;gap:6px;display:inline-flex}.stackedText{flex-direction:column;gap:2px;min-width:0;display:flex}.nodeTabs{z-index:2;background:#f9f7eff0;flex-wrap:wrap;gap:8px;padding:8px 0;display:flex;position:sticky;top:0}.nodeTabs button{min-width:92px;padding:.55rem .8rem}.nodeTabs button.active{color:#fffaf0;background:#36556c;border-color:#0000}.rawDetailsGrid{grid-template-columns:repeat(2,minmax(0,1fr));gap:10px;display:grid}.rawBlock{border:1px solid var(--line);background:#ffffff6b;border-radius:12px;min-width:0}.rawBlock summary{cursor:pointer;padding:10px 12px;font-weight:700}.rawBlock pre{white-space:pre-wrap;overflow-wrap:anywhere;max-height:320px;margin:0;padding:0 12px 12px;font-size:.78rem;line-height:1.45;overflow:auto}.segmented{flex-wrap:wrap;gap:8px;display:flex}.segmented button{min-width:84px;padding:.52rem .85rem}.segmented button.active{color:#fffaf0;background:#36556c;border-color:#0000}.stateList{gap:10px;display:grid}.stateLine{grid-template-columns:130px minmax(0,1fr);align-items:baseline;gap:10px;display:grid}.stateLine strong{overflow-wrap:anywhere;min-width:0}.signalStrip{grid-template-columns:repeat(4,minmax(120px,1fr));gap:12px;display:grid}.signalStrip.compact{grid-template-columns:repeat(4,minmax(90px,1fr))}.signal{border:1px solid var(--line);background:#ffffff7a;border-radius:18px;padding:14px}.signal strong{margin-top:8px;font-size:1.6rem;display:block}.signalStrip.compact .signal strong{font-size:1rem}.clusterCatalog{gap:14px;display:grid}.clusterCard{border:1px solid var(--line);background:#ffffff6b;border-radius:22px;gap:14px;padding:16px;display:grid}.clusterCard.selected{background:#2f6f4f14;border-color:#2f6f4f73}.clusterCardMain{grid-template-columns:minmax(0,1fr) auto;align-items:start;gap:16px;display:grid}.clusterCardMain h4{margin-bottom:6px;font-size:1.3rem}.clusterCardActions{flex-wrap:wrap;justify-content:flex-end;gap:8px;display:flex}.pill{width:fit-content;color:var(--steel);background:#36556c1a;border-radius:999px;align-items:center;padding:.34rem .72rem;font-size:.78rem;font-weight:900;display:inline-flex}.pill.good{color:var(--green);background:#2f6f4f1f}.pill.info{color:var(--steel);background:#36556c1a}.pill.bad{color:var(--red);background:#b144341f}.pill.warn{color:var(--amber);background:#b86f2324}.membershipList{flex-wrap:wrap;gap:6px;display:flex}.treeList{gap:12px;margin-top:16px;display:grid}.treeBranch{background:#ffffff52;border-left:3px solid #2f6f4f47;border-radius:0 16px 16px 0;gap:8px;padding:12px;display:grid}.treeBranch h4{flex-wrap:wrap;align-items:center;gap:8px;margin:0;display:flex}.treeNodeList{flex-wrap:wrap;gap:6px;display:flex}.telemetryBox{gap:12px;display:grid}.sparkline{border:1px solid var(--line);background:#1820180f;border-radius:16px;align-items:end;gap:3px;height:92px;padding:10px;display:flex}.sparkline span{background:linear-gradient(#4f8565,#203d2f);border-radius:999px 999px 0 0;flex:1;min-width:3px}.topologyShell{gap:14px;display:grid}.topologySvg{border:1px solid var(--line);color:#1820186b;background:linear-gradient(#18201809 1px,#0000 1px) 0 0/34px 34px,linear-gradient(90deg,#18201809 1px,#0000 1px) 0 0/34px 34px,linear-gradient(135deg,#ffffff8f,#f8faf66b);border-radius:22px;width:100%;min-height:640px;max-height:78vh}.topologyLink{stroke-width:3px;stroke-linecap:round;opacity:.84;fill:none}.topologyLinkGroup{cursor:help}.topologyLinkGroup:hover .topologyLink{stroke-width:5px;opacity:1}.topologyLink.good{color:var(--green);stroke:var(--green)}.topologyLink.weak{color:var(--amber);stroke:var(--amber)}.topologyLink.oneWay{color:var(--amber);stroke:var(--amber);stroke-dasharray:4 7}.topologyLink.relay{stroke-dasharray:8 7}.topologyLink.route{color:var(--steel);stroke:var(--steel);stroke-dasharray:3 8;opacity:.7}.topologyLink.bad{color:var(--red);stroke:var(--red);stroke-dasharray:10 8}.topologyLink.stale{color:var(--red);stroke:var(--red);stroke-dasharray:2 8;opacity:.42}.topologyPlacementLink{stroke:var(--steel);stroke-width:3px;stroke-linecap:round;stroke-dasharray:7 8;opacity:.58}.topologyPlacementLink.good{color:var(--green);stroke:var(--green)}.topologyPlacementLink.weak{color:var(--amber);stroke:var(--amber)}.topologyConfiguredLink{color:var(--steel);stroke:var(--steel);stroke-width:2.5px;stroke-linecap:round;stroke-dasharray:4 7;opacity:.72}.topologyLinkLabel,.topologyNodeName,.topologyNodeMeta,.topologyEndpointName,.topologyEndpointMeta,.topologyEmpty{text-anchor:middle;paint-order:stroke;stroke:#fffcefeb;stroke-width:5px;stroke-linejoin:round;font-weight:900}.topologyLinkLabel{fill:var(--muted);font-size:16px}.topologyEndpointRect{fill:#fffceff0;stroke:var(--steel);stroke-width:3px;filter:drop-shadow(0 12px 18px #1820181f)}.topologyEndpointRect.active{stroke:var(--green)}.topologyEndpointRect.disabled{stroke:var(--red)}.topologyEndpointRect.maintenance{stroke:var(--amber)}.topologyEndpointName{fill:var(--ink);font-size:18px}.topologyEndpointMeta{fill:var(--muted);font-size:15px}.topologyNodeCircle{fill:#fffceff0;stroke:var(--steel);stroke-width:3px;filter:drop-shadow(0 14px 22px #18201824)}.topologyNode{cursor:help}.topologyNode:hover .topologyNodeCircle{stroke-width:5px;filter:drop-shadow(0 16px 26px #18201838)}.topologyNodeCircle.healthy{stroke:var(--green)}.topologyNodeCircle.active{stroke-width:5px}.topologyNodeCircle.passive{stroke:var(--amber);stroke-dasharray:10 7}.topologyNodeCircle.mixed{stroke:var(--steel);stroke-dasharray:16 6 4 6}.topologyNodeCircle.unknown{stroke-dasharray:4 8}.topologyNodeCircle.web-ready{fill:#e8f8effa}.topologyNodeCircle.web-degraded{fill:#fff6e0fa}.topologyNodeCircle.web-blocked{fill:#ffebe6fa}.topologyNodeCircle.critical,.topologyNodeCircle.offline,.topologyNodeCircle.failed{stroke:var(--red)}.topologyNodeName{fill:var(--ink);font-size:20px}.topologyNodeMeta{fill:var(--muted);font-size:13px}.topologyEmpty{fill:var(--muted);font-size:24px}.topologyTooltipObject{pointer-events:none}.topologyTooltip{max-width:360px;color:var(--ink);border:1px solid var(--line);background:#fffceff5;border-radius:8px;gap:4px;padding:12px 14px;font-size:13px;line-height:1.3;display:grid;box-shadow:0 18px 42px #1820182e}.topologyTooltip strong,.topologyTooltip span{text-overflow:ellipsis;white-space:nowrap;min-width:0;overflow:hidden}.topologyTooltip strong{font-size:14px}.topologyLegend{color:var(--muted);flex-wrap:wrap;align-items:center;gap:12px;font-size:.88rem;display:flex}.topologyLegend span{align-items:center;gap:8px;display:inline-flex}.legendLine{border-top:3px solid var(--steel);width:36px;height:0;display:inline-block}.legendLine.placement{border-top-style:dashed;border-color:var(--green)}.legendLine.observed{border-color:var(--amber)}.legendLine.oneWay,.legendLine.relay{border-top-style:dashed;border-color:var(--amber)}.legendLine.route{border-top-style:dashed;border-color:var(--steel)}.legendLine.stale,.legendLine.problem{border-top-style:dashed;border-color:var(--red)}.legendLine.configured{border-top-style:dashed;border-color:var(--steel)}.legendDot{border:1px solid var(--line);border-radius:50%;width:14px;height:14px;display:inline-block}.legendDot.webReady{background:#e8f8effa}.legendDot.webDegraded{background:#fff6e0fa}.legendDot.webBlocked{background:#ffebe6fa}.serviceTags{grid-template-columns:repeat(auto-fit,minmax(220px,1fr));gap:10px;display:grid}.sectionBlock{gap:12px;margin-top:18px;display:grid}.sectionBlock h4{margin:0}.serviceTag{border:1px solid var(--line);background:#ffffff7a;border-radius:16px;gap:5px;padding:12px;display:grid}.serviceTag span,.serviceTag small{color:var(--muted)}.serviceTag .pill{color:var(--steel);font-size:.7rem}.serviceTag .pill.good{color:var(--green)}.serviceTag .pill.bad{color:var(--red)}.actions{flex-wrap:wrap;gap:10px;display:flex}.status{color:#fff;background:var(--steel);border-radius:999px;width:fit-content;margin-right:6px;padding:.32rem .7rem;font-size:.78rem;font-weight:900;display:inline-flex}.status.pending,.status.enabled,.status.connecting{background:var(--amber)}.status.active,.status.approved,.status.healthy,.status.connected,.status.current,.status.running,.status.authoritative{background:var(--green)}.status.outdated,.status.no_policy{background:var(--amber)}.status.rejected,.status.failed,.status.critical,.status.missing,.status.offline,.status.stale,.status.unreachable,.status.disabled,.status.revoked{background:var(--red)}.tableWrap{overflow-x:auto}table{border-collapse:collapse;width:100%}th,td{border-bottom:1px solid var(--line);text-align:left;vertical-align:top;padding:.86rem .66rem}th{color:var(--muted);letter-spacing:.1em;text-transform:uppercase;font-size:.74rem}.errorPanel,.noticePanel{margin-top:18px;padding:16px}.errorPanel{color:#fff;background:var(--red)}.noticePanel{color:#fff;background:var(--green)}.noticePanel.goodPanel{background:var(--green)}.noticePanel.warnPanel{background:var(--amber)}.noticePanel.badPanel{background:var(--red)}.noticePanel .muted,.noticePanel .stateLine span{color:#ffffffc7}.formGrid{grid-template-columns:repeat(2,minmax(180px,1fr));gap:12px;margin-bottom:14px;display:grid}.wideLabel{margin-bottom:14px}.checkGrid{grid-template-columns:repeat(auto-fit,minmax(210px,1fr));gap:10px;display:grid}.codePreview{border:1px solid var(--line);max-height:320px;color:var(--ink);white-space:pre-wrap;background:#1820180f;border-radius:16px;margin:10px 0 0;padding:14px;overflow:auto}.modalBackdrop{z-index:30;-webkit-backdrop-filter:blur(6px);backdrop-filter:blur(6px);background:#1820185c;place-items:center;padding:28px;display:grid;position:fixed;inset:0}.modalCard{border:1px solid var(--line);background:var(--paper-strong);width:min(760px,100%);max-height:min(760px,100vh - 56px);box-shadow:var(--shadow);border-radius:28px;gap:18px;padding:24px;display:grid;overflow:auto}.modalCard.wide{width:min(1040px,100%)}.inlineForm{flex-wrap:wrap;gap:12px;margin:18px 0;display:flex}.inlineForm input,.inlineForm select{flex:320px}.diagnosticCommandPanel{border:1px solid var(--line);background:#ffffff6b;border-radius:18px;gap:12px;margin:12px 0 18px;padding:14px;display:grid}.diagnosticCommandPanel label{gap:6px;display:grid}.diagnosticCommandPanel input{min-width:min(520px,100%)}.secretOnce{background:#b86f231a;border:1px solid #b86f2361;border-radius:18px;gap:8px;margin-top:16px;padding:14px;display:grid}code{overflow-wrap:anywhere;font-family:Cascadia Code,Consolas,monospace}.workloadBlock{border-bottom:1px solid var(--line);gap:10px;padding-bottom:16px;display:grid}@media (width<=1100px){.consoleShell,.portalShell{grid-template-columns:1fr}.sideRail,.portalRail{height:auto;position:relative}.workspace,.portalWorkspace{width:100%}.grid.five,.grid.three,.grid.two,.controlBar,.topBar,.portalTop,.loginShell,.nodeDetailGrid,.rawDetailsGrid,.vpnCard{grid-template-columns:1fr}.loginHero{min-height:auto}.span2,.span3{grid-column:auto}}@media (width<=720px){body{background:#f4f1e7}button,input,select,textarea{font-size:16px}button{min-height:46px}.workspace,.portalWorkspace,.sideRail,.portalRail{padding:18px}.loginShell{align-items:start;padding:16px}.loginCard{border-radius:18px;padding:18px}.portalRail{height:auto;min-height:0}.portalRail .brandMark,.portalRail .sideKicker,.portalRail .sideText{display:none}.portalRail h1{letter-spacing:0;margin:0;font-size:2rem}.portalTop,.card,.metric,.empty,.errorPanel,.noticePanel{border-radius:18px}.portalTop{padding:18px}.portalTop h2{letter-spacing:0;font-size:2rem}.portalInstallList,.stack,.grid{gap:12px}.installTile{border-radius:14px}.metric{min-height:96px}.metric strong{font-size:2.2rem}.formGrid,.signalStrip,.clusterCardMain,.cardHead,.roleRow,.requestCard{grid-template-columns:1fr}} diff --git a/web-admin/deploy/html/assets/index-DU4b34gj.js b/web-admin/deploy/html/assets/index-DU4b34gj.js deleted file mode 100644 index 2102cf5..0000000 --- a/web-admin/deploy/html/assets/index-DU4b34gj.js +++ /dev/null @@ -1,23 +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,M=t.unstable_now,Me=t.unstable_getCurrentPriorityLevel,Ne=t.unstable_ImmediatePriority,Pe=t.unstable_UserBlockingPriority,Fe=t.unstable_NormalPriority,Ie=t.unstable_LowPriority,Le=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 $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),fn=!1;if(dn)try{var pn={};Object.defineProperty(pn,`passive`,{get:function(){fn=!0}}),window.addEventListener(`test`,pn,pn),window.removeEventListener(`test`,pn,pn)}catch{fn=!1}var L=null,mn=null,hn=null;function gn(){if(hn)return hn;var e,t=mn,n=t.length,r,i=`value`in L?L.value:L.textContent,a=i.length;for(e=0;e=Wn),qn=` `,Jn=!1;function Yn(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 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?(Jn=!0,qn):null;case`textInput`:return e=t.data,e===qn&&Jn?null:e;default:return null}}function $n(e,t){if(Zn)return e===`compositionend`||!Un&&Yn(e,t)?(e=gn(),hn=mn=L=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=xr(n)}}function Cr(e,t){return e&&t?e===t?!0:e&&e.nodeType===3?!1:t&&t.nodeType===3?Cr(e,t.parentNode):`contains`in e?e.contains(t):e.compareDocumentPosition?!!(e.compareDocumentPosition(t)&16):!1:!1}function wr(e){e=e!=null&&e.ownerDocument!=null&&e.ownerDocument.defaultView!=null?e.ownerDocument.defaultView:window;for(var t=Bt(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=Bt(e.document)}return t}function Tr(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 Er=dn&&`documentMode`in document&&11>=document.documentMode,Dr=null,Or=null,kr=null,Ar=!1;function jr(e,t,n){var r=n.window===n?n.document:n.nodeType===9?n:n.ownerDocument;Ar||Dr==null||Dr!==Bt(r)||(r=Dr,`selectionStart`in r&&Tr(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}),kr&&br(kr,r)||(kr=r,r=Td(Or,`onSelect`),0>=o,i-=o,xi=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),W&&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),W&&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 W&&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)}),W&&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,Yi(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=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 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=ei(e),$r(e,null,n),t}return Xr(e,r,t,n),ei(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,at(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 Y(){if(Ra){var e=aa;if(e!==null)throw e}}function za(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===ia&&(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 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 Ji(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:ta()},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,yr(s,o))return Xr(e,t,i,0),Fl===null&&U(),!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:Ji,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:Ji,use:Eo,useCallback:function(e,t){return So().memoizedState=[e,t===void 0?null:t],e},useContext:Ji,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(W){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(W){var n=Si,r=xi;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=he.current,Ii(t)){if(e=t.stateNode,n=t.memoizedProps,r=null,a=Oi,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 G(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;jc(t),e=!1}else n=Li(),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 G(),!(t.flags&128)&&(t.memoizedState=null),t.flags|=4;jc(t),a=!1}else a=Li(),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 Ui(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),W&&Ci(t,r.treeForkCount),t.child}e=e.sibling}r.tail!==null&&M()>$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&&!W)return jc(t),null}else 2*M()-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=M(),e.sibling=null,n=eo.current,pe(eo,a?n&1|2:n&1),W&&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),Ui(ea),jc(t),null;case 25:return null;case 30:return null}throw Error(i(156,t.tag))}function Nc(e,t){switch(Ei(t),t.tag){case 1:return e=t.flags,e&65536?(t.flags=e&-65537|128,t):null;case 3:return Ui(ea),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));G()}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));G()}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 Ui(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 Ui(ea),null;case 25:return null;default:return null}}function Pc(e,t){switch(Ei(t),t.tag){case 3:Ui(ea),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:Ui(t.type);break;case 22:case 23:$a(t),Ka(),e!==null&&k(la);break;case 24:Ui(ea)}}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[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=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[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=wr(e),Tr(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,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 _=Sr(s,h),v=Sr(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),Ve&&typeof Ve.onPostCommitFiberRoot==`function`)try{Ve.onPostCommitFiberRoot(Be,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=Fa(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=Fa(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>M()-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-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=M(),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=Ht(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="`+Ht(t)+`"]`;t===`image`&&n&&n.imageSrcSet?(i+=`[imagesrcset="`+Ht(n.imageSrcSet)+`"]`,typeof n.imageSizes==`string`&&(i+=`[imagesizes="`+Ht(n.imageSizes)+`"]`)):i+=`[href="`+Ht(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="`+Ht(r)+`"][href="`+Ht(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=St(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=St(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=St(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=St(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=St(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=St(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="`+Ht(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="`+Ht(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~="`+Ht(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 listFabricEntryPoints(e){return(await this.get(`/clusters/${e}/fabric/entry-points`)).entry_points??[]}async createFabricEntryPoint(e,t){return(await this.post(`/clusters/${e}/fabric/entry-points`,{actor_user_id:this.actorUserId,name:t.name,status:`active`,endpoint_type:t.endpointType||`client_access`,public_endpoint:t.publicEndpoint||null,policy:t.policy||{},metadata:t.metadata||{}})).entry_point}async listFabricEntryPointNodes(e,t){return(await this.get(`/clusters/${e}/fabric/entry-points/${t}/nodes`)).entry_point_nodes??[]}async setFabricEntryPointNode(e,t,n,r={}){return(await this.put(`/clusters/${e}/fabric/entry-points/${t}/nodes/${n}`,{actor_user_id:this.actorUserId,status:r.status||`active`,priority:r.priority||100,metadata:r.metadata||{}})).entry_point_node}async listFabricEgressPools(e){return(await this.get(`/clusters/${e}/fabric/egress-pools`)).egress_pools??[]}async createFabricEgressPool(e,t){return(await this.post(`/clusters/${e}/fabric/egress-pools`,{actor_user_id:this.actorUserId,name:t.name,status:`active`,description:t.description||null,route_scope:t.routeScope||{},policy:t.policy||{},metadata:t.metadata||{}})).egress_pool}async listFabricEgressPoolNodes(e,t){return(await this.get(`/clusters/${e}/fabric/egress-pools/${t}/nodes`)).egress_pool_nodes??[]}async setFabricEgressPoolNode(e,t,n,r={}){return(await this.put(`/clusters/${e}/fabric/egress-pools/${t}/nodes/${n}`,{actor_user_id:this.actorUserId,status:r.status||`active`,priority:r.priority||100,metadata:r.metadata||{}})).egress_pool_node}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=[`entry-node`,`relay-node`,`core-mesh`,`rdp-worker`,`vnc-worker`,`vpn-exit`,`vpn-connector`,`file-storage-cache`,`update-cache`,`video-relay`],w={"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`,"file-storage-cache":`File/cache storage`,"update-cache":`Update cache`,"video-relay":`Video relay`},ae={"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`],"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`,fabricIngressLayer:`Входы`,fabricNodeLayer:`Узлы кластера`,fabricEgressLayer:`Выходные зоны`,observedPeerLinks:`Наблюдаемые связи`,placementIntent:`control-plane назначение`,fabricEntryPoints:`Точки входа`,fabricEntryPointHelp:`Логические внешние входы в кластер. Они скрывают конкретные узлы от организаций и клиентов.`,fabricEgressPools:`Выходные зоны`,fabricEgressPoolHelp:`Логические выходы к внешним сетям, например “Офис Москва”. Организации используют зону, а не конкретный узел.`,endpointName:`Название`,publicEndpoint:`Публичный адрес`,endpointType:`Тип входа`,description:`Описание`,routeScope:`Область маршрутов JSON`,createEntryPoint:`Создать точку входа`,createEgressPool:`Создать выходную зону`,endpointNodes:`Назначенные узлы`,assignEndpointNode:`Назначить узел`,selectNode:`Выберите узел`,assignedNodesEmpty:`Узлы пока не назначены`,entryPointsEmpty:`Точки входа пока не созданы.`,egressPoolsEmpty:`Выходные зоны пока не созданы.`,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`,fabricIngressLayer:`Ingress`,fabricNodeLayer:`Cluster nodes`,fabricEgressLayer:`Egress pools`,observedPeerLinks:`Observed links`,placementIntent:`control-plane placement`,fabricEntryPoints:`Entry points`,fabricEntryPointHelp:`Logical external ingress points for the cluster. They hide concrete nodes from organizations and clients.`,fabricEgressPools:`Egress pools`,fabricEgressPoolHelp:`Logical exits to external networks, for example “Office Moscow”. Organizations use the pool, not a concrete node.`,endpointName:`Name`,publicEndpoint:`Public endpoint`,endpointType:`Entry type`,description:`Description`,routeScope:`Route scope JSON`,createEntryPoint:`Create entry point`,createEgressPool:`Create egress pool`,endpointNodes:`Assigned nodes`,assignEndpointNode:`Assign node`,selectNode:`Select node`,assignedNodesEmpty:`No nodes assigned yet`,entryPointsEmpty:`No entry points created yet.`,egressPoolsEmpty:`No egress pools created 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)({}),[Fe,ze]=(0,_.useState)([]),[Be,Ve]=(0,_.useState)([]),[Ue,Xe]=(0,_.useState)([]),[$e,nt]=(0,_.useState)({}),[rt,it]=(0,_.useState)({}),[ot,st]=(0,_.useState)({}),[lt,ut]=(0,_.useState)({}),[dt,Ct]=(0,_.useState)({}),[Et,Dt]=(0,_.useState)({}),[Lt,Rt]=(0,_.useState)({}),[zt,Bt]=(0,_.useState)([]),[Vt,Ht]=(0,_.useState)([]),[Ut,Wt]=(0,_.useState)({}),[Gt,Kt]=(0,_.useState)([]),[qt,Jt]=(0,_.useState)([]),[F,Yt]=(0,_.useState)(null),[Xt,Zt]=(0,_.useState)([]),[I,Qt]=(0,_.useState)(null),[$t,rn]=(0,_.useState)(null),[an,on]=(0,_.useState)(null),[un,dn]=(0,_.useState)(null),[fn,pn]=(0,_.useState)(null),[L,mn]=(0,_.useState)(null),[hn,gn]=(0,_.useState)([]),[vn,yn]=(0,_.useState)(!1),[B,kn]=(0,_.useState)(re),[An,jn]=(0,_.useState)(null),[Mn,Nn]=(0,_.useState)(null),[Pn,Fn]=(0,_.useState)([]),[In,Ln]=(0,_.useState)([]),[Rn,zn]=(0,_.useState)({}),[Bn,Vn]=(0,_.useState)([]),[Hn,Un]=(0,_.useState)({}),[Wn,Gn]=(0,_.useState)([]),[Kn,qn]=(0,_.useState)([]),[Jn,Yn]=(0,_.useState)({}),[Xn,Zn]=(0,_.useState)({}),[Qn,$n]=(0,_.useState)(()=>localStorage.getItem(C.vpnDiagnosticDeviceId)||``),[er,tr]=(0,_.useState)([]),[nr,rr]=(0,_.useState)(null),[ir,ar]=(0,_.useState)(`http://2ip.ru/`),[or,sr]=(0,_.useState)(null),[cr,lr]=(0,_.useState)([]),[ur,dr]=(0,_.useState)([]),[fr,pr]=(0,_.useState)([]),[mr,hr]=(0,_.useState)({}),[gr,_r]=(0,_.useState)([]),[vr,yr]=(0,_.useState)([]),[br,xr]=(0,_.useState)(null),[Sr,Cr]=(0,_.useState)(``),[wr,Tr]=(0,_.useState)(`poll`),[Er,Dr]=(0,_.useState)(``),[Or,kr]=(0,_.useState)(null),[Ar,jr]=(0,_.useState)(!1),[Mr,Nr]=(0,_.useState)(``),[Pr,Fr]=(0,_.useState)(``),[Ir,Lr]=(0,_.useState)({slug:``,name:``,region:``}),[Rr,zr]=(0,_.useState)({name:``,status:`active`,region:``,metadataJson:`{}`}),[Br,Vr]=(0,_.useState)({name:``,parentGroupId:``}),[Hr,Ur]=(0,_.useState)({name:``,endpointType:`client_access`,publicEndpoint:``}),[Wr,Gr]=(0,_.useState)({name:``,description:``,routeScope:`{ - "routes": [] -}`}),[Kr,qr]=(0,_.useState)({hysteresisPenalty:`150`,promotionMinSamples:`64`,demotionFailureThreshold:`1`,demotionDropThreshold:`1`,demotionSlowThreshold:`1`,demotionRebuildEnabled:!0,demotionFencedEnabled:!0}),[Jr,Yr]=(0,_.useState)({currentWindowSeconds:`1800`,historyWindowSeconds:`86400`}),[H,U]=(0,_.useState)(le),[Xr,Zr]=(0,_.useState)(null),[Qr,$r]=(0,_.useState)({authorityState:`authoritative`,mutationMode:`normal`,notes:``}),[ei,ti]=(0,_.useState)(``),[ni,ri]=(0,_.useState)(`cluster`),[ii,ai]=(0,_.useState)(``),[oi,si]=(0,_.useState)(``),[ci,li]=(0,_.useState)([]),[ui,di]=(0,_.useState)(`membership`),[fi,pi]=(0,_.useState)(null),[mi,hi]=(0,_.useState)([]),[gi,_i]=(0,_.useState)(null),[vi,yi]=(0,_.useState)(`details`),[bi,xi]=(0,_.useState)({}),[Si,Ci]=(0,_.useState)({}),[wi,Ti]=(0,_.useState)({}),[Ei,Di]=(0,_.useState)({}),[Oi,ki]=(0,_.useState)({}),[W,Ai]=(0,_.useState)({}),[ji,Mi]=(0,_.useState)(``),[Ni,Pi]=(0,_.useState)({telemetry:!0,links:!0}),[Fi,Ii]=(0,_.useState)({nodeId:``,serviceType:`entry-node`,desiredState:`enabled`,runtimeMode:`container`,version:``,configJson:`{}`,environmentJson:`{}`}),[G,Li]=(0,_.useState)({organizationId:``,name:``,protocolFamily:`generic`,desiredState:`disabled`,credentialRef:``,targetEndpointJson:`{}`,allowedNodePolicyJson:`{"mode":"explicit","node_ids":[]}`,routingUsageJson:`[]`,routePolicyJson:`{}`,qosPolicyJson:`{}`,placementPolicyJson:`{}`}),[Ri,zi]=(0,_.useState)({slug:``,name:``}),[Bi,Vi]=(0,_.useState)({email:``,password:``,platformRole:`user`}),[Hi,Ui]=(0,_.useState)({organizationId:``,userId:``,roleId:`org_member`}),[Wi,Gi]=(0,_.useState)(null),[Ki,qi]=(0,_.useState)({username:``,password:``,domain:``}),[K,Ji]=(0,_.useState)({organizationId:``,name:``,address:``,protocol:`rdp`,routeMode:`vpn_exit`,entryNode:``,exitNode:``,tags:``,username:``,password:``,domain:``}),[Yi,Xi]=(0,_.useState)(``),[Zi,Qi]=(0,_.useState)(``),[$i,ea]=(0,_.useState)(``),ta=`rap-android-rdp-vpn-latest-release.apk`,[na,ra]=(0,_.useState)(ta),q=(0,_.useMemo)(()=>new y({baseUrl:i,actorUserId:p}),[i,p]),ia=(0,_.useMemo)(()=>new y({baseUrl:i,actorUserId:``}),[i]),aa=(0,_.useRef)(0),oa=(0,_.useRef)(!1),J=E[d],sa=ge.find(e=>e.id===T)||null,ca=Se.find(e=>e.cluster_id===T)||null,la=(0,_.useMemo)(()=>xn(i),[i]),ua=(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},[]),da=ua(na,ta),fa=$i?ua($i,da):da,pa=$i?fa:da,ma=`${/^https?:\/\//i.test(pa)?pa:`${la}/${pa}`}${Zi?`?_v=${encodeURIComponent(Zi)}`:``}`,ha=(0,_.useMemo)(()=>bt(H),[H]),ga=(0,_.useMemo)(()=>Xr?xt(Xr.scope,H):H,[Xr,H]),_a=(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)(()=>tn(_a,T,ii,ui,d),[_a,ui,ii,d,T]);let va=(0,_.useMemo)(()=>{let e=ii.trim().toLowerCase(),t=oi?new Set([oi,..._t(oi,De)]):null;return _a.filter(n=>{let r=n.memberships.some(e=>e.cluster.id===T);if(ni!==`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||nn(n,e)})},[_a,ii,oi,De,ni,T]),ya=(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)},[]),ba=(0,_.useCallback)(async()=>{try{let e=`${la}/downloads/rap-android-rdp-vpn-build.json?_cb=${Date.now()}`,t=await fetch(e,{cache:`no-store`});if(!t.ok){Xi(``),Qi(new Date().toISOString()),ea(``),ra(ta);return}let n=await t.json();Xi(n.version?.name||``),Qi(n.published?.timestamp_utc||``),ea(n.release_paths?.versioned||``),ra(n.published?.path||n.release_paths?.latest||ta)}catch{Xi(``),Qi(new Date().toISOString()),ea(``),ra(ta)}},[la]),xa=(0,_.useMemo)(()=>vt(va,De,T,J,new Set(ci)),[ci,De,T,J,va]),Sa=(0,_.useMemo)(()=>vr.slice(0,8),[vr]);(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 ia.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),ya(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)}})()}},[ia,e,i,ya]),(0,_.useEffect)(()=>{let e=!1;return ia.getInstallationStatus().then(t=>{e||b(t)}).catch(t=>{e||Nr(t instanceof Error?t.message:`Не удалось загрузить статус установки.`)}),()=>{e=!0}},[ia]),(0,_.useEffect)(()=>{if(!sa){zr({name:``,status:`active`,region:``,metadataJson:`{}`});return}zr({name:sa.name,status:sa.status||`active`,region:sa.region||``,metadataJson:JSON.stringify(sa.metadata||{},null,2)})},[sa]),(0,_.useEffect)(()=>{si(``),Vr({name:``,parentGroupId:``}),li([])},[T]),(0,_.useEffect)(()=>{pi(null),hi([])},[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&&Ca()},[a?.userId]),(0,_.useEffect)(()=>{if(!a||s!==`admin`||!T)return;let e=!1,t=()=>{e||Ar||oa.current||document.visibilityState===`hidden`||(oa.current=!0,Ta(T).catch(t=>{e||Nr(t instanceof Error?t.message:`Не удалось автообновить данные панели.`)}).finally(()=>{oa.current=!1}))},n=null;typeof window.EventSource==`function`&&(n=new EventSource(q.clusterEventsURL(T)),n.onopen=()=>{e||Tr(`sse`)},n.onerror=()=>{e||Tr(`poll`)},n.addEventListener(`cluster.changed`,t));let r=window.setInterval(t,n?3e4:1e4);return()=>{e=!0,n?.close(),window.clearInterval(r)}},[q,s,Ar,T,a?.userId]);async function Ca(e=T){if(!p.trim()){Nr(J.noLoginError);return}if(s===`user`){await wa();return}jr(!0),Nr(``),Fr(``);try{let[t,n,r,i,a]=await Promise.all([q.listClusters(),q.listClusterSummaries(),q.listOrganizations(),q.listUsers(),q.listResources()]);ve(t),Ce(n),lr(r),dr(i),pr(a),!Er&&r[0]?.id&&Dr(r[0].id),Ui(e=>({...e,organizationId:e.organizationId||r[0]?.id||``})),Ji(e=>({...e,organizationId:e.organizationId||r[0]?.id||``}));let o=await Promise.all(r.map(async e=>[e.id,await q.listOrganizationMemberships(e.id)]));hr(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 Ea(c),Cr(new Date().toISOString())}catch(e){Nr(e instanceof Error?e.message:`Неизвестная ошибка панели управления платформой.`)}finally{jr(!1)}}async function wa(){if(!p.trim()){Nr(`Войдите, чтобы загрузить личный кабинет.`);return}jr(!0),Nr(``),Fr(``);try{await ba();let[e,t]=await Promise.all([q.listOrganizations(),q.listResources()]);lr(e),pr(t),!Er&&e[0]?.id&&Dr(e[0].id);let n=await Promise.all(e.map(async e=>[e.id,await q.listOrganizationMemberships(e.id)]));hr(Object.fromEntries(n)),Cr(new Date().toISOString())}catch(e){Nr(e instanceof Error?e.message:`Не удалось загрузить личный кабинет.`)}finally{jr(!1)}}async function Ta(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),lr(r),dr(i),pr(a),Ae(t=>({...t,[e]:n})),await Ea(e,{preserveEditableForms:!0}),Cr(new Date().toISOString())}async function Ea(e,t={}){let n=++aa.current,r=vn?20:10,i=vn?B.offset:0,a={reporterNodeId:B.reporterNodeId||void 0,routeId:B.routeId||void 0,serviceClass:B.serviceClass||void 0,generation:B.generation||void 0,feedbackSource:B.feedbackSource||void 0,feedbackChannelId:B.feedbackChannelId||void 0,feedbackViolationStatus:B.feedbackViolationStatus||void 0,limit:r,offset:i,enrichment:vn?`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,T,se]=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.listFabricEntryPoints(e),q.listFabricEgressPools(e),q.listVPNConnections(e),q.listFabricTestingFlags()]);if(n!==aa.current)return;Ee(o),Oe(s),ze(c),Ve(l),Xe(u),Te(d),t.preserveEditableForms||$r({authorityState:d.authority_state,mutationMode:d.mutation_mode,notes:d.notes||``}),_r(f),yr(p.events),xr(p.summary||null),Bt(m),Ht(h),Kt(g),Jt(_),Yt(v),Zt(y),Qt(b),rn(x),on(ee),pn(S),mn(te),gn(ne),jn(re),Nn(ie),t.preserveEditableForms||Yr({currentWindowSeconds:String(ie.current_window_seconds||1800),historyWindowSeconds:String(ie.history_window_seconds||86400)}),qr({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}),Fn(w),Ln(ae),Vn(oe),qn(T),Gn(se);let ce=await q.listVPNClientDiagnosticStatuses(e);if(n!==aa.current)return;tr(ce);let le=ce.find(e=>e.device_id===Qn.trim())||ce[0]||null;rr(le),!Qn.trim()&&le&&($n(le.device_id),localStorage.setItem(C.vpnDiagnosticDeviceId,le.device_id));let[E,D]=await Promise.all([Promise.all(ae.map(async t=>[t.id,await q.listFabricEntryPointNodes(e,t.id)])),Promise.all(oe.map(async t=>[t.id,await q.listFabricEgressPoolNodes(e,t.id)]))]);if(n!==aa.current)return;zn(Object.fromEntries(E)),Un(Object.fromEntries(D));let ue=await Promise.all(o.map(async t=>[t.id,await q.listNodeRoles(e,t.id)]));if(n!==aa.current)return;st(Object.fromEntries(ue));let de=await Promise.all(o.map(async t=>[t.id,await q.listDesiredWorkloads(e,t.id)]));if(n!==aa.current)return;ut(Object.fromEntries(de));let fe=await Promise.all(o.map(async t=>[t.id,await q.listWorkloadStatuses(e,t.id)]));if(n!==aa.current)return;Ct(Object.fromEntries(fe));let O=await Promise.all(o.map(async t=>[t.id,await q.listNodeHeartbeats(e,t.id,60)]));if(n!==aa.current)return;Dt(Object.fromEntries(O));let k=await Promise.all(o.map(async t=>[t.id,await q.getNodeUpdatePlan(e,t.id,{currentVersion:t.reported_version})]));if(n!==aa.current)return;nt(Object.fromEntries(k));let pe=await Promise.all(o.map(async t=>[t.id,await q.listNodeUpdateStatuses(e,t.id,80)]));if(n!==aa.current)return;it(Object.fromEntries(pe));let A=await Promise.all(o.map(async t=>[t.id,await q.listNodeTelemetry(e,t.id,120)]));if(n!==aa.current)return;Rt(Object.fromEntries(A));let me=await Promise.all(o.map(async t=>[t.id,await q.getNodeSyntheticMeshConfig(e,t.id)]));if(n!==aa.current)return;Wt(Object.fromEntries(me));let he=await Promise.all(T.map(async t=>[t.id,await q.getActiveVPNLease(e,t.id)]));if(n!==aa.current)return;Yn(Object.fromEntries(he));let ge=await Promise.all(T.map(async t=>[t.id,await q.getVPNPacketStats(e,t.id)]));n===aa.current&&Zn(Object.fromEntries(ge))}async function Da(e=vn,t=B){if(T){jr(!0),Nr(``),Fr(``);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`});yn(e),kn(t),Jt(n),Fr(e?`Deep rebuild ledger loaded.`:`Fast rebuild ledger loaded.`)}catch(e){Nr(e instanceof Error?e.message:`Не удалось загрузить rebuild ledger.`)}finally{jr(!1)}}}async function Oa(){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)]);Yt(e),Zt(t),Qt(n),rn(r),on(i),pn(a),mn(o),gn(s),yr(c.events),xr(c.summary||null),Nn(l),Yr({currentWindowSeconds:String(l.current_window_seconds||1800),historyWindowSeconds:String(l.history_window_seconds||86400)})}async function ka(){if(T)try{jr(!0);let e=await q.warmupFabricServiceChannelRebuildSnapshots(T,{limit:10,staleAfterSeconds:60});dn(e),await Oa(),Fr(`Snapshot warmup: warmed ${e.warmed_count}, fresh ${e.already_fresh_count}, errors ${e.error_count}.`)}catch(e){Nr(e instanceof Error?e.message:`Не удалось прогреть rebuild snapshots.`)}finally{jr(!1)}}async function Aa(){if(T)try{jr(!0);let e=await q.cleanupFabricServiceChannelLeases(T,{limit:100});pn(e),Fr(`Service-channel lease cleanup: deleted ${e.deleted_expired_count||0}, active ${e.active_count}, expired ${e.expired_count}.`)}catch(e){Nr(e instanceof Error?e.message:`Не удалось очистить service-channel leases.`)}finally{jr(!1)}}async function ja(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});yr(n.events),xr(n.summary||null),kn(t),await Da(!0,t)}function Ma(e){let t=new Set(e.affected_reporter_node_ids||[]),n=new Set(e.affected_route_ids||[]);return hn.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 Na(e){let t=at(e.payload)||{},n=N(t,`feedback_source`,``),r=N(t,`feedback_channel_id`,``),i=N(t,`feedback_violation_status`,``),a=N(t,`reporter_node_id`,``),o=N(t,`route_id`,``);return!n&&!r&&!i?null:(F?.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 Pa(e){let t=at(e.payload)||{},n=N(t,`reporter_node_id`,``),r=N(t,`route_id`,e.target_type===`fabric_service_channel_route_rebuild_incident`&&e.target_id||``),i=N(t,`service_class`,``),a=N(t,`generation`,``),o=N(t,`guard_status`,``);return hn.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 Fa(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});yr(n.events),xr(n.summary||null),ae(`fabric`),kn(t),await Da(!0,t)}async function Ia(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 Oa()}async function La(e){await q.unsilenceFabricServiceChannelRouteRebuildAlert(T,e.id,`operator removed rebuild alert silence`),await Oa()}function Ra(){Ee([]),Oe([]),ze([]),Ve([]),Xe([]),nt({}),Te(null),st({}),ut({}),Ct({}),Dt({}),it({}),Rt({}),Bt([]),Ht([]),Wt({}),Kt([]),Jt([]),Yt(null),Zt([]),Qt(null),rn(null),dn(null),gn([]),yn(!1),kn(re),Fn([]),Ln([]),zn({}),Vn([]),Un({}),Gn([]),qn([]),Yn({}),Zn({}),tr([]),rr(null),lr([]),dr([]),pr([]),hr({}),_r([]),yr([]),xr(null)}async function Y(e,t){jr(!0),Nr(``),Fr(``);try{await e(),Fr(t),await Ca()}catch(e){Nr(e instanceof Error?e.message:`Действие не выполнено.`)}finally{jr(!1)}}async function za(){if(!T){rr(null);return}let e=await q.listVPNClientDiagnosticStatuses(T);tr(e);let t=Qn.trim()||e[0]?.device_id||``;t&&(localStorage.setItem(C.vpnDiagnosticDeviceId,t),$n(t));let n=e.find(e=>e.device_id===t)||(t?await q.getVPNClientDiagnosticStatus(T,t):null);rr(n),Fr(n?`Диагностика VPN-клиента обновлена.`:`Диагностика VPN-клиента не найдена.`)}async function Ba(e,t){if(!T){Nr(`Выбери кластер перед отправкой команды.`);return}let n=Qn.trim();if(!n){Nr(`Укажи Android device id или выбери найденный клиент.`);return}jr(!0),Nr(``),Fr(``);try{sr(await q.enqueueVPNClientDiagnosticCommand(T,n,e)),Fr(`${t}: команда поставлена в очередь. Клиент заберет ее через диагностический канал.`),window.setTimeout(()=>{za()},3500)}catch(e){Nr(e instanceof Error?e.message:`Команда VPN-клиенту не отправлена.`)}finally{jr(!1)}}async function Va(){jr(!0),Nr(``),Fr(``);try{let e=D(await ia.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()]);lr(e),pr(r),e[0]?.id&&Dr(e[0].id);let i=await Promise.all(e.map(async e=>[e.id,await t.listOrganizationMemberships(e.id)]));hr(Object.fromEntries(i)),n=`user`}catch{try{await ia.revokeAuthSession({userId:e.userId,authSessionId:e.authSessionId,reason:`user_portal_access_denied`})}catch{}throw Error(J.accessDenied)}}r(h.rememberMe),ya(e,h.rememberMe),o(e),m(e.userId),g(t=>({...t,email:e.email,password:``})),u(new Date().toISOString()),c(n),Fr(`${J.signedInAs}: ${e.email}`)}catch(e){Nr(e instanceof Error?e.message:`Вход не выполнен.`)}finally{jr(!1)}}async function Ha(){jr(!0),Nr(``),Fr(``);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 ia.bootstrapOwner({email:x.email,password:x.password,activationPayload:e,activationSignature:x.activationSignature})).installation),g({...h,email:x.email,password:x.password}),Fr(J.ownerCreated)}catch(e){Nr(e instanceof Error?e.message:`Создание владельца не выполнено.`)}finally{jr(!1)}}async function Ua(){let e=a;if(o(null),r(!1),u(``),ya(null),c(null),m(``),ve([]),Ce([]),Ra(),Ae({}),de(``),e?.userId&&e.authSessionId)try{await ia.revokeAuthSession({userId:e.userId,authSessionId:e.authSessionId,reason:`platform_owner_console_logout`})}catch{}}async function Wa(e){de(e),Ra(),jr(!0),Nr(``),Fr(``);try{await Ea(e)}catch(e){Nr(e instanceof Error?e.message:`Не удалось загрузить кластер.`)}finally{jr(!1)}}let Ga=Fe.filter(e=>e.status===`pending`).length,Ka=j.filter(e=>e.health_status===`healthy`).length,qa=j.filter(e=>e.health_status!==`healthy`||e.membership_status!==`active`).length,Ja=Object.values(ot).flat().filter(e=>e.status===`active`).length,Ya=Wn.find(e=>e.scope_type===`platform`&&!e.scope_id)||null;Wn.find(e=>e.scope_type===`organization`&&e.scope_id===ji&&(!e.cluster_id||e.cluster_id===T));let Xa=Object.values(Ut),Za=Xa.filter(e=>e.enabled).length,Qa=Xa.reduce((e,t)=>e+t.routes.length,0),$a=Xa.reduce((e,t)=>e+Object.keys(t.peer_endpoints||{}).length,0),eo=Xa.reduce((e,t)=>e+ft(t),0),to=Xa.reduce((e,t)=>e+(t.peer_directory?.length??0),0),no=Xa.reduce((e,t)=>e+(t.recovery_seeds?.length??0),0),X=Xa.filter(e=>e.production_forwarding).length,ro=Vt.filter(e=>Ke(e)===`active`),io=Vt.filter(e=>Ke(e)===`expired`),ao=Vt.filter(e=>Ke(e)===`disabled`),oo=Gt.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()}),so=oo.filter(e=>e.feedback_status===`fenced`),co=oo.filter(e=>e.feedback_status===`degraded`),lo=oo.filter(e=>e.feedback_status===`healthy`),uo=oo.filter(e=>e.recovery_state===`recovered`||e.recovery_hysteresis_active),fo=oo.filter(e=>e.recovery_promoted),po=oo.filter(e=>e.recovery_demoted),mo=oo.filter(e=>e.feedback_status===`operator_retry_cooldown`||e.retry_cooldown_until),ho=Xa.flatMap(e=>e.route_path_decisions?.decisions||[]),go=ho.filter(e=>e.decision_source===`service_channel_feedback_no_alternate`),_o=ho.filter(e=>e.decision_source===`service_channel_feedback_replacement`),vo=ho.filter(e=>e.rebuild_status),yo=vo.filter(e=>e.rebuild_status===`applied`),bo=qt.filter(e=>e.rebuild_status===`applied`),xo=qt.filter(e=>e.rebuild_status&&e.rebuild_status!==`applied`),So=qt.filter(e=>e.guard_severity===`bad`),Co=ho.filter(e=>(e.score_reasons||[]).includes(`service_channel_recovery_hysteresis`)),wo=ho.filter(e=>(e.score_reasons||[]).includes(`service_channel_recovery_promoted`)),To=ho.filter(e=>(e.score_reasons||[]).includes(`service_channel_recovery_demoted`)),Eo=v?.bootstrapped===!1,Do=Eo&&!v?.strict_authority&&!v?.insecure_bootstrap_allowed,Oo=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:R(v.root_fingerprint)})]}),Eo?(0,S.jsxs)(`section`,{className:`loginCard`,children:[(0,S.jsx)(`h1`,{children:J.bootstrapTitle}),(0,S.jsx)(`p`,{className:`loginHint`,children:Do?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})]})]}),Mr&&(0,S.jsx)(`div`,{className:`errorPanel`,children:Mr}),Pr&&(0,S.jsx)(`div`,{className:`noticePanel`,children:Pr}),(0,S.jsx)(`button`,{className:`primary wide`,onClick:()=>void Ha(),disabled:Ar||Do||!x.email||x.password.length<12||v?.strict_authority&&(!x.activationPayload||!x.activationSignature),children:Ar?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`&&Va()}})]}),(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]}),Mr&&(0,S.jsx)(`div`,{className:`errorPanel`,children:Mr}),Pr&&(0,S.jsx)(`div`,{className:`noticePanel`,children:Pr}),(0,S.jsx)(`button`,{className:`primary wide`,onClick:()=>void Va(),disabled:Ar||!h.email||!h.password,children:Ar?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:Ar?J.lastRefresh:`Восстанавливаем сессию...`})})});if(s===`user`){let e=cr.find(e=>e.id===Er)||cr[0]||null,t=e?fr.filter(t=>t.organization_id===e.id):fr,n=e?(mr[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:`${Oo} • ${l?Cn(l):`н/д`}`}),(0,S.jsx)(A,{label:J.actorUser,value:a.email}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>void Ua(),disabled:Ar,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=>Dr(e.target.value),children:cr.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))})]}),(0,S.jsx)(`button`,{className:`primary`,onClick:()=>void wa(),disabled:Ar,children:Ar?J.refreshing:J.refresh})]}),Mr&&(0,S.jsx)(`div`,{className:`errorPanel`,children:Mr}),Pr&&(0,S.jsx)(`div`,{className:`noticePanel`,children:Pr}),(0,S.jsxs)(`section`,{className:`grid three`,children:[(0,S.jsx)(fe,{label:`Организации`,value:cr.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:Yi?`Актуальная версия Android: ${Yi}`:`Скачивайте актуальные клиенты только отсюда, чтобы не ловить старую сборку.`})]}),(0,S.jsx)(`span`,{className:`status active`,children:`latest`})]}),(0,S.jsxs)(`div`,{className:`portalInstallList`,children:[(0,S.jsxs)(`a`,{className:`installTile primaryInstall`,href:ma,children:[(0,S.jsx)(`strong`,{children:`Android VPN`}),(0,S.jsx)(`span`,{children:`Последняя сборка RAP HOME VPN для телефона`}),(0,S.jsx)(`small`,{children:$i||pa})]}),(0,S.jsxs)(`a`,{className:`installTile`,href:`${la}/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:Sr?z(Sr):`нет`})]}),(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?`настроен`:`нет`,V(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/RDP сессии`}),(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:sa?sa.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 Wa(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,`: `,sa?.slug||`н/д`]})]}),(0,S.jsx)(`button`,{className:`primary`,onClick:()=>void Ca(),disabled:Ar,children:Ar?J.refreshing:J.refresh}),(0,S.jsxs)(`div`,{className:`refreshStatus`,children:[(0,S.jsx)(`strong`,{children:J.autoRefresh}),(0,S.jsx)(`span`,{children:Sr?`${J.lastRefresh}: ${Cn(Sr)} / ${wr.toUpperCase()}`:wr.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,`: `,Oo,` | `,J.sessionRefreshedAt,`: `,l?Cn(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 Ua(),disabled:Ar,children:J.logout})]})]}),Mr&&(0,S.jsx)(`div`,{className:`errorPanel`,children:Mr}),Pr&&(0,S.jsx)(`div`,{className:`noticePanel`,children:Pr}),sa&&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:Ka,tone:`green`}),(0,S.jsx)(fe,{label:`Ожидают подключения`,value:Ga,tone:`amber`}),(0,S.jsx)(fe,{label:`Рискованные состояния`,value:qa,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,R(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),z(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:R(ca?.cluster_key_fingerprint)}),(0,S.jsx)(A,{label:`Обновлено`,value:z(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(Ja)}),(0,S.jsx)(O,{label:`Отчеты сервисов`,value:String(Object.values(dt).filter(e=>e.length>0).length)}),(0,S.jsx)(O,{label:`Наблюдения связей`,value:String(zt.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:_n(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 Wa(e.id),children:J.makeActive}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>{Wa(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:z(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:V(e.status)}),(0,S.jsx)(A,{label:`Authority`,value:t?`${t.authority_state}/${t.mutation_mode}`:`неизвестно`}),(0,S.jsx)(A,{label:`Создан`,value:z(e.created_at)}),(0,S.jsx)(A,{label:`Обновлен`,value:z(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:Ir.slug,onChange:e=>Lr({...Ir,slug:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Название`,(0,S.jsx)(`input`,{value:Ir.name,onChange:e=>Lr({...Ir,name:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Регион`,(0,S.jsx)(`input`,{value:Ir.region,onChange:e=>Lr({...Ir,region:e.target.value})})]})]}),(0,S.jsx)(`p`,{className:`muted`,children:J.slugHelp}),(0,S.jsx)(`button`,{className:`primary`,disabled:!Ir.slug||!Ir.name,onClick:()=>void Y(async()=>{await q.createCluster({slug:Ir.slug,name:Ir.name,region:Ir.region||null}),Lr({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:[!sa&&(0,S.jsx)(me,{title:`Кластер не выбран`,text:`Выберите активный кластер, чтобы открыть настройки.`}),sa&&(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:sa.id,readOnly:!0})]}),(0,S.jsxs)(`label`,{children:[`Slug`,(0,S.jsx)(`input`,{value:sa.slug,readOnly:!0})]}),(0,S.jsxs)(`label`,{children:[`Название`,(0,S.jsx)(`input`,{value:Rr.name,onChange:e=>zr({...Rr,name:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Статус`,(0,S.jsxs)(`select`,{value:Rr.status,onChange:e=>zr({...Rr,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:Rr.region,onChange:e=>zr({...Rr,region:e.target.value}),placeholder:`например ru-msk-1`})]}),(0,S.jsxs)(`label`,{children:[`Обновлен`,(0,S.jsx)(`input`,{value:z(sa.updated_at||sa.created_at),readOnly:!0})]})]}),(0,S.jsxs)(`label`,{className:`wideLabel`,children:[`Metadata JSON`,(0,S.jsx)(`textarea`,{value:Rr.metadataJson,onChange:e=>zr({...Rr,metadataJson:e.target.value}),rows:8,spellCheck:!1})]}),(0,S.jsx)(`button`,{className:`primary`,disabled:!Rr.name.trim(),onClick:()=>bn(`Сохранить базовые настройки кластера`)&&void Y(async()=>{let e=Me(Rr.metadataJson||`{}`,`Metadata JSON`);await q.updateCluster(sa.id,{name:Rr.name,status:Rr.status,region:Rr.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:R(ca?.cluster_key_fingerprint)}),(0,S.jsx)(A,{label:`Последнее изменение`,value:z(we?.updated_at)})]}),(0,S.jsxs)(je,{children:[(0,S.jsxs)(`label`,{children:[`Состояние authority`,(0,S.jsxs)(`select`,{value:Qr.authorityState,onChange:e=>$r({...Qr,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:Qr.mutationMode,onChange:e=>$r({...Qr,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:Qr.notes,onChange:e=>$r({...Qr,notes:e.target.value})})]})]}),(0,S.jsx)(`button`,{disabled:!T,onClick:()=>bn(`Изменить authority state кластера`)&&void Y(()=>q.updateClusterAuthority(T,{authorityState:Qr.authorityState,mutationMode:Qr.mutationMode,notes:Qr.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(ca?.node_count??j.length)}),(0,S.jsx)(A,{label:`Healthy`,value:String(ca?.healthy_node_count??Ka)}),(0,S.jsx)(A,{label:`Pending join`,value:String(ca?.pending_join_count??Fe.filter(e=>e.status===`pending`).length)}),(0,S.jsx)(A,{label:`Последний узел`,value:z(ca?.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:Ya?.telemetry_enabled?`включен`:`выключен`}),(0,S.jsx)(A,{label:`Synthetic links`,value:Ya?.synthetic_links_enabled?`включены`:`выключены`}),(0,S.jsx)(A,{label:`Хранение истории, часов`,value:String(Ya?.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:`${yt(`update-cache`,ot).length} узл.`}),(0,S.jsx)(A,{label:`File/config cache`,value:`${yt(`file-storage-cache`,ot).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:`${yt(`entry-node`,ot).length} узл.`}),(0,S.jsx)(A,{label:`Relay nodes`,value:`${yt(`relay-node`,ot).length} узл.`}),(0,S.jsx)(A,{label:`Core mesh`,value:`${yt(`core-mesh`,ot).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:ni===`all`,onChange:e=>ri(e.target.checked?`all`:`cluster`)}),J.showAllPlatformNodes]}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>{ri(`all`),ai(``)},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(_a.length)}),(0,S.jsx)(O,{label:`Заявки`,value:String(Ga)}),(0,S.jsx)(O,{label:`Активные роли`,value:String(Ja)})]}),(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:va.length})]}),(0,S.jsxs)(je,{children:[(0,S.jsxs)(`label`,{children:[J.nodeSearch,(0,S.jsx)(`input`,{value:ii,onChange:e=>ai(e.target.value),placeholder:J.nodeSearchPlaceholder})]}),(0,S.jsxs)(`label`,{children:[J.nodeGroupFilter,(0,S.jsxs)(`select`,{value:oi,onChange:e=>si(e.target.value),children:[(0,S.jsx)(`option`,{value:``,children:J.allNodeGroups}),De.map(e=>(0,S.jsx)(`option`,{value:e.id,children:ht(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:Br.name,onChange:e=>Vr({...Br,name:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[J.parentNodeGroup,(0,S.jsxs)(`select`,{value:Br.parentGroupId,onChange:e=>Vr({...Br,parentGroupId:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:J.rootNodeGroup}),De.map(e=>(0,S.jsx)(`option`,{value:e.id,children:ht(e,De)},e.id))]})]}),(0,S.jsxs)(`label`,{children:[J.createNodeGroup,(0,S.jsx)(`button`,{className:`primary`,disabled:!Br.name.trim(),onClick:()=>void Y(async()=>{await q.createNodeGroup(T,{name:Br.name,parentGroupId:Br.parentGroupId||null}),Vr({name:``,parentGroupId:``})},J.nodeGroupCreated),children:J.createNodeGroup})]})]}),(0,S.jsxs)(`div`,{className:`nodeList`,children:[xa.map(e=>{if(e.kind===`group`){let t=ci.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:gt(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:()=>li(en(ci,e.key)),children:t?J.expandGroup:J.collapseGroup}),e.groupId&&(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>Vr({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=ct(r,Et[r.id]||[],zt),a=Re(r,$e[r.id],Ue),o=Ye(rt[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:z(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:()=>{_i(t),yi(`details`)},children:J.nodeDetails}),s?(0,S.jsxs)(S.Fragment,{children:[(0,S.jsx)(`button`,{className:`primary`,onClick:()=>{_i(t),yi(`manage`)},children:J.manageNode}),(0,S.jsx)(`button`,{className:`danger`,onClick:()=>bn(`Удалить узел ${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:()=>{pi(t),hi([])},children:J.connectExistingNode})]})]},e.key)}),xa.length===0&&(0,S.jsx)(me,{title:J.noNodesTitle,text:J.noNodesByFilter})]})]}),fi&&(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:()=>pi(null),children:J.cancel})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`Узел`,value:fi.node.name}),(0,S.jsx)(A,{label:`Node key`,value:fi.node.node_key}),(0,S.jsx)(A,{label:J.activeCluster,value:sa?.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:mi.includes(e),onChange:()=>hi(en(mi,e))}),Ie(e)]},e))}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{className:`primary`,onClick:()=>void Y(async()=>{await q.attachExistingNode(T,fi.node.id,mi),pi(null),hi([]),ri(`cluster`)},`Узел подключен к активному кластеру.`),children:J.connectWithRoles}),(0,S.jsx)(`button`,{onClick:()=>pi(null),children:J.cancel})]})]})}),gi&&(()=>{let e=gi.memberships.find(e=>e.cluster.id===T),t=e?.node||gi.node,n=e?(Et[t.id]||[])[0]:void 0,r=e?(ot[t.id]||[]).filter(e=>e.status===`active`):[],i=e&<[t.id]||[],a=e&&dt[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:[vi===`manage`?J.manageNode:J.nodeDetails,`: `,t.name]}),(0,S.jsx)(`p`,{className:`muted`,children:t.node_key})]}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>{_i(null),yi(`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:R(t.id)}),(0,S.jsx)(A,{label:`Ключ узла`,value:t.node_key}),(0,S.jsx)(A,{label:`Тип владения`,value:V(t.ownership_type)}),(0,S.jsx)(A,{label:`Owner org`,value:R(t.owner_organization_id)}),(0,S.jsx)(A,{label:`Регистрация`,value:V(t.registration_status)}),(0,S.jsx)(A,{label:`Здоровье`,value:V(t.health_status)}),(0,S.jsx)(A,{label:`Версия`,value:t.reported_version||`неизвестно`}),(0,S.jsx)(A,{label:`Последний сигнал`,value:z(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:gi.memberships.map(e=>(0,S.jsxs)(`span`,{className:e.cluster.id===T?`pill good`:`pill`,children:[e.cluster.name,`: `,V(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:V(t.membership_status)}),(0,S.jsx)(A,{label:`Сегмент`,value:V(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)})]})]}),vi===`details`&&(0,S.jsx)(_e,{node:t,memberships:gi.memberships,activeRoles:r,desiredWorkloads:i,observedWorkloads:a,heartbeats:Et[t.id]||[],telemetry:Lt[t.id]||[],updatePlan:$e[t.id],updateStatuses:rt[t.id]||[],meshLinks:zt.filter(e=>e.source_node_id===t.id||e.target_node_id===t.id),syntheticConfig:Ut[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}),vi===`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:ei,onChange:e=>ti(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=sn(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:Ie(e)}),(0,S.jsx)(`span`,{children:ln(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:V(u),tone:u===`enabled`?`good`:``}),(0,S.jsx)(pe,{label:J.observedRuntime,value:V(f),tone:f===`running`?`good`:f===`missing`?`warn`:``}),(0,S.jsx)(`span`,{className:`pill ${l}`,children:cn(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`,ei||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=wi[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=>Ti({...wi,[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:ht(e,De)},e.id))]})}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{onClick:()=>bn(`Отключить участие узла ${t.name}`)&&void Y(()=>q.disableMembership(T,t.id,`Отключено из панели владельца платформы.`),`Участие узла отключено.`),children:`Отключить участие`}),(0,S.jsx)(`button`,{className:`danger`,onClick:()=>bn(`Отозвать 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:()=>{pi(gi),hi([]),_i(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:H.ttlHours,onChange:e=>U({...H,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:H.maxUses,onChange:e=>U({...H,maxUses:Number(e.target.value)})}),(0,S.jsx)(`small`,{children:J.maxUsesHelp})]}),(0,S.jsxs)(`label`,{children:[J.nodeOwnership,(0,S.jsxs)(`select`,{value:H.ownershipType,onChange:e=>U({...H,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:H.purpose,onChange:e=>U({...H,purpose:e.target.value}),placeholder:`например: стартовый entry-node в ru-msk-1`})]}),(0,S.jsxs)(`label`,{children:[`Имя нового узла`,(0,S.jsx)(`input`,{value:H.nodeName,onChange:e=>U({...H,nodeName:e.target.value}),placeholder:At(H,sa)}),(0,S.jsx)(`small`,{children:`Если оставить пустым, панель подставит имя автоматически.`})]}),(0,S.jsxs)(`label`,{children:[`Группа узла`,(0,S.jsxs)(`select`,{value:H.nodeGroupId,onChange:e=>U({...H,nodeGroupId:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:`Без группы`}),De.map(e=>(0,S.jsx)(`option`,{value:e.id,children:ht(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:H.installMode===e?`active`:``,onClick:()=>U({...H,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:Ot(H)===e?`active`:``,onClick:()=>U(kt(H,e)),children:t},e))}),(0,S.jsxs)(je,{children:[(0,S.jsxs)(`label`,{children:[`Control-plane endpoint`,(0,S.jsx)(`input`,{value:H.controlPlaneEndpoint,onChange:e=>U({...H,controlPlaneEndpoint:e.target.value}),placeholder:wt()})]}),(0,S.jsxs)(`label`,{children:[H.installMode===`windows_service`?`Windows node-agent artifact`:H.installMode===`linux_binary`?`Linux node-agent artifact`:`Docker image`,(0,S.jsx)(`input`,{value:H.dockerImage,onChange:e=>U({...H,dockerImage:e.target.value})})]}),H.installMode===`windows_service`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`label`,{children:[`Windows startup`,(0,S.jsxs)(`select`,{value:H.windowsStartupMode,onChange:e=>U({...H,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:H.windowsInstallDir,onChange:e=>U({...H,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:H.windowsNodeAgentSHA256,onChange:e=>U({...H,windowsNodeAgentSHA256:e.target.value}),placeholder:`опционально, но желательно для production`})]})]}),H.installMode===`linux_binary`&&(0,S.jsxs)(S.Fragment,{children:[(0,S.jsxs)(`label`,{children:[`Linux install dir`,(0,S.jsx)(`input`,{value:H.linuxInstallDir,onChange:e=>U({...H,linuxInstallDir:e.target.value}),placeholder:`/opt/rap/node-name`})]}),(0,S.jsxs)(`label`,{children:[`Linux node-agent SHA256`,(0,S.jsx)(`input`,{value:H.linuxNodeAgentSHA256,onChange:e=>U({...H,linuxNodeAgentSHA256:e.target.value}),placeholder:`опционально, но желательно для production`})]})]}),H.installMode===`docker`&&(0,S.jsxs)(`label`,{children:[`Container name`,(0,S.jsx)(`input`,{value:H.dockerContainerName,onChange:e=>U({...H,dockerContainerName:e.target.value}),placeholder:jt(H,sa)})]}),(0,S.jsxs)(`label`,{children:[`Artifact endpoints`,(0,S.jsx)(`input`,{value:H.artifactEndpoints,onChange:e=>U({...H,artifactEndpoints:e.target.value}),placeholder:Tt()}),(0,S.jsx)(`small`,{children:`Через запятую: public/LAN/cache узлы, где host-agent сможет скачать image tar до входа в mesh.`})]}),H.installMode===`docker`&&(0,S.jsxs)(`label`,{children:[`Docker image tar SHA256`,(0,S.jsx)(`input`,{value:H.dockerImageArtifactSHA256,onChange:e=>U({...H,dockerImageArtifactSHA256:e.target.value}),placeholder:`опционально, но желательно для production`})]}),H.installMode===`docker`&&(0,S.jsxs)(`label`,{children:[`Docker network`,(0,S.jsxs)(`select`,{value:H.dockerNetwork,onChange:e=>U({...H,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:H.meshListenAddr,onChange:e=>U({...H,meshListenAddr:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Listen mode`,(0,S.jsxs)(`select`,{value:H.meshListenPortMode,onChange:e=>U({...H,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:`${H.meshListenAutoPortStart}-${H.meshListenAutoPortEnd}`,onChange:e=>{let[t,n]=e.target.value.split(`-`).map(e=>Number(e.trim()));U({...H,meshListenAutoPortStart:Number.isFinite(t)?t:H.meshListenAutoPortStart,meshListenAutoPortEnd:Number.isFinite(n)?n:H.meshListenAutoPortEnd})}})]}),(0,S.jsxs)(`label`,{children:[`Advertise endpoint`,(0,S.jsx)(`input`,{value:H.meshAdvertiseEndpoint,onChange:e=>U({...H,meshAdvertiseEndpoint:e.target.value}),placeholder:`http://public-or-private-ip:19131`})]}),(0,S.jsxs)(`label`,{children:[`Connectivity`,(0,S.jsxs)(`select`,{value:H.meshConnectivityMode,onChange:e=>U({...H,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:H.meshNATType,onChange:e=>U({...H,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:H.meshRegion,onChange:e=>U({...H,meshRegion:e.target.value})})]}),H.installMode===`docker`&&(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:H.pullImage,onChange:e=>U({...H,pullImage:e.target.checked})}),`Pull image`]}),(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:H.replace,onChange:e=>U({...H,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:H.roles.includes(e),onChange:()=>U({...H,roles:en(H.roles,e)})}),Ie(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(ha,null,2)})]}),(0,S.jsxs)(`p`,{className:`muted`,children:[J.manualApprovalRequired,`.`]}),(0,S.jsx)(`button`,{className:`primary`,disabled:!T,onClick:()=>void Y(async()=>{Zr(await q.createJoinToken(T,{ttlHours:H.ttlHours,maxUses:H.maxUses,scope:ha}))},`Join token создан.`),children:`Создать install token`}),Xr&&(0,S.jsxs)(`div`,{className:`secretOnce`,children:[(0,S.jsx)(`strong`,{children:`Исходный token, возвращается один раз`}),(0,S.jsx)(`code`,{children:Xr.token}),(0,S.jsxs)(`span`,{className:`muted`,children:[`Authority key: `,R(Xr.authority_signature?.key_fingerprint)]}),(0,S.jsx)(`strong`,{children:`Scope выданного token`}),(0,S.jsx)(`pre`,{className:`codePreview`,children:JSON.stringify(Xr.scope,null,2)}),(0,S.jsx)(`strong`,{children:`Docker host-agent install`}),(0,S.jsx)(`pre`,{className:`codePreview`,children:Mt(Xr,sa,ga)}),(0,S.jsx)(`strong`,{children:`Profile-based Docker install`}),(0,S.jsx)(`pre`,{className:`codePreview`,children:Nt(Xr,sa,ga)}),(0,S.jsx)(`strong`,{children:`Profile-based Ubuntu service install`}),(0,S.jsx)(`pre`,{className:`codePreview`,children:Pt(Xr,sa,ga)}),(0,S.jsx)(`strong`,{children:`Profile-based Windows PowerShell install`}),(0,S.jsx)(`pre`,{className:`codePreview`,children:Ft(Xr,sa,ga)}),(0,S.jsx)(`strong`,{children:`Profile-based Windows CMD install`}),(0,S.jsx)(`pre`,{className:`codePreview`,children:It(Xr,sa,ga)})]})]}),(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:Be.map(e=>[Ze(e),V(e.status),`${e.used_count}/${e.max_uses}`,z(e.expires_at),z(e.created_at),e.status===`active`?(0,S.jsx)(`button`,{className:`danger`,onClick:()=>bn(`Отозвать install token ${R(e.id)}`)&&void Y(()=>q.revokeJoinToken(T,e.id),`Install token отозван.`),children:`Отозвать`}):(0,S.jsx)(`span`,{className:`muted`,children:V(e.status)})])})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Заявки на подключение`}),(0,S.jsxs)(`div`,{className:`stack`,children:[Fe.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 `,R(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)),Fe.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:ei,onChange:e=>ti(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:Pe(ot[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,ei||void 0),`${n} назначена узлу ${e.name}.`)},children:[(0,S.jsx)(`option`,{value:``,children:`Назначить роль...`}),ie.map(e=>(0,S.jsx)(`option`,{value:e,children:Ie(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:Fi.nodeId,onChange:e=>Ii({...Fi,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:Fi.serviceType,onChange:e=>Ii({...Fi,serviceType:e.target.value}),children:ie.map(e=>(0,S.jsx)(`option`,{value:e,children:Ie(e)},e))})]}),(0,S.jsxs)(`label`,{children:[`Желаемое состояние`,(0,S.jsxs)(`select`,{value:Fi.desiredState,onChange:e=>Ii({...Fi,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:Fi.runtimeMode,onChange:e=>Ii({...Fi,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:Fi.version,onChange:e=>Ii({...Fi,version:e.target.value})})]})]}),(0,S.jsxs)(`label`,{children:[`Config JSON`,(0,S.jsx)(`textarea`,{value:Fi.configJson,onChange:e=>Ii({...Fi,configJson:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Environment JSON`,(0,S.jsx)(`textarea`,{value:Fi.environmentJson,onChange:e=>Ii({...Fi,environmentJson:e.target.value})})]}),(0,S.jsx)(`button`,{className:`primary`,disabled:!Fi.nodeId||!T,onClick:()=>void Y(()=>q.setDesiredWorkload(T,Fi.nodeId,Fi.serviceType,{desiredState:Fi.desiredState,runtimeMode:Fi.runtimeMode,version:Fi.version,config:Me(Fi.configJson,`config сервиса`),environment:Me(Fi.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}),(dt[e.id]||[]).length===0?(0,S.jsx)(`p`,{className:`muted`,children:`Статус пока не получен.`}):(0,S.jsx)(M,{columns:[`сервис`,`состояние`,`runtime`,`наблюдение`],rows:(dt[e.id]||[]).map(e=>[e.service_type,e.reported_state,e.runtime_mode,z(e.observed_at)])})]},e.id))})]})]}),w===`fabric`&&(0,S.jsxs)(`section`,{className:`grid two`,children:[(0,S.jsxs)(`article`,{className:`card span2`,children:[(0,S.jsx)(`h3`,{children:`Граница подготовки Fabric`}),(0,S.jsx)(`p`,{className:`muted`,children:"Этот экран показывает synthetic/control-plane подготовку и C17Z11 boundary: production forwarding доступен только для route-bound `fabric.control` при явном gate. Service traffic, RDP, VPN и произвольный relay здесь не включены."}),(0,S.jsxs)(`div`,{className:`signalStrip`,children:[(0,S.jsx)(O,{label:`Synthetic configs`,value:`${Za}/${j.length}`}),(0,S.jsx)(O,{label:`Routes`,value:String(Qa)}),(0,S.jsx)(O,{label:`Endpoints / candidates`,value:`${$a}/${eo}`}),(0,S.jsx)(O,{label:`Peer dir / seeds`,value:`${to}/${no}`}),(0,S.jsx)(O,{label:`Scoped production flag`,value:X===0?`false`:`true:${X}`})]})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:J.fabricEntryPoints}),(0,S.jsx)(`p`,{className:`muted`,children:J.fabricEntryPointHelp})]}),(0,S.jsx)(`span`,{className:`pill`,children:In.length})]}),(0,S.jsxs)(je,{children:[(0,S.jsxs)(`label`,{children:[J.endpointName,(0,S.jsx)(`input`,{value:Hr.name,onChange:e=>Ur({...Hr,name:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[J.endpointType,(0,S.jsxs)(`select`,{value:Hr.endpointType,onChange:e=>Ur({...Hr,endpointType:e.target.value}),children:[(0,S.jsx)(`option`,{value:`client_access`,children:`client_access`}),(0,S.jsx)(`option`,{value:`admin`,children:`admin`}),(0,S.jsx)(`option`,{value:`api`,children:`api`}),(0,S.jsx)(`option`,{value:`other`,children:`other`})]})]}),(0,S.jsxs)(`label`,{className:`span2`,children:[J.publicEndpoint,(0,S.jsx)(`input`,{placeholder:`wss://entry.example.com`,value:Hr.publicEndpoint,onChange:e=>Ur({...Hr,publicEndpoint:e.target.value})})]})]}),(0,S.jsx)(`div`,{className:`actions`,children:(0,S.jsx)(`button`,{className:`primary`,disabled:!Hr.name.trim(),onClick:()=>void Y(async()=>{await q.createFabricEntryPoint(T,{name:Hr.name,endpointType:Hr.endpointType,publicEndpoint:Hr.publicEndpoint||null}),Ur({name:``,endpointType:`client_access`,publicEndpoint:``})},`Точка входа создана.`),children:J.createEntryPoint})}),(0,S.jsxs)(`div`,{className:`stack`,children:[In.map(e=>{let t=Rn[e.id]||[];return(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h4`,{children:e.name}),(0,S.jsxs)(`p`,{className:`muted`,children:[e.endpoint_type,` · `,e.public_endpoint||J.addressNotSet]})]}),(0,S.jsx)(k,{value:e.status})]}),(0,S.jsx)(`h5`,{children:J.endpointNodes}),t.length===0?(0,S.jsx)(`p`,{className:`muted`,children:J.assignedNodesEmpty}):(0,S.jsx)(`div`,{className:`membershipList`,children:t.map(t=>(0,S.jsxs)(`span`,{className:t.status===`active`?`pill good`:`pill`,children:[P(j,t.node_id),` · `,V(t.status),` · p`,t.priority]},`${e.id}-${t.node_id}`))}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsxs)(`select`,{value:Ei[e.id]||``,onChange:t=>Di({...Ei,[e.id]:t.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:J.selectNode}),j.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))]}),(0,S.jsx)(`button`,{disabled:!Ei[e.id],onClick:()=>void Y(()=>q.setFabricEntryPointNode(T,e.id,Ei[e.id],{status:`active`}),`Узел назначен точке входа.`),children:J.assignEndpointNode})]})]},e.id)}),In.length===0&&(0,S.jsx)(me,{title:J.fabricEntryPoints,text:J.entryPointsEmpty})]})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h3`,{children:J.fabricEgressPools}),(0,S.jsx)(`p`,{className:`muted`,children:J.fabricEgressPoolHelp})]}),(0,S.jsx)(`span`,{className:`pill`,children:Bn.length})]}),(0,S.jsxs)(je,{children:[(0,S.jsxs)(`label`,{children:[J.endpointName,(0,S.jsx)(`input`,{value:Wr.name,onChange:e=>Gr({...Wr,name:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[J.description,(0,S.jsx)(`input`,{value:Wr.description,onChange:e=>Gr({...Wr,description:e.target.value})})]}),(0,S.jsxs)(`label`,{className:`span2`,children:[J.routeScope,(0,S.jsx)(`textarea`,{rows:5,value:Wr.routeScope,onChange:e=>Gr({...Wr,routeScope:e.target.value})})]})]}),(0,S.jsx)(`div`,{className:`actions`,children:(0,S.jsx)(`button`,{className:`primary`,disabled:!Wr.name.trim(),onClick:()=>void Y(async()=>{let e=Me(Wr.routeScope,`Route scope JSON`);await q.createFabricEgressPool(T,{name:Wr.name,description:Wr.description||null,routeScope:e}),Gr({name:``,description:``,routeScope:`{ - "routes": [] -}`})},`Выходная зона создана.`),children:J.createEgressPool})}),(0,S.jsxs)(`div`,{className:`stack`,children:[Bn.map(e=>{let t=Hn[e.id]||[];return(0,S.jsxs)(`section`,{className:`nodePanel`,children:[(0,S.jsxs)(`div`,{className:`cardHead`,children:[(0,S.jsxs)(`div`,{children:[(0,S.jsx)(`h4`,{children:e.name}),(0,S.jsx)(`p`,{className:`muted`,children:e.description||J.descriptionNotSet})]}),(0,S.jsx)(k,{value:e.status})]}),(0,S.jsx)(`p`,{className:`muted`,children:JSON.stringify(e.route_scope||{})}),(0,S.jsx)(`h5`,{children:J.endpointNodes}),t.length===0?(0,S.jsx)(`p`,{className:`muted`,children:J.assignedNodesEmpty}):(0,S.jsx)(`div`,{className:`membershipList`,children:t.map(t=>(0,S.jsxs)(`span`,{className:t.status===`active`?`pill good`:`pill`,children:[P(j,t.node_id),` · `,V(t.status),` · p`,t.priority]},`${e.id}-${t.node_id}`))}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsxs)(`select`,{value:Oi[e.id]||``,onChange:t=>ki({...Oi,[e.id]:t.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:J.selectNode}),j.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))]}),(0,S.jsx)(`button`,{disabled:!Oi[e.id],onClick:()=>void Y(()=>q.setFabricEgressPoolNode(T,e.id,Oi[e.id],{status:`active`}),`Узел назначен выходной зоне.`),children:J.assignEndpointNode})]})]},e.id)}),Bn.length===0&&(0,S.jsx)(me,{title:J.fabricEgressPools,text:J.egressPoolsEmpty})]})]}),(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.fabricMap}),(0,S.jsx)(`p`,{className:`muted`,children:`Визуальный слой показывает, какие узлы живы, какие сервисы на них назначены и какие тестовые наблюдения связей проходят между ними.`})]}),(0,S.jsx)(k,{value:Ya?.synthetic_links_enabled?`enabled`:`disabled`})]}),(0,S.jsx)(xe,{nodes:j,links:zt,syntheticMeshConfigsByNode:Ut,entryPoints:In,entryPointNodesById:Rn,egressPools:Bn,egressPoolNodesById:Hn,rolesByNode:ot,workloadsByNode:dt,telemetryByNode:Lt,labels:J,emptyText:J.noLinks})]}),(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:X===0?`pill good`:`pill bad`,children:[`production_forwarding=`,X===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=Ut[e.id];return[e.name,t?t.enabled?`enabled`:`disabled`:`не загружен`,String(t?.routes.length??0),String(Object.keys(t?.peer_endpoints||{}).length),String(t?ft(t):0),String(t?.peer_directory?.length??0),String(t?.recovery_seeds?.length??0),String(t?.rendezvous_leases?.length??0),pt(t),mt(t),t?.authority_required?R(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 `,ro.length]}),(0,S.jsxs)(`span`,{className:io.length>0?`pill warn`:`pill`,children:[`expired `,io.length]}),(0,S.jsxs)(`span`,{className:`pill`,children:[`disabled `,ao.length]})]})]}),(0,S.jsx)(M,{columns:[`route`,`life`,`service`,`priority`,`source`,`destination`,`expires`,`updated`,`actions`],rows:Vt.slice(0,120).map(e=>{let t=Ke(e);return[R(e.id),(0,S.jsx)(`span`,{className:`pill ${qe(e)}`,children:t}),e.service_class,String(e.priority),Je(e.source_selector||{}),Je(e.destination_selector||{}),e.policy_expires_at?z(e.policy_expires_at):`нет`,z(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`})]})]})}),Vt.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:so.length>0?`pill bad`:`pill good`,children:[`fenced `,so.length]}),(0,S.jsxs)(`span`,{className:co.length>0?`pill warn`:`pill`,children:[`degraded `,co.length]}),(0,S.jsxs)(`span`,{className:mo.length>0?`pill warn`:`pill`,children:[`retry `,mo.length]}),(0,S.jsxs)(`span`,{className:uo.length>0?`pill warn`:`pill`,children:[`recovered `,uo.length]}),(0,S.jsxs)(`span`,{className:fo.length>0?`pill good`:`pill`,children:[`promoted `,fo.length]}),(0,S.jsxs)(`span`,{className:po.length>0?`pill bad`:`pill`,children:[`demoted `,po.length]}),(0,S.jsxs)(`span`,{className:`pill good`,children:[`healthy `,lo.length]}),(0,S.jsxs)(`span`,{className:go.length>0?`pill bad`:`pill`,children:[`no alternate `,go.length]}),(0,S.jsxs)(`span`,{className:Co.length>0?`pill warn`:`pill`,children:[`hysteresis `,Co.length]}),(0,S.jsxs)(`span`,{className:wo.length>0?`pill good`:`pill`,children:[`promoted paths `,wo.length]}),(0,S.jsxs)(`span`,{className:To.length>0?`pill bad`:`pill`,children:[`demoted paths `,To.length]}),(0,S.jsxs)(`span`,{className:(An?.fingerprint||``).length>0?`pill good`:`pill warn`,children:[`policy fp `,An?.fingerprint?R(An.fingerprint):`нет`]}),(0,S.jsxs)(`span`,{className:vo.length>yo.length?`pill warn`:`pill good`,children:[`rebuild `,yo.length,`/`,vo.length]}),(0,S.jsxs)(`span`,{className:xo.length>0?`pill warn`:`pill good`,children:[`ledger `,bo.length,`/`,qt.length]}),(0,S.jsxs)(`span`,{className:So.length>0?`pill bad`:`pill good`,children:[`guard `,So.length]}),(0,S.jsx)(`span`,{className:vn?`pill info`:`pill`,children:vn?`deep ledger`:`fast ledger`})]})]}),go.length>0&&(0,S.jsx)(`div`,{className:`noticePanel`,children:`Есть service-channel route без unfenced alternate. Для production-сервиса это означает деградацию: fabric не нашел безопасную замену и будет ждать нового маршрута или операторского решения.`}),$t&&(0,S.jsxs)(`div`,{className:`noticePanel ${$t.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 ${$t.status===`blocked`?`bad`:`good`}`,children:V($t.status)}),(0,S.jsxs)(`span`,{className:$t.missing_check_count>0?`pill bad`:`pill good`,children:[$t.passed_check_count,`/`,$t.required_check_count]}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>void ka(),disabled:Ar,children:`warm snapshots`})]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`reason`,value:V($t.reason)}),(0,S.jsx)(A,{label:`required`,value:$t.required_migration}),(0,S.jsx)(A,{label:`missing`,value:($t.missing_checks||[]).map(e=>e.check_id).slice(0,4).join(`, `)||`нет`}),(0,S.jsx)(A,{label:`action`,value:$t.recommended_operator_action||`schema is compatible`}),un&&(0,S.jsx)(A,{label:`warmup`,value:`warmed ${un.warmed_count}, fresh ${un.already_fresh_count}, missing ${un.missing_snapshot_count}, stale ${un.stale_snapshot_count}, deferred ${un.deferred_stale_count}, errors ${un.error_count}`})]})]}),an&&(0,S.jsxs)(`div`,{className:`noticePanel ${an.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 ${an.status===`degraded`?`warn`:`good`}`,children:V(an.status)}),(0,S.jsxs)(`span`,{className:an.overdue_missing_snapshot_count>0?`pill bad`:`pill good`,children:[`overdue `,an.overdue_missing_snapshot_count]}),(0,S.jsxs)(`span`,{className:an.auto_warmup_error_count>0?`pill bad`:`pill good`,children:[`auto errors `,an.auto_warmup_error_count]})]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`reason`,value:V(an.reason)}),(0,S.jsx)(A,{label:`snapshots`,value:`valid ${an.valid_snapshot_count}, missing ${an.missing_snapshot_count}, attempts ${an.recent_attempt_count}`}),(0,S.jsx)(A,{label:`auto-warmup`,value:`events ${an.auto_warmup_event_count}, warmed ${an.auto_warmup_warmed_count}, fresh ${an.auto_warmup_already_fresh_count}, latest ${z(an.latest_auto_warmup_at)}`}),(0,S.jsx)(A,{label:`guard`,value:`age ${an.min_age_seconds}s, heartbeats ${an.heartbeat_threshold}`}),(0,S.jsx)(A,{label:`action`,value:an.recommended_operator_action||`snapshot maintenance is current`})]}),(an.nodes||[]).length>0&&(0,S.jsx)(M,{columns:[`node`,`snapshots`,`heartbeat`,`auto-warmup`,`latest`],rows:(an.nodes||[]).slice(0,6).map(e=>[P(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}`,z(e.latest_auto_warmup_at||e.last_heartbeat_at)])})]}),fn&&(0,S.jsxs)(`div`,{className:`noticePanel ${fn.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 ${fn.status===`degraded`?`warn`:`good`}`,children:V(fn.status)}),(0,S.jsxs)(`span`,{className:`pill good`,children:[`active `,fn.active_count]}),(0,S.jsxs)(`span`,{className:fn.expired_count>0?`pill warn`:`pill`,children:[`expired `,fn.expired_count]}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>void Aa(),disabled:Ar,children:`cleanup`})]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`reason`,value:V(fn.reason)}),(0,S.jsx)(A,{label:`scanned`,value:`${fn.scanned_count}/${fn.window_limit}`}),(0,S.jsx)(A,{label:`deleted`,value:String(fn.deleted_expired_count||0)}),(0,S.jsx)(A,{label:`action`,value:fn.recommended_operator_action||`lease maintenance is current`})]}),(fn.leases||[]).length>0&&(0,S.jsx)(M,{columns:[`expires`,`resource`,`entry`,`exit`,`route`,`data plane`,`state`],rows:(fn.leases||[]).slice(0,8).map(e=>[z(e.expires_at),e.resource_id||R(e.channel_id),P(j,e.selected_entry_node_id||``),P(j,e.selected_exit_node_id||``),e.primary_route_id?`${R(e.primary_route_id)} / ${V(e.primary_route_status||``)}`:`backend fallback`,`${V(e.data_plane?.working_data_transport||`unknown`)} / ${V(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`:V(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:V(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 `,V(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 ${En(L.flow_health_status,L.flow_dropped)}`,children:wn(L.traffic_class_counts)}),(0,S.jsxs)(`span`,{className:`pill ${En(L.flow_health_status,L.flow_dropped)}`,children:[`flow `,V(L.flow_health_status||`healthy`)]}),(0,S.jsxs)(`span`,{className:`pill ${L.adaptive_backpressure_active?`info`:`good`}`,children:[`windows `,Tn(L.recommended_parallel_windows)]})]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`reason`,value:V(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 ${V(L.last_data_plane_mode||`unknown`)}, working ${V(L.last_working_data_transport||`unknown`)}, steady ${V(L.last_steady_state_transport||`unknown`)}, relay ${V(L.last_backend_relay_policy||`unknown`)}, flows ${V(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?`${V(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:`${V(L.flow_health_status||`healthy`)} / ${V(L.flow_health_reason||`flow_health_ready`)}, ${wn(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?V(L.adaptive_backpressure_reason||`adaptive`):`off`}, ${Tn(L.recommended_parallel_windows)}, policy ${L.adaptive_policy_fingerprint?R(L.adaptive_policy_fingerprint):`n/a`}`}),(0,S.jsx)(A,{label:`latest accepted`,value:z(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||R(e.channel_id),`${P(j,e.selected_entry_node_id||``)} -> ${P(j,e.selected_exit_node_id||``)}`,e.primary_route_id?`${R(e.primary_route_id)} / ${V(e.primary_route_status||``)}`:`backend fallback`,(0,S.jsx)(`span`,{className:`pill ${On(e.route_decision_source,e.route_decision_rebuild_status,e.route_decision_score_reasons)}`,children:e.route_decision_source?`${V(e.route_decision_source)}${e.route_decision_route_id?` ${R(e.route_decision_route_id)}`:``}${e.route_decision_replacement_route_id?` -> ${R(e.route_decision_replacement_route_id)}`:``}${e.route_decision_rebuild_status?` / ${V(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} / ${V(e.entry_node_last_working_data_transport||`unknown`)} / ${V(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 ${En(e.entry_node_flow_health_status,e.entry_node_flow_dropped)}`,children:[V(e.entry_node_flow_health_status||`healthy`),e.entry_node_flow_health_reason?` / ${V(e.entry_node_flow_health_reason)}`:``]}),(0,S.jsx)(`span`,{className:`pill ${e.entry_node_adaptive_backpressure_active?`info`:`good`}`,children:Tn(e.entry_node_recommended_parallel_windows)}),(0,S.jsxs)(`span`,{className:`pill ${En(e.entry_node_flow_health_status,e.entry_node_flow_dropped)}`,children:[wn(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?`${V(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 `:``}${V(e.remediation_action)}${e.remediation_command?.replacement_route_id?` -> ${R(e.remediation_command.replacement_route_id)}`:e.remediation_route_id?` -> ${R(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?V(e.remediation_guard_status):e.pool_policy_fingerprint?`pool policy`:`n/a`,e.remediation_guard_reason?` / ${V(e.remediation_guard_reason)}`:``]}),(0,S.jsxs)(`span`,{className:`pill ${Dn(e.remediation_execution_status)}`,children:[e.remediation_execution_status?V(e.remediation_execution_status):`n/a`,e.remediation_execution_generation?` / ${R(e.remediation_execution_generation)}`:``,e.remediation_execution_reason?` / ${V(e.remediation_execution_reason)}`:``]}),z(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=>[P(j,e.node_id)||e.node_name||R(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} / ${V(e.last_working_data_transport||`unknown`)} / ${V(e.last_backend_relay_policy||`unknown`)}${e.backend_fallback_blocked_count?` / blocked ${e.backend_fallback_blocked_count}`:``}`}),(0,S.jsxs)(`span`,{className:`pill ${En(e.flow_health_status,e.flow_dropped)}`,children:[V(e.flow_health_status||`healthy`),e.flow_health_reason?` / ${V(e.flow_health_reason)}`:``]}),(0,S.jsx)(`span`,{className:`pill ${e.adaptive_backpressure_active?`info`:`good`}`,children:Tn(e.recommended_parallel_windows)}),(0,S.jsxs)(`span`,{className:`pill ${En(e.flow_health_status,e.flow_dropped)}`,children:[wn(e.traffic_class_counts),` / flows ${e.flow_channel_count||0} / in ${e.flow_max_in_flight||0}`]}),z(e.last_accepted_at||e.observed_at)])})]}),I&&(0,S.jsxs)(`div`,{className:`noticePanel ${I.status===`blocked`?`badPanel`:I.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 ${I.status===`blocked`?`bad`:I.status===`degraded`?`warn`:`good`}`,children:V(I.status)}),(0,S.jsxs)(`span`,{className:I.active_alert_count>0?`pill bad`:`pill`,children:[`active `,I.active_alert_count]}),(0,S.jsxs)(`span`,{className:I.resurfaced_count>0?`pill bad`:`pill`,children:[`resurfaced `,I.resurfaced_count]})]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`reason`,value:V(I.reason)}),(0,S.jsx)(A,{label:`blocking`,value:(I.blocking_reasons||[]).map(V).join(`, `)||`нет`}),(0,S.jsx)(A,{label:`degraded`,value:(I.degraded_reasons||[]).map(V).join(`, `)||`нет`}),(0,S.jsx)(A,{label:`missing/post`,value:`transition ${I.missing_transition_count}, route-gen ${I.missing_route_generation_count}, traffic ${I.missing_post_rebuild_traffic_count}`})]})]}),hn.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:hn.length})]}),(0,S.jsx)(M,{columns:[`last`,`source`,`reporter`,`route`,`service`,`guard`,`count`,`replacement`,`action`],rows:hn.slice(0,10).map(e=>[z(e.last_seen_at),e.incident_source?V(e.incident_source):`ledger`,P(j,e.reporter_node_id),R(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:[V(e.guard_status),e.alert_silenced?` / silenced`:e.alert_resurfaced?` / resurfaced`:``]}),String(e.attempt_count),e.latest_replacement_route_id?R(e.latest_replacement_route_id):`нет`,(0,S.jsxs)(`div`,{className:`inlineActions`,children:[(0,S.jsxs)(`span`,{children:[V(e.recommended_operator_action||`inspect`),e.alert_resurfaced&&e.alert_resurfaced_cause?` (${V(e.alert_resurfaced_cause)})`:``,e.alert_resurfaced&&e.alert_resurfaced_previous_generation?` from ${R(e.alert_resurfaced_previous_generation)} until ${z(e.alert_resurfaced_previous_until)}`:``]}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>ja(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(()=>Ia(e),`Rebuild incident silenced for 6 hours.`),children:`silence 6h`})]})])})]}),(Mn||Sa.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:br?.total_count||Sa.length}),(0,S.jsxs)(`span`,{className:`pill good`,children:[`linked `,br?.correlated_count||0]}),(0,S.jsxs)(`span`,{className:(br?.not_visible_count||0)>0?`pill warn`:`pill`,children:[`not visible `,br?.not_visible_count||0]}),Object.entries(br?.counts_by_breadcrumb_status||{}).map(([e,t])=>(0,S.jsxs)(`span`,{className:e===`current`?`pill good`:e===`stale`?`pill warn`:`pill bad`,children:[V(e),` `,t]},e)),Object.entries(br?.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:[V(e),` `,t]},e))]})]}),Mn&&(0,S.jsxs)(`div`,{className:`inlineForm`,children:[(0,S.jsxs)(`label`,{children:[`current window, sec`,(0,S.jsx)(`input`,{type:`number`,min:`60`,value:Jr.currentWindowSeconds,onChange:e=>Yr(t=>({...t,currentWindowSeconds:e.target.value}))})]}),(0,S.jsxs)(`label`,{children:[`history window, sec`,(0,S.jsx)(`input`,{type:`number`,min:`60`,value:Jr.historyWindowSeconds,onChange:e=>Yr(t=>({...t,historyWindowSeconds:e.target.value}))})]}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(async()=>{Nn(await q.updateFabricServiceChannelBreadcrumbWindowPolicy(T,{currentWindowSeconds:Number(Jr.currentWindowSeconds),historyWindowSeconds:Number(Jr.historyWindowSeconds)}));let e=await q.getFabricServiceChannelRebuildInvestigationBreadcrumbs(T,{limit:20});yr(e.events),xr(e.summary||null)},`Breadcrumb window policy updated.`),children:`apply windows`}),(0,S.jsxs)(`span`,{className:`muted`,children:[`source `,Mn.source,`, fp `,R(Mn.fingerprint||``)]})]}),br&&(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`latest`,value:z(br.latest_at)}),(0,S.jsx)(A,{label:`windows`,value:`${Mn?.current_window_seconds||`n/a`}s current / ${Mn?.history_window_seconds||`n/a`}s history`}),(0,S.jsx)(A,{label:`sources`,value:Object.entries(br.counts_by_feedback_source||{}).slice(0,3).map(([e,t])=>`${V(e)} ${t}`).join(`, `)||`нет`}),(0,S.jsx)(A,{label:`violations`,value:Object.entries(br.counts_by_feedback_violation_status||{}).slice(0,3).map(([e,t])=>`${V(e)} ${t}`).join(`, `)||`нет`})]}),(0,S.jsx)(M,{columns:[`time`,`freshness`,`source`,`feedback`,`target`,`current`,`actor`,`reason`],rows:Sa.map(e=>{let t=at(e.payload)||{},n=N(t,`feedback_channel_id`,``),r=N(t,`feedback_violation_status`,``),i=N(t,`feedback_source`,``),a=N(t,`reporter_node_id`,``),o=N(t,`route_id`,``),s=N(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||Na(e),f=e.correlation_hints?.rebuild_incident||Pa(e);return[z(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:V(l)}),(0,S.jsx)(`span`,{className:`muted`,children:Sn(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:V(s||e.target_type)})]}),i||n||r?(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{children:V(i||`feedback`)}),(0,S.jsx)(`span`,{className:`muted`,children:n?`ch ${R(n)}`:`any channel`}),(0,S.jsx)(`span`,{className:`muted`,children:V(r||`any violation`)})]}):(0,S.jsx)(`span`,{className:`muted`,children:`нет`}),(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{children:a?P(j,a):`any reporter`}),(0,S.jsx)(`span`,{className:`muted`,children:o?R(o):e.target_id?R(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:V(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(()=>Fa(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:V(c||`incident_visible`)}),(0,S.jsx)(`span`,{className:`muted`,children:V(f.guard_status)}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>ja(f),`Deep rebuild investigation opened for current incident.`),children:`open`})]}):(0,S.jsx)(`span`,{className:`muted`,children:V(c||`not_visible`)}),e.actor_user_id?R(e.actor_user_id):`system`,N(t,`reason`,`operator opened investigation`)]})})]}),Xt.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:Xt.length})]}),(0,S.jsx)(M,{columns:[`until`,`source`,`channel`,`reporter`,`route`,`guard`,`reason`,`action`],rows:Xt.slice(0,10).map(e=>[z(e.expires_at),e.incident_source?V(e.incident_source):`ledger`,e.channel_id?R(e.channel_id):`нет`,P(j,e.reporter_node_id),R(e.display_route_id||e.route_id),V(e.guard_status),e.reason||`acknowledged`,(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>La(e),`Rebuild alert silence removed.`),children:`unsilence`})])})]}),F&&(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:[`Сводка по последним `,F.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 `,F.good_count]}),(0,S.jsxs)(`span`,{className:F.active_warn_count>0?`pill warn`:`pill`,children:[`warn `,F.active_warn_count,`/`,F.warn_count]}),(0,S.jsxs)(`span`,{className:F.active_bad_count>0?`pill bad`:`pill`,children:[`bad `,F.active_bad_count,`/`,F.bad_count]}),(0,S.jsxs)(`span`,{className:F.resurfaced_count>0?`pill bad`:`pill`,children:[`resurfaced `,F.resurfaced_count]}),(0,S.jsxs)(`span`,{className:F.silenced_count>0?`pill info`:`pill`,children:[`silenced `,F.silenced_count]}),(0,S.jsxs)(`span`,{className:`pill`,children:[`applied `,F.applied_count]}),(0,S.jsxs)(`span`,{className:F.access_no_safe_count?`pill bad`:F.access_route_decision_count?`pill info`:`pill`,children:[`access `,F.access_route_decision_count||0,F.access_no_safe_count?` / no-safe ${F.access_no_safe_count}`:``]})]})]}),(0,S.jsxs)(`div`,{className:`stateList`,children:[(0,S.jsx)(A,{label:`observed`,value:z(F.observed_at)}),(0,S.jsx)(A,{label:`affected nodes`,value:(F.affected_reporter_node_ids||[]).map(e=>P(j,e)).join(`, `)||`нет`}),(0,S.jsx)(A,{label:`affected routes`,value:(F.affected_route_ids||[]).map(R).join(`, `)||`нет`}),(0,S.jsx)(A,{label:`action`,value:V(F.recommended_operator_action||`no_operator_action_required`)})]}),(F.feedback_breakdowns||[]).length>0&&(0,S.jsx)(M,{columns:[`feedback`,`active`,`total`,`affected`,`incidents`,`latest`,`action`],rows:(F.feedback_breakdowns||[]).slice(0,8).map(e=>{let t=Ma(e);return[(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{children:V(e.feedback_source||`feedback`)}),(0,S.jsx)(`span`,{className:`muted`,children:e.feedback_channel_id?`ch ${R(e.feedback_channel_id)}`:`any channel`}),(0,S.jsx)(`span`,{className:`muted`,children:V(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=>P(j,e)).join(`, `)||`нет узлов`}),(0,S.jsx)(`span`,{className:`muted`,children:(e.affected_route_ids||[]).map(R).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=>V(e.guard_status)).join(`, `)})]}):(0,S.jsx)(`span`,{className:`muted`,children:`нет`}),z(e.latest_observed_at),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(()=>Fa(e),`Rebuild ledger opened for feedback breakdown.`),children:`open ledger`})]})}),(F.most_recent_bad_attempts||[]).length>0&&(0,S.jsx)(M,{columns:[`time`,`reporter`,`route`,`guard`,`reason`],rows:(F.most_recent_bad_attempts||[]).slice(0,5).map(e=>[z(e.updated_at),P(j,e.reporter_node_id),R(e.route_id),(0,S.jsx)(`span`,{className:`pill bad`,children:V(e.guard_status||`bad`)}),(0,S.jsxs)(`div`,{className:`inlineActions`,children:[(0,S.jsx)(`span`,{children:V(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`})]})])}),(F.resurfaced_attempts||[]).length>0&&(0,S.jsx)(M,{columns:[`time`,`reporter`,`route`,`guard`,`previous`,`action`],rows:(F.resurfaced_attempts||[]).slice(0,5).map(e=>[z(e.updated_at),P(j,e.reporter_node_id),R(e.route_id),(0,S.jsx)(`span`,{className:`pill bad`,children:V(e.guard_status||`bad`)}),`${R(e.alert_resurfaced_previous_generation)} until ${z(e.alert_resurfaced_previous_until)}`,(0,S.jsxs)(`div`,{className:`inlineActions`,children:[(0,S.jsx)(`span`,{children:V(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`})]})])})]}),An&&(0,S.jsxs)(`div`,{className:`inlineForm`,children:[(0,S.jsxs)(`label`,{children:[`penalty`,(0,S.jsx)(`input`,{type:`number`,min:`0`,value:Kr.hysteresisPenalty,onChange:e=>qr(t=>({...t,hysteresisPenalty:e.target.value}))})]}),(0,S.jsxs)(`label`,{children:[`promote samples`,(0,S.jsx)(`input`,{type:`number`,min:`1`,value:Kr.promotionMinSamples,onChange:e=>qr(t=>({...t,promotionMinSamples:e.target.value}))})]}),(0,S.jsxs)(`label`,{children:[`fail`,(0,S.jsx)(`input`,{type:`number`,min:`1`,value:Kr.demotionFailureThreshold,onChange:e=>qr(t=>({...t,demotionFailureThreshold:e.target.value}))})]}),(0,S.jsxs)(`label`,{children:[`drop`,(0,S.jsx)(`input`,{type:`number`,min:`1`,value:Kr.demotionDropThreshold,onChange:e=>qr(t=>({...t,demotionDropThreshold:e.target.value}))})]}),(0,S.jsxs)(`label`,{children:[`slow`,(0,S.jsx)(`input`,{type:`number`,min:`1`,value:Kr.demotionSlowThreshold,onChange:e=>qr(t=>({...t,demotionSlowThreshold:e.target.value}))})]}),(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:Kr.demotionRebuildEnabled,onChange:e=>qr(t=>({...t,demotionRebuildEnabled:e.target.checked}))}),`rebuild`]}),(0,S.jsxs)(`label`,{className:`checkLine`,children:[(0,S.jsx)(`input`,{type:`checkbox`,checked:Kr.demotionFencedEnabled,onChange:e=>qr(t=>({...t,demotionFencedEnabled:e.target.checked}))}),`fenced`]}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Y(async()=>{jn(await q.updateFabricServiceChannelRecoveryPolicy(T,{hysteresisPenalty:Number(Kr.hysteresisPenalty),promotionMinSamples:Number(Kr.promotionMinSamples),demotionFailureThreshold:Number(Kr.demotionFailureThreshold),demotionDropThreshold:Number(Kr.demotionDropThreshold),demotionSlowThreshold:Number(Kr.demotionSlowThreshold),demotionRebuildEnabled:Kr.demotionRebuildEnabled,demotionFencedEnabled:Kr.demotionFencedEnabled}))},`Recovery policy updated.`),children:`apply policy`}),(0,S.jsxs)(`span`,{className:`muted`,children:[`source `,An.source]})]}),(0,S.jsx)(M,{columns:[`route`,`reporter`,`service`,`status`,`recovery`,`score`,`reasons`,`failures`,`retry/cooldown`,`expires`,`action`],rows:oo.slice(0,80).map(e=>[R(e.route_id),P(j,e.reporter_node_id),e.service_class,(0,S.jsx)(`span`,{className:`pill ${We(e.feedback_status)}`,children:V(e.feedback_status)}),e.recovery_state?(0,S.jsxs)(`span`,{className:`pill ${Ge(e.recovery_state)}`,children:[e.recovery_demoted?`demoted ${e.recovery_reason?V(e.recovery_reason):``}`:e.recovery_promoted?`promoted`:V(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:V(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?z(e.retry_cooldown_until):`нет`,z(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`})])}),oo.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:[..._o,...go,...vo].filter((e,t,n)=>n.findIndex(t=>t.decision_id===e.decision_id)===t).slice(0,80).map(e=>[P(j,e.local_node_id),R(e.route_id),e.replacement_route_id?R(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:V(e.feedback_source||`feedback`)}),(0,S.jsxs)(`span`,{className:`muted`,children:[R(e.feedback_observation_id),` `,e.feedback_channel_id?`ch ${R(e.feedback_channel_id)}`:``]}),(0,S.jsx)(`span`,{className:`muted`,children:V(e.feedback_violation_status||``)})]}):`нет`,P(j,e.source_node_id),P(j,e.destination_node_id),e.decision_source,e.path_score==null?`н/д`:String(e.path_score),z(e.expires_at)])}),(0,S.jsxs)(`div`,{className:`inlineForm`,children:[(0,S.jsxs)(`label`,{children:[`reporter`,(0,S.jsxs)(`select`,{value:B.reporterNodeId,onChange:e=>kn(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:B.routeId,onChange:e=>kn(t=>({...t,routeId:e.target.value.trim(),offset:0})),placeholder:`route id`})]}),(0,S.jsxs)(`label`,{children:[`generation`,(0,S.jsx)(`input`,{value:B.generation,onChange:e=>kn(t=>({...t,generation:e.target.value.trim(),offset:0})),placeholder:`route generation`})]}),(0,S.jsxs)(`label`,{children:[`service`,(0,S.jsx)(`input`,{value:B.serviceClass,onChange:e=>kn(t=>({...t,serviceClass:e.target.value.trim(),offset:0})),placeholder:`vpn_packets`})]}),(0,S.jsxs)(`label`,{children:[`feedback source`,(0,S.jsx)(`input`,{value:B.feedbackSource,onChange:e=>kn(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:B.feedbackChannelId,onChange:e=>kn(t=>({...t,feedbackChannelId:e.target.value.trim(),offset:0})),placeholder:`feedback channel id`})]}),(0,S.jsxs)(`label`,{children:[`violation`,(0,S.jsx)(`input`,{value:B.feedbackViolationStatus,onChange:e=>kn(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 Da(vn,{...B,offset:0}),children:`apply`}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>{let e={...re};kn(e),Da(!1,e)},children:`clear`})]}),(0,S.jsx)(M,{columns:[`time`,`reporter`,`route`,`replacement`,`feedback`,`guard`,`outcome`,`backend`,`agent`,`route-gen`,`traffic`,`policy`,`hops`],rows:qt.slice(0,80).map(e=>[z(e.updated_at),P(j,e.reporter_node_id),R(e.route_id),e.replacement_route_id?R(e.replacement_route_id):`нет`,e.feedback_observation_id?(0,S.jsxs)(`div`,{className:`stackedText`,children:[(0,S.jsx)(`span`,{children:V(e.feedback_source||`feedback`)}),(0,S.jsxs)(`span`,{className:`muted`,children:[R(e.feedback_observation_id),` `,e.feedback_channel_id?`ch ${R(e.feedback_channel_id)}`:``]}),(0,S.jsx)(`span`,{className:`muted`,children:V(e.feedback_violation_status||``)})]}):e.feedback_status?V(e.feedback_status):`нет`,vn?(0,S.jsxs)(`span`,{className:`pill ${e.guard_severity===`bad`?`bad`:e.guard_severity===`warn`?`warn`:`good`}`,children:[V(e.guard_status||`unknown`),e.alert_silenced?` / silenced`:e.alert_resurfaced?` / resurfaced`:``]}):(0,S.jsx)(`span`,{className:`pill info`,children:`summary`}),V(e.outcome),(0,S.jsx)(`span`,{className:`pill ${e.rebuild_status===`applied`?`good`:`warn`}`,children:V(e.rebuild_status)}),vn?e.node_transition_matched?(0,S.jsx)(`span`,{className:`pill ${e.node_transition_status===`applied_rebuild`?`good`:`warn`}`,children:V(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`}),vn?e.node_route_generation_matched?(0,S.jsx)(`span`,{className:`pill good`,children:V(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`}),vn?e.post_rebuild_selected_route_id?`${R(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?R(e.policy_fingerprint):`нет`,`${(e.old_hops||[]).map(e=>P(j,e)).join(` -> `)||`нет`} => ${(e.replacement_hops||[]).map(e=>P(j,e)).join(` -> `)||`нет`}`])}),(0,S.jsxs)(`div`,{className:`inlineActions`,children:[(0,S.jsx)(`button`,{type:`button`,className:`ghost`,onClick:()=>void Da(!vn,{...B,offset:0}),children:vn?`fast ledger`:`deep ledger`}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,disabled:!vn||B.offset<=0,onClick:()=>void Da(!0,{...B,offset:Math.max(0,B.offset-20)}),children:`prev`}),(0,S.jsx)(`button`,{type:`button`,className:`ghost`,disabled:!vn||qt.length<20,onClick:()=>void Da(!0,{...B,offset:B.offset+20}),children:`next`}),(0,S.jsxs)(`span`,{className:`pill`,children:[`offset `,vn?B.offset:0]}),(0,S.jsx)(`span`,{className:`muted`,children:`Deep ledger correlates heartbeat timeline and can be slower; default refresh stays fast.`})]}),qt.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=ct(e,Et[e.id]||[],zt);return[e.name,(0,S.jsx)(ye,{runtime:t}),t.address,e.health_status,Pe(ot[e.id]||[]),Le(dt[e.id]||[]),z((Et[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:Qe(zt).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:P(j,e.source_node_id),heartbeatsByNode:Et,meshLinks:zt}),(0,S.jsx)(be,{node:n,fallback:P(j,e.target_node_id),heartbeatsByNode:Et,meshLinks:zt}),et(e),tt(e,j),e.link_status,e.latency_ms==null?`н/д`:`${e.latency_ms} мс`,e.quality_score==null?`н/д`:`${e.quality_score}/100`,z(e.observed_at)]})})]}),(0,S.jsxs)(`article`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Политики QoS`}),(0,S.jsx)(M,{columns:[`класс`,`приоритет`,`надежность`,`политика сброса`],rows:Pn.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:G.organizationId,onChange:e=>Li({...G,organizationId:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Название`,(0,S.jsx)(`input`,{value:G.name,onChange:e=>Li({...G,name:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Протокол`,(0,S.jsxs)(`select`,{value:G.protocolFamily,onChange:e=>Li({...G,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:G.desiredState,onChange:e=>Li({...G,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:G.credentialRef,onChange:e=>Li({...G,credentialRef:e.target.value})})]})]}),(0,S.jsxs)(`label`,{children:[`Целевой endpoint JSON`,(0,S.jsx)(`textarea`,{value:G.targetEndpointJson,onChange:e=>Li({...G,targetEndpointJson:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Политика разрешенных узлов JSON`,(0,S.jsx)(`textarea`,{value:G.allowedNodePolicyJson,onChange:e=>Li({...G,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:G.routingUsageJson,onChange:e=>Li({...G,routingUsageJson:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Политика маршрута JSON`,(0,S.jsx)(`textarea`,{value:G.routePolicyJson,onChange:e=>Li({...G,routePolicyJson:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Политика QoS JSON`,(0,S.jsx)(`textarea`,{value:G.qosPolicyJson,onChange:e=>Li({...G,qosPolicyJson:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Политика размещения JSON`,(0,S.jsx)(`textarea`,{value:G.placementPolicyJson,onChange:e=>Li({...G,placementPolicyJson:e.target.value})})]})]}),(0,S.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:Me(G.targetEndpointJson,`target endpoint`),allowedNodePolicy:Me(G.allowedNodePolicyJson,`allowed node policy`),routingUsage:Ne(G.routingUsageJson,`routing usage`),routePolicy:Me(G.routePolicyJson,`route policy`),qosPolicy:Me(G.qosPolicyJson,`qos policy`),placementPolicy:Me(G.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()=>{Fr(`Истекшие VPN lease: ${(await q.expireStaleVPNLeases(T)).length}.`)},`Stale VPN lease проверены.`),children:`Проверить stale lease`}),(0,S.jsx)(`button`,{onClick:()=>void za(),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=>$n(e.target.value),onBlur:()=>localStorage.setItem(C.vpnDiagnosticDeviceId,Qn.trim())})]}),er.length>0&&(0,S.jsxs)(`label`,{children:[`Найденные клиенты`,(0,S.jsx)(`select`,{value:Qn,onChange:e=>{let t=e.target.value;$n(t),localStorage.setItem(C.vpnDiagnosticDeviceId,t),rr(er.find(e=>e.device_id===t)||null)},children:er.map(e=>{let t=at(e.payload)||{};return(0,S.jsxs)(`option`,{value:e.device_id,children:[R(e.device_id),` / `,N(t,`app_version`,`н/д`),` / `,z(e.observed_at)]},e.device_id)})})]})]}),(0,S.jsxs)(`div`,{className:`diagnosticCommandPanel`,children:[(0,S.jsxs)(`label`,{children:[`URL для теста`,(0,S.jsx)(`input`,{value:ir,onChange:e=>ar(e.target.value)})]}),(0,S.jsxs)(`div`,{className:`actions compactActions`,children:[(0,S.jsx)(`button`,{onClick:()=>void Ba({type:`refresh_profile`},`Профиль`),children:`Обновить профиль`}),(0,S.jsx)(`button`,{onClick:()=>void Ba({type:`start_vpn`},`VPN`),children:`Старт VPN`}),(0,S.jsx)(`button`,{onClick:()=>void Ba({type:`stop_vpn`},`VPN`),children:`Стоп VPN`}),(0,S.jsx)(`button`,{onClick:()=>void Ba({type:`vpn_stats`},`Stats`),children:`Stats`}),(0,S.jsx)(`button`,{onClick:()=>void Ba({type:`vpn_http_get`,url:ir},`VPN HTTP`),children:`VPN HTTP`}),(0,S.jsx)(`button`,{onClick:()=>void Ba({type:`open_url`,url:ir},`Открыть URL`),children:`Открыть URL`}),(0,S.jsx)(`button`,{className:`primary`,onClick:()=>void Ba({type:`full_vpn_test`,url:ir,watch_seconds:45},`Полный VPN test`),children:`Полный тест`})]}),or&&(0,S.jsxs)(`p`,{className:`muted`,children:[`Последняя команда: `,N(or.payload,`type`,`н/д`),` / `,z(or.created_at)]})]}),he(nr),(0,S.jsxs)(`div`,{className:`stack`,children:[Kn.map(e=>{let t=at(e.metadata?.client_config),n=at(t?.vpn_fabric_route),r=St(n?.entry_pool_node_ids||e.placement_policy?.entry_node_ids),i=St(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||Jn[e.id]?.owner_node_id||e.placement_policy?.exit_node_id||i[0]||``),s=Xn[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,` / организация `,R(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:Jn[e.id]?R(Jn[e.id]?.owner_node_id):`нет`}),(0,S.jsx)(A,{label:`Fabric route`,value:`${a?P(j,a):`entry auto`} -> ${o?P(j,o):`exit auto`}`}),(0,S.jsx)(A,{label:`Entry pool`,value:r.map(e=>P(j,e)).join(`, `)||`н/д`}),(0,S.jsx)(A,{label:`Exit pool`,value:i.map(e=>P(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:He(s.client_to_gateway)}),(0,S.jsx)(A,{label:`Gateway -> client`,value:He(s.gateway_to_client)}),(0,S.jsx)(A,{label:`Обновлено`,value:z(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)}),Kn.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:cr.length})]}),(0,S.jsxs)(je,{children:[(0,S.jsxs)(`label`,{children:[`Slug`,(0,S.jsx)(`input`,{value:Ri.slug,onChange:e=>zi({...Ri,slug:e.target.value}),placeholder:`home`})]}),(0,S.jsxs)(`label`,{children:[`Название`,(0,S.jsx)(`input`,{value:Ri.name,onChange:e=>zi({...Ri,name:e.target.value}),placeholder:`HOME`})]})]}),(0,S.jsx)(`div`,{className:`actions`,children:(0,S.jsx)(`button`,{className:`primary`,disabled:!Ri.slug.trim()||!Ri.name.trim(),onClick:()=>void Y(async()=>{let e=await q.createOrganization(Ri);zi({slug:``,name:``}),Dr(e.id),Ui(t=>({...t,organizationId:e.id})),Ji(t=>({...t,organizationId:e.id}))},`Организация создана.`),children:`Создать организацию`})}),(0,S.jsx)(M,{columns:[`организация`,`slug`,`статус`,`ресурсы`,`участники`,`действие`],rows:cr.map(e=>{let t=fr.filter(t=>t.organization_id===e.id),n=mr[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()=>{Dr(e.id),kr(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:Bi.email,onChange:e=>Vi({...Bi,email:e.target.value}),placeholder:`user@example.com`})]}),(0,S.jsxs)(`label`,{children:[`Пароль`,(0,S.jsx)(`input`,{type:`password`,value:Bi.password,onChange:e=>Vi({...Bi,password:e.target.value}),placeholder:`минимум 8 символов`})]}),(0,S.jsxs)(`label`,{children:[`Роль платформы`,(0,S.jsxs)(`select`,{value:Bi.platformRole,onChange:e=>Vi({...Bi,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:!Bi.email.trim()||Bi.password.length<8,onClick:()=>void Y(async()=>{let e=await q.createUser(Bi);dr(await q.listUsers()),Vi({email:``,password:``,platformRole:`user`}),Ui(t=>({...t,userId:e.id}))},`Пользователь создан.`),children:`Создать пользователя`})}),(0,S.jsx)(M,{columns:[`пользователь`,`роль платформы`,`id`],rows:ur.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:Hi.organizationId,onChange:e=>Ui({...Hi,organizationId:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:`Выберите организацию`}),cr.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))]})]}),(0,S.jsxs)(`label`,{children:[`Пользователь`,(0,S.jsxs)(`select`,{value:Hi.userId,onChange:e=>Ui({...Hi,userId:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:`Выберите пользователя`}),ur.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.email},e.id))]})]}),(0,S.jsxs)(`label`,{children:[`Роль`,(0,S.jsxs)(`select`,{value:Hi.roleId,onChange:e=>Ui({...Hi,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:!Hi.organizationId||!Hi.userId.trim(),onClick:()=>void Y(()=>q.addOrganizationMembership(Hi.organizationId,{userId:Hi.userId,roleId:Hi.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:Er,onChange:e=>Dr(e.target.value),children:[(0,S.jsx)(`option`,{value:``,children:`Выберите организацию`}),cr.map(e=>(0,S.jsx)(`option`,{value:e.id,children:e.name},e.id))]}),(0,S.jsx)(`button`,{disabled:!Er,onClick:()=>void Y(async()=>{kr(await q.getOrganizationAdminSummary(Er))},`Сводка организации загружена.`),children:`Обновить`})]}),Or?(0,S.jsxs)(`div`,{className:`stack`,children:[(0,S.jsx)(fe,{label:`Ресурсы`,value:Or.resource_count,tone:`steel`}),(0,S.jsx)(fe,{label:`Активные сессии`,value:Or.active_session_count,tone:`green`}),(0,S.jsx)(A,{label:`Topology exposure`,value:Or.topology_exposure}),(0,S.jsx)(M,{columns:[`контур`,`состояние`],rows:Object.entries(Or.connector_status||{}).map(([e,t])=>[e,typeof t==`string`?V(t):JSON.stringify(t)])}),(0,S.jsx)(M,{columns:[`протокол`,`количество`],rows:Or.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:fr.length})]}),(0,S.jsxs)(je,{children:[(0,S.jsxs)(`label`,{children:[`Организация`,(0,S.jsxs)(`select`,{value:K.organizationId,onChange:e=>Ji({...K,organizationId:e.target.value}),children:[(0,S.jsx)(`option`,{value:``,children:`Выберите организацию`}),cr.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=>Ji({...K,name:e.target.value}),placeholder:`Office RDP`})]}),(0,S.jsxs)(`label`,{children:[`Адрес`,(0,S.jsx)(`input`,{value:K.address,onChange:e=>Ji({...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=>Ji({...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=>Ji({...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=>Ji({...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=>Ji({...K,tags:e.target.value}),placeholder:`home, accounting`})]}),(0,S.jsxs)(`label`,{children:[`RDP пользователь`,(0,S.jsx)(`input`,{value:K.username,onChange:e=>Ji({...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=>Ji({...K,password:e.target.value}),placeholder:`хранится как secret`})]}),(0,S.jsxs)(`label`,{children:[`Домен`,(0,S.jsx)(`input`,{value:K.domain,onChange:e=>Ji({...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()}),Ji({...K,name:``,address:``,tags:``,username:``,password:``,domain:``})},`Сервер добавлен в справочник.`),children:`Добавить сервер`})}),(0,S.jsx)(M,{columns:[`сервер`,`адрес`,`протокол`,`секрет`,`организация`,`маршрут`,`создано`,`действия`],rows:fr.map(e=>{let t=e.metadata||{},n=cr.find(t=>t.id===e.organization_id);return[e.name,e.address,e.protocol,e.has_secret?`сохранен`:e.secret_ref?`нужен payload`:`нет`,n?.name||R(e.organization_id),`${R(String(t.preferred_entry_node_id||``))||`auto`} -> ${R(String(t.preferred_exit_node_id||``))||`auto`}`,z(e.created_at),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>{Gi(e),qi({username:``,password:``,domain:``})},children:`Обновить secret`})]})}),Wi&&(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:[Wi.name,` · `,Wi.address]})]}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>Gi(null),children:`Закрыть`})]}),(0,S.jsxs)(je,{children:[(0,S.jsxs)(`label`,{children:[`Пользователь`,(0,S.jsx)(`input`,{value:Ki.username,onChange:e=>qi({...Ki,username:e.target.value}),placeholder:`user или DOMAIN\\\\user`})]}),(0,S.jsxs)(`label`,{children:[`Пароль`,(0,S.jsx)(`input`,{type:`password`,value:Ki.password,onChange:e=>qi({...Ki,password:e.target.value})})]}),(0,S.jsxs)(`label`,{children:[`Домен`,(0,S.jsx)(`input`,{value:Ki.domain,onChange:e=>qi({...Ki,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:!Ki.username.trim()||!Ki.password,onClick:()=>void Y(async()=>{await q.upsertResourceSecret(Wi.id,{username:Ki.username.trim(),password:Ki.password,domain:Ki.domain.trim()}),Gi(null),qi({username:``,password:``,domain:``})},`Secret ресурса обновлен.`),children:`Сохранить secret`}),(0,S.jsx)(`button`,{onClick:()=>Gi(null),children:`Отмена`})]})]})})]})}),w===`audit`&&(0,S.jsxs)(`section`,{className:`card`,children:[(0,S.jsx)(`h3`,{children:`Аудит кластера`}),(0,S.jsx)(M,{columns:[`событие`,`цель`,`actor`,`создано`],rows:gr.map(e=>[e.event_type,`${e.target_type}${e.target_id?`:${R(e.target_id)}`:``}`,e.actor_user_id?R(e.actor_user_id):`system`,z(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:V(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=at(e.payload)||{},n=at(t.runtime),r=at(t.vpn_config),i=N(t,`app_version`,`н/д`),a=N(t,`service_state`,`н/д`),o=N(t,`control_network_mode`,`н/д`),s=N(r,`packet_relay_active_base_url`)||N(r,`packet_relay_base_url`,`н/д`),c=N(r,`packet_relay_profile_base_url`,`н/д`),l=N(r,`packet_relay_candidate_urls`,`н/д`),u=lt(n,`uplink_read_total`),d=lt(n,`uplink_sent_total`),f=lt(n,`downlink_received_total`),p=lt(n,`uplink_dropped_packets`)+lt(n,`downlink_dropped_packets`),m=lt(n,`uplink_bypassed_control_packets`),h=lt(n,`downlink_received_bytes`),g=lt(n,`uplink_sent_bytes`),_=N(n,`state`,`н/д`),v=N(n,`message`,``),y=lt(n,`uplink_sent_mbps`),b=lt(n,`downlink_received_mbps`),x=N(t,`last_command_type`,`н/д`),ee=N(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 `,R(e.device_id)]}),(0,S.jsxs)(`p`,{className:`muted`,children:[i,` / `,a,` / `,z(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:`${B(g)} / ${B(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:`${B(r.memory_used_bytes)} / ${B(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:z(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=at(m?.metadata?.mesh_listener_report),v=at(m?.metadata?.mesh_endpoint_report),y=at(m?.metadata?.mesh_outbound_session_report),b=c?.mesh_listener,x=at(m?.metadata?.mesh_peer_recovery_report),ee=at(m?.metadata?.mesh_peer_connection_intent_report),C=at(m?.metadata?.mesh_peer_connection_manager_report),te=at(m?.metadata?.mesh_rendezvous_lease_report),ne=at(m?.metadata?.mesh_route_path_decision_report),re=at(m?.metadata?.mesh_route_generation_report),ie=at(m?.metadata?.mesh_route_health_config_report),w=c?.service_channel_route_feedback,ae=w?.observations||[],oe=c?.service_channel_remediation_commands||[],T=Qe(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=ot(C?.probe_results),[D,ue]=(0,_.useState)(`network`),de=ze(f,`rap-node-agent`),fe=ze(f,`rap-host-agent`),pe=f[0],me=Ye(f),he=t.find(t=>t.node.id===e.id)?.cluster.id||t[0]?.cluster.id||``,_e=ot(v?.endpoint_candidates),ye=_e[0],be=st(v,[`peer_endpoint`,`advertised_endpoint`,`endpoint`])||N(ye,`address`,``)||``,xe=st(v,[`transport`,`advertise_transport`])||N(ye,`transport`,``)||`н/д`,Se=st(v,[`connectivity_mode`,`connectivity`])||N(ye,`connectivity_mode`,``)||N(g,`inbound_reachability`,``)||`н/д`,Ce=N(v,`nat_type`,N(ye,`nat_type`,`н/д`)),we=N(v,`region`,N(g,`region`,N(ye,`region`,`н/д`))),Te=N(v,`observed_at`,N(g,`observed_at`,m?.observed_at||`н/д`)),j=N(g,`status`,``)||(be?`нет listener report, есть advertised endpoint`:`report отсутствует`),Ee=N(g,`effective_listen_addr`,``)||`н/д`,De=N(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?z(m.observed_at):`н/д`}),(0,S.jsx)(O,{label:`Health`,value:V(m?.health_status||e.health_status)}),(0,S.jsx)(O,{label:`Listener`,value:gn(m)}),(0,S.jsx)(O,{label:`Mesh links`,value:`${se.length}/${T.length}`}),(0,S.jsx)(O,{label:`Update`,value:Be(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:V(e.ownership_type)}),(0,S.jsx)(A,{label:`Owner org`,value:R(e.owner_organization_id)}),(0,S.jsx)(A,{label:`Группа`,value:e.node_group_name||p.ungroupedNodes}),(0,S.jsx)(A,{label:`Создан`,value:z(e.created_at)}),(0,S.jsx)(A,{label:`Обновлен`,value:z(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,`: `,V(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:z(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:N(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:N(g,`inbound_reachability`,Se)}),(0,S.jsx)(A,{label:`One-way`,value:N(g,`one_way_connectivity`,`н/д`)}),(0,S.jsx)(A,{label:`Port conflict`,value:N(g,`port_conflict`,`false`)}),(0,S.jsx)(A,{label:`Failure`,value:N(g,`failure_error`,N(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:N(y,`status`,`не прислан`)}),(0,S.jsx)(A,{label:`Direction`,value:N(y,`direction`,`н/д`)}),(0,S.jsx)(A,{label:`Transport`,value:N(y,`transport`,`н/д`)}),(0,S.jsx)(A,{label:`Control Plane`,value:N(y,`control_plane_url`,`н/д`)}),(0,S.jsx)(A,{label:`Reverse usable`,value:N(y,`usable_for_inbound_control`,`н/д`)}),(0,S.jsx)(A,{label:`Inbound required`,value:N(y,`inbound_listener_required`,`н/д`)}),(0,S.jsx)(A,{label:`Relay ready`,value:N(y,`peer_connection_relay_ready`,`0`)}),(0,S.jsx)(A,{label:`Waiting rendezvous`,value:N(y,`peer_connection_waiting`,`0`)}),(0,S.jsx)(A,{label:`Rendezvous leases`,value:N(y,`rendezvous_lease_count`,`0`)}),(0,S.jsx)(A,{label:`Listener conflict`,value:N(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=>[N(e,`endpoint_id`,`н/д`),N(e,`address`,`н/д`),N(e,`transport`,`н/д`),N(e,`reachability`,`н/д`),N(e,`connectivity_mode`,`н/д`),N(e,`nat_type`,`н/д`),N(e,`priority`,`н/д`),N(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])=>[P(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})=>[P(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=>[P(l,t.source_node_id===e.id?t.target_node_id:t.source_node_id),t.source_node_id===e.id?`out`:`in`,et(t),t.link_status,t.latency_ms==null?`н/д`:`${t.latency_ms}мс`,t.quality_score==null?`н/д`:String(t.quality_score),tt(t,l),z(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=>[P(l,N(e,`node_id`,``)),N(e,`link_status`,`н/д`),N(e,`selected_endpoint`,N(e,`endpoint`,`н/д`)),N(e,`selected_candidate_id`,`н/д`),N(e,`latency_ms`,`н/д`),dt(e),N(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:un(m)}),(0,S.jsx)(A,{label:`Intents`,value:dn(m)}),(0,S.jsx)(A,{label:`Manager`,value:hn(m)}),(0,S.jsx)(A,{label:`Rendezvous`,value:fn(m)}),(0,S.jsx)(A,{label:`Path decisions`,value:pn(m)}),(0,S.jsx)(A,{label:`Route generation`,value:L(m)}),(0,S.jsx)(A,{label:`Route health`,value:mn(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=>[R(e.route_id),e.replacement_route_id?R(e.replacement_route_id):`нет`,P(l,e.source_node_id),P(l,e.destination_node_id),e.effective_hops.map(e=>yn(P(l,e))).join(` > `),e.decision_source||(e.selected_relay_id?P(l,e.selected_relay_id):`direct`),e.path_score==null?`н/д`:String(e.path_score),z(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=>[R(e?.channel_id||``),(0,S.jsx)(`span`,{className:`pill warn`,children:V(e?.action||``)}),e?.primary_route_id?R(e.primary_route_id):`н/д`,e?.replacement_route_id?R(e.replacement_route_id):`н/д`,(0,S.jsx)(`span`,{className:`pill ${e?.guard_status===`rejected`?`bad`:e?.guard_status===`allowed`?`good`:``}`,children:e?.guard_status?V(e.guard_status):`н/д`}),(0,S.jsxs)(`span`,{className:`pill ${Dn(e?.execution_status)}`,children:[e?.execution_status?V(e.execution_status):`н/д`,e?.execution_reason?` / ${V(e.execution_reason)}`:``]}),e?.reason||`н/д`,e?.expires_at?z(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=>[R(e.route_id),e.service_class,(0,S.jsx)(`span`,{className:`pill ${We(e.feedback_status)}`,children:V(e.feedback_status)}),e.recovery_state?(0,S.jsxs)(`span`,{className:`pill ${Ge(e.recovery_state)}`,children:[e.recovery_demoted?`demoted ${e.recovery_reason?V(e.recovery_reason):``}`:e.recovery_promoted?`promoted`:V(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:V(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}мс`,z(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:Ie(e.role)}),(0,S.jsx)(`span`,{children:e.organization_id?`organization: ${R(e.organization_id)}`:`cluster-wide`}),(0,S.jsx)(`small`,{children:z(e.assigned_at)}),(0,S.jsx)(`span`,{className:`pill ${sn(e.role,m)}`,children:cn(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,V(e.desired_state),e.runtime_mode,e.version||`не закреплена`,z(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,V(e.reported_state),e.runtime_mode,e.version||`н/д`,z(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:`${B(h?.disk_used_bytes)} / ${B(h?.disk_total_bytes)}`}),(0,S.jsx)(A,{label:`Network RX/TX`,value:`${B(h?.network_rx_bytes)} / ${B(h?.network_tx_bytes)}`}),(0,S.jsx)(A,{label:`Payload`,value:h?.payload?ut(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||`неизвестно`,gn(e),un(e),dn(e),fn(e),pn(e),L(e),mn(e),z(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:Ve(de)}),(0,S.jsx)(A,{label:`rap-host-agent`,value:Ve(fe)}),(0,S.jsx)(A,{label:`Всего отчетов`,value:String(f.length)}),(0,S.jsx)(A,{label:`Последний отчет`,value:z(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 ${Ue(de)}`,children:[`node-agent: `,de.status]}),fe&&(0,S.jsxs)(`span`,{className:`pill ${Ue(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 ${Ue(e)}`,children:e.status}),e.attempt_id?R(e.attempt_id):`н/д`,e.error_message||`нет`,z(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:Jt()}),(0,S.jsx)(A,{label:`Join-token`,value:`не нужен для repair существующего узла`})]}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{className:`primary`,onClick:()=>Vt(Rt(e),Lt(e,he)),children:`Скачать repair .cmd`}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>void Ht(Lt(e,he)),children:`Скопировать команду`})]}),(0,S.jsx)(`pre`,{className:`codePreview`,children:Lt(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:Jt()}),(0,S.jsx)(A,{label:`Join-token`,value:`не нужен для repair существующего узла`})]}),(0,S.jsxs)(`div`,{className:`actions`,children:[(0,S.jsx)(`button`,{className:`primary`,onClick:()=>Vt(Bt(e),zt(e,he)),children:`Скачать repair .sh`}),(0,S.jsx)(`button`,{className:`ghost`,onClick:()=>void Ht(zt(e,he)),children:`Скопировать команду`})]}),(0,S.jsx)(`pre`,{className:`codePreview`,children:zt(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=ct(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,syntheticMeshConfigsByNode:n,entryPoints:r,entryPointNodesById:i,egressPools:a,egressPoolNodesById:o,rolesByNode:s,workloadsByNode:c,telemetryByNode:l,labels:u,emptyText:d}){if(e.length===0)return(0,S.jsx)(me,{title:`Нет узлов`,text:`Одобренные node-agent появятся на карте после первого heartbeat.`});let f=Qe(t).filter(e=>e.source_node_id!==e.target_node_id),p=new Map(e.map(e=>[e.id,e])),m=f.map(e=>({link:e,status:Ce(e,f,p)})),h=m.filter(e=>e.status===`reachable`),g=m.filter(e=>e.status===`one_way`),_=m.filter(e=>e.status===`stale`),v=m.filter(e=>e.status!==`reachable`&&e.status!==`one_way`&&e.status!==`stale`),y=Ae(e,n),b=j(e.length,r.length,a.length),x=Ee(e.length),ee=Oe(e,b.height,x),C=new Map(r.map((e,t)=>[e.id,Te(170,t,r.length,150,b.height-100)])),te=new Map(a.map((e,t)=>[e.id,Te(950,t,a.length,150,b.height-100)])),ne=new Set(m.filter(e=>e.status!==`stale`).map(e=>`${e.link.source_node_id}->${e.link.target_node_id}`)),re=new Map(e.map(e=>[e.id,Se(e.id,m)])),ie=y.filter(e=>!ne.has(`${e.source_node_id}->${e.target_node_id}`)),w=r.flatMap(e=>(i[e.id]||[]).filter(e=>e.status!==`disabled`).map(t=>({entryPoint:e,assignment:t}))),ae=a.flatMap(e=>(o[e.id]||[]).filter(e=>e.status!==`disabled`).map(t=>({pool:e,assignment:t}))),oe=w.length+ae.length;return(0,S.jsxs)(`div`,{className:`topologyShell`,children:[(0,S.jsxs)(`svg`,{className:`topologySvg`,viewBox:`0 0 ${b.width} ${b.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`})})}),(0,S.jsx)(`rect`,{x:`36`,y:`58`,width:`268`,height:b.height-100,rx:`24`,className:`topologyZone ingress`}),(0,S.jsx)(`rect`,{x:`330`,y:`58`,width:`460`,height:b.height-100,rx:`24`,className:`topologyZone core`}),(0,S.jsx)(`rect`,{x:`816`,y:`58`,width:`268`,height:b.height-100,rx:`24`,className:`topologyZone egress`}),(0,S.jsx)(`text`,{x:`170`,y:`98`,className:`topologyLayerLabel`,children:u.fabricIngressLayer}),(0,S.jsx)(`text`,{x:`560`,y:`98`,className:`topologyLayerLabel`,children:u.fabricNodeLayer}),(0,S.jsx)(`text`,{x:`950`,y:`98`,className:`topologyLayerLabel`,children:u.fabricEgressLayer}),w.map(({entryPoint:e,assignment:t})=>{let n=C.get(e.id),r=ee.get(t.node_id);return!n||!r?null:(0,S.jsx)(`line`,{x1:n.x+78,y1:n.y,x2:r.x-x-8,y2:r.y,className:`topologyPlacementLink ${t.status===`active`?`good`:`weak`}`,markerEnd:`url(#arrow)`},`entry-${e.id}-${t.node_id}`)}),ae.map(({pool:e,assignment:t})=>{let n=ee.get(t.node_id),r=te.get(e.id);return!n||!r?null:(0,S.jsx)(`line`,{x1:n.x+x+8,y1:n.y,x2:r.x-78,y2:r.y,className:`topologyPlacementLink ${t.status===`active`?`good`:`weak`}`,markerEnd:`url(#arrow)`},`egress-${e.id}-${t.node_id}`)}),ie.map(e=>{let t=ee.get(e.source_node_id),n=ee.get(e.target_node_id);if(!t||!n)return null;let r=ke(t,n,x+8);return(0,S.jsx)(`line`,{x1:r.x1,y1:r.y1,x2:r.x2,y2:r.y2,className:`topologyConfiguredLink`,markerEnd:`url(#arrow)`},e.id)}),m.map(({link:e,status:t})=>{let n=ee.get(e.source_node_id),r=ee.get(e.target_node_id);if(!n||!r)return null;let i=ke(n,r,x+8),a=(i.x1+i.x2)/2,o=(i.y1+i.y2)/2;return(0,S.jsxs)(`g`,{children:[(0,S.jsx)(`line`,{x1:i.x1,y1:i.y1,x2:i.x2,y2:i.y2,className:`topologyLink ${vn(e,t)}`,markerEnd:`url(#arrow)`}),(0,S.jsx)(`text`,{x:a,y:o-8,className:`topologyLinkLabel`,children:nt(e,t)})]},e.id||`${e.source_node_id}-${e.target_node_id}`)}),r.map(e=>{let t=C.get(e.id),n=(i[e.id]||[]).length;return(0,S.jsxs)(`g`,{className:`topologyEndpoint`,children:[(0,S.jsx)(`rect`,{x:t.x-86,y:t.y-36,width:`172`,height:`72`,rx:`20`,className:`topologyEndpointRect ${e.status}`}),(0,S.jsx)(`text`,{x:t.x,y:t.y-8,className:`topologyEndpointName`,children:yn(e.name)}),(0,S.jsxs)(`text`,{x:t.x,y:t.y+15,className:`topologyEndpointMeta`,children:[e.endpoint_type,` · `,n]})]},e.id)}),e.map(t=>{let n=ee.get(t.id),r=De(e.length),i=Fe(s[t.id]||[]),a=re.get(t.id)||`isolated`;return(0,S.jsxs)(`g`,{className:`topologyNode`,children:[(0,S.jsx)(`circle`,{cx:n.x,cy:n.y,r:x,className:`topologyNodeCircle ${t.health_status}`}),(0,S.jsx)(`text`,{x:n.x,y:n.y-r.nameOffset,className:`topologyNodeName`,style:{fontSize:r.name},children:yn(t.name,r.maxChars)}),(0,S.jsxs)(`text`,{x:n.x,y:n.y+r.metaOffset,className:`topologyNodeMeta`,style:{fontSize:r.meta},children:[i.length,` активн. ролей / `,(c[t.id]||[]).length,` серв.`]}),(0,S.jsxs)(`text`,{x:n.x,y:n.y+r.memoryOffset,className:`topologyNodeMeta`,style:{fontSize:r.meta},children:[`mesh: `,V(a)]})]},t.id)}),a.map(e=>{let t=te.get(e.id),n=(o[e.id]||[]).length;return(0,S.jsxs)(`g`,{className:`topologyEndpoint`,children:[(0,S.jsx)(`rect`,{x:t.x-86,y:t.y-36,width:`172`,height:`72`,rx:`20`,className:`topologyEndpointRect ${e.status}`}),(0,S.jsx)(`text`,{x:t.x,y:t.y-8,className:`topologyEndpointName`,children:yn(e.name)}),(0,S.jsxs)(`text`,{x:t.x,y:t.y+15,className:`topologyEndpointMeta`,children:[`egress · `,n]})]},e.id)}),m.length===0&&ie.length===0&&oe===0&&(0,S.jsx)(`text`,{x:b.width/2,y:b.height-34,className:`topologyEmpty`,children:d})]}),(0,S.jsxs)(`div`,{className:`topologyLegend`,children:[(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendLine placement`}),` `,u.placementIntent,`: `,oe]}),(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendLine observed`}),` реальные: `,h.length]}),(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendLine oneWay`}),` one-way: `,g.length]}),(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendLine stale`}),` stale: `,_.length]}),(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendLine problem`}),` проблемы: `,v.length]}),(0,S.jsxs)(`span`,{children:[(0,S.jsx)(`i`,{className:`legendLine configured`}),` configured: `,ie.length]})]}),(0,S.jsxs)(`div`,{className:`serviceTags`,children:[r.map(t=>(0,S.jsxs)(`div`,{className:`serviceTag`,children:[(0,S.jsx)(`strong`,{children:t.name}),(0,S.jsx)(`span`,{children:t.endpoint_type}),(0,S.jsx)(`small`,{children:(i[t.id]||[]).map(t=>P(e,t.node_id)).join(`, `)||u.assignedNodesEmpty})]},t.id)),e.map(e=>(0,S.jsxs)(`div`,{className:`serviceTag`,children:[(0,S.jsx)(`strong`,{children:e.name}),(0,S.jsxs)(`span`,{children:[V(e.health_status),` / mesh `,V(re.get(e.id)||`isolated`)]}),(0,S.jsx)(`small`,{children:Pe(s[e.id]||[])}),(0,S.jsx)(`small`,{children:Le(c[e.id]||[])})]},e.id)),a.map(t=>(0,S.jsxs)(`div`,{className:`serviceTag`,children:[(0,S.jsx)(`strong`,{children:t.name}),(0,S.jsx)(`span`,{children:t.status}),(0,S.jsx)(`small`,{children:(o[t.id]||[]).map(t=>P(e,t.node_id)).join(`, `)||u.assignedNodesEmpty})]},t.id))]})]})}function Se(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 Ce(e,t,n){if(we(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&&!we(t,n));return!r||r.link_status!==`reachable`?`one_way`:`reachable`}function we(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>120*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 Te(e,t,n,r,i){return n<=1?{x:e,y:Math.round((r+i)/2)}:{x:e,y:Math.round(r+(i-r)*t/(n-1))}}function j(e,t,n){let r=Math.max(e,t,n,1),i=Math.max(Math.ceil(e/3),r);return{width:1120,height:Math.max(640,220+i*104)}}function Ee(e){return e>48?26:e>24?32:e>12?40:52}function De(e){return e>48?{name:12,meta:9,nameOffset:7,metaOffset:7,memoryOffset:20,maxChars:10}:e>24?{name:14,meta:10,nameOffset:8,metaOffset:9,memoryOffset:24,maxChars:12}:e>12?{name:16,meta:12,nameOffset:10,metaOffset:11,memoryOffset:28,maxChars:14}:{name:21,meta:15,nameOffset:12,metaOffset:10,memoryOffset:31,maxChars:18}}function Oe(e,t,n){let r=e.length>24?4:e.length>8?3:e.length>3?2:1,i=Math.max(1,Math.ceil(e.length/r)),a=r===1?0:300/(r-1),o=t-96,s=i===1?0:(o-142)/(i-1);return new Map(e.map((e,t)=>{let n=t%r,c=Math.floor(t/r);return[e.id,{x:Math.round(r===1?560:410+a*n),y:Math.round(i===1?(142+o)/2:142+s*c)}]}))}function ke(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 Ae(e,t){let n=new Set(e.map(e=>e.id)),r=new Map;for(let[e,i]of Object.entries(t))for(let t of i.routes||[]){let i=t.hops||[];for(let a=0;a${s}`,{id:`configured-${e}-${t.route_id}-${a}`,source_node_id:o,target_node_id:s})}}return[...r.values()]}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 Me(e,t){let n=JSON.parse(e||`{}`);if(!n||Array.isArray(n)||typeof n!=`object`)throw Error(`${t}: требуется JSON object.`);return n}function Ne(e,t){let n=JSON.parse(e||`[]`);if(!Array.isArray(n))throw Error(`${t}: требуется JSON array.`);return n}function Pe(e){let t=Fe(e);return t.length===0?`активные роли не назначены`:t.map(e=>`${Ie(e.role)}${e.organization_id?` @ ${R(e.organization_id)}`:``}`).join(`, `)}function Fe(e){return e.filter(e=>e.status===`active`)}function Ie(e){let t=w[e];return t?`${t} (${e})`:e}function Le(e){return e.length===0?`нет сервисов`:e.map(e=>`${e.service_type}:${e.reported_state}`).join(`, `)}function Re(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 ze(e,t){return e.find(e=>e.product===t)}function Be(e,t){return e?`${e.product}: ${e.phase}/${e.status}`:t?`${t.action}: ${t.reason}`:`нет отчета`}function Ve(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}, ${z(e.observed_at)}${n}`}function He(e){return e?`push ${e.pushed||0} / pop ${e.popped||0} / q ${e.queue_depth||0} / drop ${e.dropped||0}`:`нет данных`}function Ue(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 We(e){let t=e.toLowerCase();return t===`healthy`?`good`:t===`fenced`?`bad`:t===`degraded`||t===`operator_retry_cooldown`?`warn`:``}function Ge(e){let t=e.toLowerCase();return t===`healthy`?`good`:t===`recovered`||t===`cooldown`||t===`degraded`?`warn`:t===`fenced`||t===`demoted`?`bad`:``}function Ke(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 qe(e){let t=Ke(e);return t===`active`?`good`:t===`expired`?`warn`:t===`disabled`?``:`warn`}function Je(e){let t=typeof e.node_id==`string`?e.node_id:``;if(t)return R(t);let n=Array.isArray(e.node_ids)?e.node_ids.filter(e=>typeof e==`string`):[];return n.length>0?n.map(R).join(`, `):`selector`}function Ye(e){let t=ze(e,`rap-node-agent`),n=ze(e,`rap-host-agent`);if(!t&&!n)return{label:`updater: нет отчета`,detail:`repair/update task не отчитался`,tone:`bad`};let r=[t,n].some(e=>e&&Xe(e)),i=!n,a=n?.phase===`apply`&&n?.status===`staged`,o=[t,n].some(e=>e&&Ue(e)===`bad`),s=t?`${t.current_version||`?`}->${t.target_version||`?`}`:`node ?`,c=n?`${n.current_version||`?`}->${n.target_version||`?`}`:`host ?`,l=z((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 Xe(e){let t=new Date(e.observed_at).getTime();return!Number.isFinite(t)||Date.now()-t>900*1e3}function Ze(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||R(e.id)}function Qe(e){let t=new Map;for(let n of e){let e=`${n.source_node_id}->${n.target_node_id}:${$e(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 $e(e){let t=rt(e,`observation_type`)||`default`;return t===`synthetic_route_health`?`${t}:${rt(e,`route_id`)||e.id}`:t===`peer_connection_manager`?`${t}:${rt(e,`transport_mode`)}:${rt(e,`relay_node_id`)}`:t}function et(e){let t=rt(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=rt(e,`transport_mode`)||`manager`,n=rt(e,`connection_state`);return n?`${t} ${n}`:t}return t||`link`}function tt(e,t){let n=rt(e,`route_id`),r=rt(e,`route_path_decision_selected_relay_id`)||rt(e,`relay_node_id`),i=it(e,`expected_effective_hops`),a=it(e,`observed_ack_path`),o=i.length>0?i:a,s=[];return n&&s.push(R(n)),r&&s.push(`via ${R(r)}`),o.length>0&&s.push(o.map(e=>yn(P(t,e))).join(` > `)),s.length>0?s.join(` / `):`н/д`}function nt(e,t=e.link_status===`reachable`?`reachable`:`unknown`){return t===`stale`?`stale`:t===`one_way`?`one-way`:rt(e,`observation_type`)===`synthetic_route_health`?e.metadata?.route_path_drift_detected===!0?`drift`:`route`:e.latency_ms==null?`связь`:`${e.latency_ms}мс`}function rt(e,t){let n=e.metadata?.[t];return typeof n==`string`?n:``}function it(e,t){let n=e.metadata?.[t];return Array.isArray(n)?n.filter(e=>typeof e==`string`):[]}function at(e){return e&&typeof e==`object`&&!Array.isArray(e)?e:void 0}function ot(e){return Array.isArray(e)?e.map(e=>at(e)).filter(e=>!!e):[]}function N(e,t,n=``){let r=e?.[t];return typeof r==`string`?r:typeof r==`number`||typeof r==`boolean`?String(r):n}function st(e,t){for(let n of t){let t=N(e,n,``);if(t)return t}return``}function ct(e,t,n){let r=t[0],i=r?.metadata||{},a=at(i.mesh_listener_report),o=at(i.mesh_endpoint_report),s=at(i.mesh_outbound_session_report),c=at(i.mesh_peer_connection_manager_report),l=at(i.mesh_peer_recovery_report),u=ot(o?.endpoint_candidates)[0],d=st(o,[`peer_endpoint`,`advertised_endpoint`,`endpoint`])||N(u,`address`,``)||N(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=lt(c,`peer_connection_ready`)||lt(l,`peer_connection_ready`)||Qe(n).filter(t=>(t.source_node_id===e.id||t.target_node_id===e.id)&&t.link_status===`reachable`).length,p=lt(c,`peer_connection_total`)||lt(l,`peer_connection_total`)||Qe(n).filter(t=>t.source_node_id===e.id||t.target_node_id===e.id).length,m=lt(c,`failed`),h=N(a,`status`,``),g=a?.port_conflict===!0,_=a?.one_way_connectivity===!0||N(o,`connectivity_mode`,``)===`outbound_only`||lt(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=N(s,`status`,``),S=s?.usable_for_inbound_control===!0,C=lt(s,`peer_connection_relay_ready`),te=lt(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:N(a,`failure_error`,N(a,`failure_reason`,``))}}function lt(e,t,n=0){let r=e?.[t];return typeof r==`number`&&Number.isFinite(r)?r:n}function ut(e){if(e==null)return`н/д`;let t=JSON.stringify(e);return t.length>140?`${t.slice(0,137)}...`:t}function dt(e){let t=ot(e.candidate_results);return t.length===0?`н/д`:t.slice(0,4).map(e=>{let t=N(e,`candidate_id`,`candidate`),n=N(e,`link_status`,`unknown`),r=N(e,`latency_ms`,``);return r&&r!==`0`?`${t}:${n}:${r}мс`:`${t}:${n}`}).join(`, `)}function ft(e){return Object.values(e.peer_endpoint_candidates||{}).reduce((e,t)=>e+t.length,0)}function pt(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 ${R(r.selected_relay_id)}`),n.join(` `)}function mt(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 ${R(r.selected_relay_id)}`):r?.next_hop_id&&n.push(`next ${R(r.next_hop_id)}`),n.join(` `)}function P(e,t){return e.find(e=>e.id===t)?.name||R(t)}function ht(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 gt(e,t){let n=t.find(t=>t.id===e);return n?ht(n,t):e}function _t(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 vt(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 yt(e,t){return Object.entries(t).filter(([,t])=>t.some(t=>t.role===e&&t.status===`active`)).map(([e])=>e)}function bt(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||wt()).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=Et(e.artifactEndpoints||Tt()),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||wt()).trim().replace(/\/$/,``);t.install_profile=`windows_service`,t.backend_url=n,t.control_plane_endpoints=[n],t.artifact_endpoints=Et(e.artifactEndpoints||Tt()),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||wt()).trim().replace(/\/$/,``);t.install_profile=`linux_binary`,t.backend_url=n,t.control_plane_endpoints=[n],t.artifact_endpoints=Et(e.artifactEndpoints||Tt()),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 xt(e,t){let n=St(e.roles),r=St(e.artifact_endpoints).join(`, `);return{...t,roles:n.length>0?n:t.roles,nodeName:N(e,`node_name`,``)||t.nodeName,nodeGroupId:N(e,`node_group_id`,``)||t.nodeGroupId,ownershipType:N(e,`ownership_type`,t.ownershipType),purpose:N(e,`purpose`,``)||t.purpose,installMode:N(e,`install_profile`,t.installMode),dockerImage:N(e,`image`,t.dockerImage),dockerContainerName:N(e,`container_name`,``)||t.dockerContainerName,dockerNetwork:N(e,`network`,t.dockerNetwork),windowsStartupMode:N(e,`startup_mode`,t.windowsStartupMode),windowsInstallDir:N(e,`install_dir`,``)||t.windowsInstallDir,windowsNodeAgentSHA256:N(e,`node_agent_artifact_sha256`,``)||t.windowsNodeAgentSHA256,linuxInstallDir:N(e,`install_dir`,``)||t.linuxInstallDir,linuxNodeAgentSHA256:N(e,`node_agent_artifact_sha256`,``)||t.linuxNodeAgentSHA256,meshListenAddr:N(e,`mesh_listen_addr`,t.meshListenAddr),meshListenPortMode:N(e,`mesh_listen_port_mode`,t.meshListenPortMode),meshListenAutoPortStart:lt(e,`mesh_listen_auto_port_start`,t.meshListenAutoPortStart),meshListenAutoPortEnd:lt(e,`mesh_listen_auto_port_end`,t.meshListenAutoPortEnd),meshAdvertiseEndpoint:N(e,`mesh_advertise_endpoint`,``)||t.meshAdvertiseEndpoint,meshAdvertiseTransport:N(e,`mesh_advertise_transport`,t.meshAdvertiseTransport),meshConnectivityMode:N(e,`mesh_connectivity_mode`,t.meshConnectivityMode),meshNATType:N(e,`mesh_nat_type`,t.meshNATType),meshRegion:N(e,`mesh_region`,``)||t.meshRegion,controlPlaneEndpoint:St(e.control_plane_endpoints)[0]||N(e,`backend_url`,``)||t.controlPlaneEndpoint,artifactEndpoints:r||t.artifactEndpoints,dockerImageArtifactSHA256:N(e,`docker_image_artifact_sha256`,``)||t.dockerImageArtifactSHA256,pullImage:Ct(e,`pull_image`,t.pullImage),replace:Ct(e,`replace`,t.replace),syntheticRuntime:Ct(e,`mesh_synthetic_runtime_enabled`,t.syntheticRuntime)}}function St(e){return Array.isArray(e)?e.filter(e=>typeof e==`string`).map(e=>e.trim()).filter(Boolean):[]}function Ct(e,t,n){let r=e[t];return typeof r==`boolean`?r:n}function wt(){return typeof window>`u`||!window.location?.origin?`http://:18080/api/v1`:`${window.location.origin.replace(/\/$/,``)}/api/v1`}function Tt(){return typeof window>`u`||!window.location?.origin?`http://:18080/downloads`:`${window.location.origin.replace(/\/$/,``)}/downloads`}function Et(e){return e.split(`,`).map(e=>e.trim().replace(/\/$/,``)).filter(Boolean)}function Dt(e){return Et(e.artifactEndpoints||Tt()).map(e=>`${e}/rap-node-agent-dev-enrollment-bootstrap-smoke.tar`)}function Ot(e){return e.meshConnectivityMode===`outbound_only`?`outbound_only`:e.meshConnectivityMode===`private_lan`?`private_lan`:e.meshNATType!==`none`&&e.meshAdvertiseEndpoint.trim()?`nat_forward`:`direct`}function kt(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 At(e,t){return e.nodeName.trim()?e.nodeName.trim():`${Zt(t?.slug||t?.name||`rap-node`)}-node-1`}function jt(e,t){return e.dockerContainerName.trim()?e.dockerContainerName.trim():`rap-node-agent-${Zt(At(e,t))}`}function Mt(e,t,n=le){let r=t?.id||e.cluster_id,i=At(n,t),a=jt(n,t),o=Zt(i),s=[`rap-host-agent install`,`--backend-url ${I(qt(n))}`,`--cluster-id ${I(r)}`,`--join-token ${I(e.token)}`,`--node-name ${I(i)}`,`--image ${I(n.dockerImage||`rap-node-agent:latest`)}`,`--container-name ${I(a)}`,`--state-dir ${I(`/var/lib/rap/nodes/${o}`)}`,`--network host`,`--replace`];for(let e of Dt(n))s.push(`--image-artifact-url ${I(e)}`);return n.dockerImageArtifactSHA256.trim()&&s.push(`--image-artifact-sha256 ${I(n.dockerImageArtifactSHA256.trim())}`),s.join(` \\ - `)}function Nt(e,t,n=le){let r=t?.id||e.cluster_id,i=At(n,t),a=[`sudo "$rap_host_agent" install`,`--profile-url ${I(qt(n))}`,`--cluster-id ${I(r)}`,`--install-token ${I(e.token)}`,`--node-name ${I(i)}`].join(` \\ - `);return[`rap_host_agent="$(mktemp /tmp/rap-host-agent.XXXXXX)"`,`curl -fL --retry 3 --retry-delay 1 ${I(Yt(n))} -o "$rap_host_agent"`,`chmod +x "$rap_host_agent"`,a].join(` && \\ - `)}function Pt(e,t,n=le){let r=t?.id||e.cluster_id,i=At(n,t),a=[`sudo "$rap_host_agent" install-linux`,`--profile-url ${I(qt(n))}`,`--cluster-id ${I(r)}`,`--install-token ${I(e.token)}`,`--node-name ${I(i)}`].join(` \\ - `);return[`rap_host_agent="$(mktemp /tmp/rap-host-agent.XXXXXX)"`,`curl -fL --retry 3 --retry-delay 1 ${I(Yt(n))} -o "$rap_host_agent"`,`chmod +x "$rap_host_agent"`,a].join(` && \\ - `)}function Ft(e,t,n=le){let r=t?.id||e.cluster_id,i=At(n,t),a=qt(n);return[`$rapHostAgent = Join-Path $env:TEMP "rap-host-agent.exe"`,`Invoke-WebRequest -UseBasicParsing ${Qt(Xt(n))} -OutFile $rapHostAgent`,`& $rapHostAgent install-windows --profile-url ${Qt(a)} --cluster-id ${Qt(r)} --install-token ${Qt(e.token)} --node-name ${Qt(i)} --startup-mode ${Qt(n.windowsStartupMode||`auto`)}`].join(`\r -`)}function It(e,t,n=le){let r=t?.id||e.cluster_id,i=At(n,t),a=qt(n),o=Xt(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 Lt(e,t){let n=Jt(),r=n.replace(/\/api\/v1$/i,``).replace(/\/api$/i,``).replace(/\/$/,``),i=e.name||e.node_key||e.id,a=Wt(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: ${$t(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 "${$t(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 Rt(e){return`rap-repair-updater-${Ut(e.name||e.node_key||e.id||`node`)}.cmd`}function zt(e,t){let n=Jt(),r=n.replace(/\/api\/v1$/i,``).replace(/\/api$/i,``).replace(/\/$/,``),i=e.name||e.node_key||e.id,a=Gt(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: ${Kt(i)} ==="`,`echo "Node ID: ${e.id}"`,`echo "Control Plane: ${n}"`,`echo`,`echo "=== Before repair: systemd units ==="`,`systemctl status ${I(c)} --no-pager || true`,`systemctl status ${I(l)} --no-pager || true`,`echo`,`echo "=== Before repair: binaries ==="`,`ls -la ${I(o)} || true`,`echo`,`rap_host_agent="$(mktemp /tmp/rap-host-agent.XXXXXX)"`,`curl -fL --retry 3 --retry-delay 1 ${I(`${r}/downloads/rap-host-agent-linux-amd64`)} -o "$rap_host_agent"`,`chmod +x "$rap_host_agent"`,`sudo "$rap_host_agent" install-linux --backend-url ${I(n)} --cluster-id ${I(t||``)} --node-id ${I(e.id)} --node-name ${I(i)} --replace --startup-mode systemd --auto-update-current-version 0.0.0 --auto-update-initial-delay-seconds 1`,`sudo ${I(u)} update-loop --backend-url ${I(n)} --cluster-id ${I(t||``)} --node-id ${I(e.id)} --state-dir ${I(s)} --current-version 0.0.0 --os linux --arch amd64 --install-type linux_binary --binary-path ${I(`${o}/rap-node-agent`)} --systemd-unit ${I(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 ${I(u)}`,`sudo systemctl daemon-reload`,`sudo systemctl restart ${I(l)}`,`echo`,`echo "=== After repair: systemd updater ==="`,`systemctl status ${I(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 Bt(e){return`rap-repair-updater-${Ut(e.name||e.node_key||e.id||`node`)}.sh`}function Vt(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 Ht(e){await navigator.clipboard.writeText(e)}function Ut(e){return e.trim().replace(/[\\/:*?"<>|]+/g,`-`).replace(/\s+/g,`-`).replace(/-+/g,`-`).replace(/^-|-$/g,``).slice(0,80)||`node`}function Wt(e){return e.trim().replace(/[\\/:*?"<>|]+/g,`-`).replace(/\s+/g,`-`).replace(/-+/g,`-`).replace(/^-|-$/g,``)||`node`}function Gt(e){return Wt(e).slice(0,48)||`node`}function Kt(e){return e.replace(/\\/g,`\\\\`).replace(/"/g,`\\"`).replace(/\$/g,`\\$`).replace(/`/g,"\\`")}function qt(e=le){return(e.controlPlaneEndpoint||wt()).trim().replace(/\/$/,``)}function Jt(){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 F(e=le){let t=Et(e.artifactEndpoints)[0];return t?t.replace(/\/downloads$/i,``).replace(/\/$/,``):qt(e).replace(/\/api\/v1$/i,``).replace(/\/api$/i,``).replace(/\/$/,``)}function Yt(e=le){return`${typeof window>`u`&&!e.controlPlaneEndpoint?`http://:18080`:F(e)}/downloads/rap-host-agent-linux-amd64`}function Xt(e=le){return`${typeof window>`u`&&!e.controlPlaneEndpoint?`http://:18080`:F(e)}/downloads/rap-host-agent-windows-amd64.exe`}function Zt(e){return e.trim().toLowerCase().replace(/[^a-z0-9-]+/g,`-`).replace(/^-+|-+$/g,``).slice(0,42)||`rap-node`}function I(e){return`'${e.replace(/'/g,`'\\''`)}'`}function Qt(e){return`'${e.replace(/'/g,`''`)}'`}function $t(e){return e.replace(/"/g,`""`)}function en(e,t){return e.includes(t)?e.filter(e=>e!==t):[...e,t]}function tn(e,t,n,r,i){let a=n.trim().toLowerCase(),o=new Map;for(let n of e){if(a&&!nn(n,a))continue;let e=rn(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 nn(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 rn(e,t,n,r){if(n===`health`)return V(e.node.health_status);if(n===`ownership`)return V(e.node.ownership_type);if(n===`cluster_count`)return _n(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`:`Участие`}: ${V(i.node.membership_status)}`:r===`en`?`Not in active cluster`:`Не в активном кластере`}function an(e,t){let n=ae[e]||[];if(n.length===0||!t)return`unknown`;if(on(t))return`stale`;let r=t.capabilities||{};return n.some(e=>!!r[e])?`confirmed`:`missing`}function on(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 sn(e,t){let n=an(e,t);return n===`confirmed`?`good`:n===`missing`?`bad`:n===`stale`?`warn`:``}function cn(e,t,n){let r=an(e,t);return r===`confirmed`?n.capabilityConfirmed:r===`missing`?n.capabilityMissing:r===`stale`?`heartbeat устарел`:n.capabilityUnknown}function ln(e,t,n){let r=an(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 un(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 dn(e){let t=e?.metadata?.mesh_peer_connection_intent_report;if(!t||typeof t!=`object`||Array.isArray(t))return hn(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=hn(e);return d===`н/д`?u:`${u}; ${d}`}function fn(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 pn(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 L(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 mn(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 hn(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 gn(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 _n(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 vn(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 yn(e,t=16){return e.length>t?`${e.slice(0,Math.max(1,t-2))}…`:e}function bn(e){return window.confirm(`${e}?\n\nЭто высокорисковая операция владельца платформы. Действие будет записано в аудит.`)}function xn(e){let t=(e||``).replace(/\/$/,``);return!t||t===`/api/v1`?window.location.origin:t.endsWith(`/api/v1`)?t.slice(0,-7):t}function R(e){return e?e.length>12?`${e.slice(0,8)}...${e.slice(-4)}`:e:`нет`}function z(e){return e?new Intl.DateTimeFormat(void 0,{dateStyle:`medium`,timeStyle:`short`}).format(new Date(e)):`никогда`}function Sn(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 Cn(e){return e?new Intl.DateTimeFormat(void 0,{hour:`2-digit`,minute:`2-digit`,second:`2-digit`}).format(new Date(e)):`н/д`}function B(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 Tn(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 En(e,t){return(t||0)>0||e===`critical`?`bad`:e===`degraded`?`warn`:e===`watch`?`info`:`good`}function Dn(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 On(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 V(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/assets/index-gMV--oab.js b/web-admin/deploy/html/assets/index-gMV--oab.js new file mode 100644 index 0000000..bd01d72 --- /dev/null +++ b/web-admin/deploy/html/assets/index-gMV--oab.js @@ -0,0 +1,24 @@ +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 b69ff5a..409e7a7 100644 --- a/web-admin/deploy/html/index.html +++ b/web-admin/deploy/html/index.html @@ -4,8 +4,8 @@ Панель Secure Access Fabric - - + +
diff --git a/web-admin/src/App.tsx b/web-admin/src/App.tsx index 7109555..0ab7c78 100644 --- a/web-admin/src/App.tsx +++ b/web-admin/src/App.tsx @@ -10,10 +10,6 @@ import type { ClusterNode, ClusterNodeGroup, CreatedJoinToken, - FabricEntryPoint, - FabricEntryPointNode, - FabricEgressPool, - FabricEgressPoolNode, FabricServiceChannelAccessTelemetry, FabricServiceChannelBreadcrumbWindowPolicy, FabricServiceChannelRecoveryPolicy, @@ -90,6 +86,15 @@ const defaultFabricRebuildLedgerFilters: FabricRebuildLedgerFilters = { }; const roleOptions = [ + "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", @@ -97,12 +102,23 @@ const roleOptions = [ "vnc-worker", "vpn-exit", "vpn-connector", + "vpn-client", + "ipv4-egress", "file-storage-cache", "update-cache", "video-relay", ]; const roleDisplayNames: Record = { + "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", @@ -110,12 +126,23 @@ const roleDisplayNames: Record = { "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", }; const capabilityKeysByRole: Record = { + "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"], @@ -123,6 +150,8 @@ const capabilityKeysByRole: Record = { "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"], @@ -448,28 +477,18 @@ const copy = { rolesAndServices: "Роли и сервисы", links: "Связи", fabricMap: "Карта трафика Fabric", - fabricIngressLayer: "Входы", fabricNodeLayer: "Узлы кластера", - fabricEgressLayer: "Выходные зоны", observedPeerLinks: "Наблюдаемые связи", placementIntent: "control-plane назначение", - fabricEntryPoints: "Точки входа", - fabricEntryPointHelp: "Логические внешние входы в кластер. Они скрывают конкретные узлы от организаций и клиентов.", - fabricEgressPools: "Выходные зоны", - fabricEgressPoolHelp: "Логические выходы к внешним сетям, например “Офис Москва”. Организации используют зону, а не конкретный узел.", endpointName: "Название", publicEndpoint: "Публичный адрес", endpointType: "Тип входа", description: "Описание", routeScope: "Область маршрутов JSON", - createEntryPoint: "Создать точку входа", - createEgressPool: "Создать выходную зону", endpointNodes: "Назначенные узлы", assignEndpointNode: "Назначить узел", selectNode: "Выберите узел", assignedNodesEmpty: "Узлы пока не назначены", - entryPointsEmpty: "Точки входа пока не созданы.", - egressPoolsEmpty: "Выходные зоны пока не созданы.", addressNotSet: "адрес не задан", descriptionNotSet: "описание не задано", servicePlacement: "Размещение сервисов", @@ -645,28 +664,18 @@ const copy = { rolesAndServices: "Roles and services", links: "Links", fabricMap: "Fabric traffic map", - fabricIngressLayer: "Ingress", fabricNodeLayer: "Cluster nodes", - fabricEgressLayer: "Egress pools", observedPeerLinks: "Observed links", placementIntent: "control-plane placement", - fabricEntryPoints: "Entry points", - fabricEntryPointHelp: "Logical external ingress points for the cluster. They hide concrete nodes from organizations and clients.", - fabricEgressPools: "Egress pools", - fabricEgressPoolHelp: "Logical exits to external networks, for example “Office Moscow”. Organizations use the pool, not a concrete node.", endpointName: "Name", publicEndpoint: "Public endpoint", endpointType: "Entry type", description: "Description", routeScope: "Route scope JSON", - createEntryPoint: "Create entry point", - createEgressPool: "Create egress pool", endpointNodes: "Assigned nodes", assignEndpointNode: "Assign node", selectNode: "Select node", assignedNodesEmpty: "No nodes assigned yet", - entryPointsEmpty: "No entry points created yet.", - egressPoolsEmpty: "No egress pools created yet.", addressNotSet: "address not set", descriptionNotSet: "description not set", servicePlacement: "Service placement", @@ -776,10 +785,6 @@ export function App() { const [fabricRecoveryPolicy, setFabricRecoveryPolicy] = useState(null); const [fabricBreadcrumbWindowPolicy, setFabricBreadcrumbWindowPolicy] = useState(null); const [qosPolicies, setQosPolicies] = useState([]); - const [entryPoints, setEntryPoints] = useState([]); - const [entryPointNodesById, setEntryPointNodesById] = useState>({}); - const [egressPools, setEgressPools] = useState([]); - const [egressPoolNodesById, setEgressPoolNodesById] = useState>({}); const [testingFlags, setTestingFlags] = useState([]); const [vpnConnections, setVPNConnections] = useState([]); const [vpnLeases, setVPNLeases] = useState>({}); @@ -808,8 +813,6 @@ export function App() { const [clusterForm, setClusterForm] = useState({ slug: "", name: "", region: "" }); const [clusterSettingsForm, setClusterSettingsForm] = useState({ name: "", status: "active", region: "", metadataJson: "{}" }); const [nodeGroupForm, setNodeGroupForm] = useState({ name: "", parentGroupId: "" }); - const [entryPointForm, setEntryPointForm] = useState({ name: "", endpointType: "client_access", publicEndpoint: "" }); - const [egressPoolForm, setEgressPoolForm] = useState({ name: "", description: "", routeScope: "{\n \"routes\": []\n}" }); const [fabricRecoveryPolicyForm, setFabricRecoveryPolicyForm] = useState({ hysteresisPenalty: "150", promotionMinSamples: "64", @@ -841,8 +844,6 @@ export function App() { const [meshListenerDrafts, setMeshListenerDrafts] = useState< Record >({}); - const [entryPointNodeDrafts, setEntryPointNodeDrafts] = useState>({}); - const [egressPoolNodeDrafts, setEgressPoolNodeDrafts] = useState>({}); const [nodeTestingDrafts, setNodeTestingDrafts] = useState>({}); const [testingOrgId, setTestingOrgId] = useState(""); const [testingOrgDraft, setTestingOrgDraft] = useState({ telemetry: true, links: true }); @@ -889,7 +890,7 @@ export function App() { const [androidClientVersion, setAndroidClientVersion] = useState(""); const [androidClientPublishedAt, setAndroidClientPublishedAt] = useState(""); const [androidClientVersionedPath, setAndroidClientVersionedPath] = useState(""); - const androidClientDefaultLatestFilename = "rap-android-rdp-vpn-latest-release.apk"; + const androidClientDefaultLatestFilename = "rap-android-vpn-latest-release.apk"; const [androidClientLatestPath, setAndroidClientLatestPath] = useState(androidClientDefaultLatestFilename); const client = useMemo(() => new AdminApiClient({ baseUrl, actorUserId }), [baseUrl, actorUserId]); @@ -997,7 +998,7 @@ export function App() { const updateAndroidPortalMetadata = useCallback(async () => { try { - const manifestUrl = `${portalDownloadBaseUrl}/downloads/rap-android-rdp-vpn-build.json?_cb=${Date.now()}`; + const manifestUrl = `${portalDownloadBaseUrl}/downloads/rap-android-vpn-build.json?_cb=${Date.now()}`; const response = await fetch(manifestUrl, { cache: "no-store" }); if (!response.ok) { setAndroidClientVersion(""); @@ -1341,8 +1342,6 @@ export function App() { loadedFabricRecoveryPolicy, loadedFabricBreadcrumbWindowPolicy, loadedQosPolicies, - loadedEntryPoints, - loadedEgressPools, loadedVPNConnections, loadedTestingFlags, ] = @@ -1370,8 +1369,6 @@ export function App() { client.getFabricServiceChannelRecoveryPolicy(clusterId), client.getFabricServiceChannelBreadcrumbWindowPolicy(clusterId), client.listQoSPolicies(clusterId), - client.listFabricEntryPoints(clusterId), - client.listFabricEgressPools(clusterId), client.listVPNConnections(clusterId), client.listFabricTestingFlags(), ]); @@ -1424,8 +1421,6 @@ export function App() { demotionFencedEnabled: loadedFabricRecoveryPolicy.demotion_fenced_enabled, }); setQosPolicies(loadedQosPolicies); - setEntryPoints(loadedEntryPoints); - setEgressPools(loadedEgressPools); setVPNConnections(loadedVPNConnections); setTestingFlags(loadedTestingFlags); const diagnostics = await client.listVPNClientDiagnosticStatuses(clusterId); @@ -1443,16 +1438,6 @@ export function App() { localStorage.setItem(storageKeys.vpnDiagnosticDeviceId, selectedDiagnostic.device_id); } - const [entryPointNodeEntries, egressPoolNodeEntries] = await Promise.all([ - Promise.all(loadedEntryPoints.map(async (entryPoint) => [entryPoint.id, await client.listFabricEntryPointNodes(clusterId, entryPoint.id)] as const)), - Promise.all(loadedEgressPools.map(async (pool) => [pool.id, await client.listFabricEgressPoolNodes(clusterId, pool.id)] as const)), - ]); - if (requestSeq !== clusterScopeRequestSeq.current) { - return; - } - setEntryPointNodesById(Object.fromEntries(entryPointNodeEntries)); - setEgressPoolNodesById(Object.fromEntries(egressPoolNodeEntries)); - const roleEntries = await Promise.all(loadedNodes.map(async (node) => [node.id, await client.listNodeRoles(clusterId, node.id)] as const)); if (requestSeq !== clusterScopeRequestSeq.current) { return; @@ -1801,10 +1786,6 @@ export function App() { setFabricRebuildLedgerDeep(false); setFabricRebuildLedgerFilters(defaultFabricRebuildLedgerFilters); setQosPolicies([]); - setEntryPoints([]); - setEntryPointNodesById({}); - setEgressPools([]); - setEgressPoolNodesById({}); setTestingFlags([]); setVPNConnections([]); setVPNLeases({}); @@ -2027,6 +2008,7 @@ export function App() { const syntheticPeerDirectoryCount = syntheticMeshConfigs.reduce((sum, config) => sum + (config.peer_directory?.length ?? 0), 0); const syntheticRecoverySeedCount = syntheticMeshConfigs.reduce((sum, config) => sum + (config.recovery_seeds?.length ?? 0), 0); const productionForwardingConfigCount = syntheticMeshConfigs.filter((config) => config.production_forwarding).length; + const webIngressReceiverCluster = webIngressReceiverClusterSummary(nodes, heartbeatsByNode); const routeIntentActive = routeIntents.filter((item) => routeIntentLifecycle(item) === "active"); const routeIntentExpired = routeIntents.filter((item) => routeIntentLifecycle(item) === "expired"); const routeIntentDisabled = routeIntents.filter((item) => routeIntentLifecycle(item) === "disabled"); @@ -2321,7 +2303,7 @@ export function App() {

Что здесь будет дальше

Устройства и доверенные входы - Активные VPN/RDP сессии + Активные VPN-сессии Обновление профиля VPN без ручных ключей Самостоятельная смена пароля
@@ -4483,256 +4465,59 @@ export function App() { )} {activeView === "fabric" && ( -
-
-

Граница подготовки Fabric

-

- Этот экран показывает synthetic/control-plane подготовку и C17Z11 boundary: production forwarding доступен только для route-bound - `fabric.control` при явном gate. Service traffic, RDP, VPN и произвольный relay здесь не включены. -

-
- - - - - -
-
-
+
+
-

{t.fabricEntryPoints}

-

{t.fabricEntryPointHelp}

-
- {entryPoints.length} -
- - - - - -
- -
-
- {entryPoints.map((item) => { - const assignedNodes = entryPointNodesById[item.id] || []; - return ( -
-
-
-

{item.name}

-

- {item.endpoint_type} · {item.public_endpoint || t.addressNotSet} -

-
- -
-
{t.endpointNodes}
- {assignedNodes.length === 0 ? ( -

{t.assignedNodesEmpty}

- ) : ( -
- {assignedNodes.map((assignment) => ( - - {nodeName(nodes, assignment.node_id)} · {statusLabel(assignment.status)} · p{assignment.priority} - - ))} -
- )} -
- - -
-
- ); - })} - {entryPoints.length === 0 && } -
-
-
-
-
-

{t.fabricEgressPools}

-

{t.fabricEgressPoolHelp}

-
- {egressPools.length} -
- - - -