3473 lines
128 KiB
Go
3473 lines
128 KiB
Go
package cluster
|
|
|
|
import (
|
|
"context"
|
|
"crypto/sha256"
|
|
"encoding/binary"
|
|
"encoding/hex"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"os"
|
|
"path/filepath"
|
|
"reflect"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/go-chi/chi/v5"
|
|
"github.com/jackc/pgx/v5"
|
|
|
|
"github.com/example/remote-access-platform/backend/internal/platform/authority"
|
|
"github.com/example/remote-access-platform/backend/internal/platform/httpx"
|
|
"github.com/example/remote-access-platform/backend/internal/platform/module"
|
|
"github.com/example/remote-access-platform/backend/internal/platform/secrets"
|
|
)
|
|
|
|
type Module struct {
|
|
service *Service
|
|
vpnPacketHub *vpnPacketHub
|
|
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 != "" {
|
|
if encryptor, err := secrets.NewEncryptor(deps.Config.Secret.EncryptionKeyBase64, deps.Config.Secret.EncryptionKeyID); err == nil {
|
|
store.WithClusterKeyEncryptor(encryptor)
|
|
}
|
|
}
|
|
return &Module{
|
|
service: NewService(store),
|
|
vpnPacketHub: newVPNPacketHub(),
|
|
vpnClientDiagnosticHub: newVPNClientDiagnosticHub(),
|
|
}
|
|
}
|
|
|
|
func (m *Module) Name() string {
|
|
return "cluster"
|
|
}
|
|
|
|
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)
|
|
r.Get("/{clusterID}", m.getCluster)
|
|
r.Put("/{clusterID}", m.updateCluster)
|
|
r.Get("/{clusterID}/nodes", m.listClusterNodes)
|
|
r.Get("/{clusterID}/node-groups", m.listNodeGroups)
|
|
r.Post("/{clusterID}/node-groups", m.createNodeGroup)
|
|
r.Get("/{clusterID}/join-requests", m.listJoinRequests)
|
|
r.Post("/{clusterID}/join-requests", m.createJoinRequest)
|
|
r.Post("/{clusterID}/join-requests/{requestID}/approve", m.approveJoinRequest)
|
|
r.Post("/{clusterID}/join-requests/{requestID}/reject", m.rejectJoinRequest)
|
|
r.Get("/{clusterID}/join-tokens", m.listJoinTokens)
|
|
r.Post("/{clusterID}/join-tokens", m.createJoinToken)
|
|
r.Post("/{clusterID}/join-tokens/{tokenID}/revoke", m.revokeJoinToken)
|
|
r.Get("/{clusterID}/nodes/{nodeID}/roles", m.listNodeRoles)
|
|
r.Post("/{clusterID}/nodes/{nodeID}/roles", m.assignNodeRole)
|
|
r.Post("/{clusterID}/nodes/{nodeID}/heartbeats", m.recordHeartbeat)
|
|
r.Get("/{clusterID}/nodes/{nodeID}/heartbeats", m.listNodeHeartbeats)
|
|
r.Get("/{clusterID}/updates/releases", m.listReleaseVersions)
|
|
r.Post("/{clusterID}/updates/releases", m.createReleaseVersion)
|
|
r.Put("/{clusterID}/nodes/{nodeID}/updates/policy", m.upsertNodeUpdatePolicy)
|
|
r.Get("/{clusterID}/nodes/{nodeID}/updates/plan", m.getNodeUpdatePlan)
|
|
r.Get("/{clusterID}/nodes/{nodeID}/updates/bridge-replay-plan", m.getNodeBridgeReplayPlan)
|
|
r.Post("/{clusterID}/nodes/{nodeID}/updates/status", m.reportNodeUpdateStatus)
|
|
r.Get("/{clusterID}/nodes/{nodeID}/updates/statuses", m.listNodeUpdateStatuses)
|
|
r.Get("/{clusterID}/updates/stale-node-risk-report", m.getStaleNodeRiskReport)
|
|
r.Get("/{clusterID}/nodes/{nodeID}/testing-flags", m.getEffectiveNodeTestingFlags)
|
|
r.Get("/{clusterID}/nodes/{nodeID}/mesh/synthetic-config", m.getNodeSyntheticMeshConfig)
|
|
r.Post("/{clusterID}/nodes/{nodeID}/telemetry", m.recordNodeTelemetry)
|
|
r.Get("/{clusterID}/nodes/{nodeID}/telemetry", m.listNodeTelemetry)
|
|
r.Post("/{clusterID}/nodes/{nodeID}/membership/attach", m.attachExistingNodeToCluster)
|
|
r.Put("/{clusterID}/nodes/{nodeID}/group", m.assignNodeGroup)
|
|
r.Post("/{clusterID}/nodes/{nodeID}/identity/revoke", m.revokeNodeIdentity)
|
|
r.Post("/{clusterID}/nodes/{nodeID}/membership/disable", m.disableMembership)
|
|
r.Delete("/{clusterID}/nodes/{nodeID}", m.deleteClusterNode)
|
|
r.Get("/{clusterID}/nodes/{nodeID}/workloads/desired", m.listDesiredWorkloads)
|
|
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)
|
|
r.Post("/{clusterID}/mesh/route-intents", m.createRouteIntent)
|
|
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/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)
|
|
r.Get("/{clusterID}/fabric/service-channels/rebuild-health", m.getFabricServiceChannelRouteRebuildHealthSummary)
|
|
r.Get("/{clusterID}/fabric/service-channels/readiness", m.getFabricServiceChannelReadiness)
|
|
r.Get("/{clusterID}/fabric/service-channels/schema-status", m.getFabricServiceChannelSchemaStatus)
|
|
r.Get("/{clusterID}/fabric/service-channels/rebuild-snapshots/health", m.getFabricServiceChannelRebuildSnapshotMaintenanceHealth)
|
|
r.Post("/{clusterID}/fabric/service-channels/rebuild-snapshots/warmup", m.warmupFabricServiceChannelRebuildSnapshots)
|
|
r.Get("/{clusterID}/fabric/service-channels/rebuild-incidents", m.listFabricServiceChannelRouteRebuildIncidents)
|
|
r.Post("/{clusterID}/fabric/service-channels/rebuild-incidents/investigations", m.recordFabricServiceChannelRouteRebuildInvestigation)
|
|
r.Get("/{clusterID}/fabric/service-channels/rebuild-investigations/breadcrumbs", m.listFabricServiceChannelRebuildInvestigationBreadcrumbs)
|
|
r.Get("/{clusterID}/fabric/service-channels/rebuild-health/silences", m.listFabricServiceChannelRouteRebuildAlertSilences)
|
|
r.Post("/{clusterID}/fabric/service-channels/rebuild-health/silences", m.silenceFabricServiceChannelRouteRebuildAlert)
|
|
r.Delete("/{clusterID}/fabric/service-channels/rebuild-health/silences/{silenceID}", m.unsilenceFabricServiceChannelRouteRebuildAlert)
|
|
r.Get("/{clusterID}/fabric/service-channels/recovery-policy", m.getFabricServiceChannelRecoveryPolicy)
|
|
r.Put("/{clusterID}/fabric/service-channels/recovery-policy", m.updateFabricServiceChannelRecoveryPolicy)
|
|
r.Get("/{clusterID}/fabric/service-channels/adaptive-policy", m.getFabricServiceChannelAdaptivePolicy)
|
|
r.Put("/{clusterID}/fabric/service-channels/adaptive-policy", m.updateFabricServiceChannelAdaptivePolicy)
|
|
r.Get("/{clusterID}/fabric/service-channels/pool-policy", m.getFabricServiceChannelPoolPolicy)
|
|
r.Put("/{clusterID}/fabric/service-channels/pool-policy", m.updateFabricServiceChannelPoolPolicy)
|
|
r.Get("/{clusterID}/fabric/service-channels/breadcrumb-window-policy", m.getFabricServiceChannelBreadcrumbWindowPolicy)
|
|
r.Put("/{clusterID}/fabric/service-channels/breadcrumb-window-policy", m.updateFabricServiceChannelBreadcrumbWindowPolicy)
|
|
r.Post("/{clusterID}/fabric/service-channels/leases", m.issueFabricServiceChannelLease)
|
|
r.Get("/{clusterID}/fabric/service-channels/leases", m.listFabricServiceChannelLeases)
|
|
r.Post("/{clusterID}/fabric/service-channels/leases/cleanup", m.cleanupFabricServiceChannelLeases)
|
|
r.Get("/{clusterID}/fabric/service-channels/access-telemetry", m.getFabricServiceChannelAccessTelemetry)
|
|
r.Post("/{clusterID}/fabric/service-channels/{channelID}/introspect", m.introspectFabricServiceChannelLease)
|
|
r.Post("/{clusterID}/vpn-connection-leases/expire-stale", m.expireStaleVPNConnectionLeases)
|
|
r.Get("/{clusterID}/vpn-connections", m.listVPNConnections)
|
|
r.Post("/{clusterID}/vpn-connections", m.createVPNConnection)
|
|
r.Get("/{clusterID}/vpn-connections/{vpnConnectionID}", m.getVPNConnection)
|
|
r.Put("/{clusterID}/vpn-connections/{vpnConnectionID}/desired-state", m.updateVPNConnectionDesiredState)
|
|
r.Get("/{clusterID}/vpn-connections/{vpnConnectionID}/allowed-nodes", m.listVPNConnectionAllowedNodes)
|
|
r.Put("/{clusterID}/vpn-connections/{vpnConnectionID}/allowed-nodes", m.setVPNConnectionAllowedNodes)
|
|
r.Get("/{clusterID}/vpn-connections/{vpnConnectionID}/route-policies", m.listVPNConnectionRoutePolicies)
|
|
r.Post("/{clusterID}/vpn-connections/{vpnConnectionID}/route-policies", m.upsertVPNConnectionRoutePolicy)
|
|
r.Get("/{clusterID}/vpn-connections/{vpnConnectionID}/leases/active", m.getActiveVPNConnectionLease)
|
|
r.Post("/{clusterID}/vpn-connections/{vpnConnectionID}/leases/acquire", m.acquireVPNConnectionLease)
|
|
r.Post("/{clusterID}/vpn-connections/{vpnConnectionID}/leases/{leaseID}/renew", m.renewVPNConnectionLease)
|
|
r.Post("/{clusterID}/vpn-connections/{vpnConnectionID}/leases/{leaseID}/release", m.releaseVPNConnectionLease)
|
|
r.Post("/{clusterID}/vpn-connections/{vpnConnectionID}/leases/{leaseID}/fence", m.fenceVPNConnectionLease)
|
|
r.Get("/{clusterID}/nodes/{nodeID}/vpn/assignments", m.listNodeVPNAssignments)
|
|
r.Post("/{clusterID}/nodes/{nodeID}/vpn/assignments/{vpnConnectionID}/lease/acquire", m.acquireNodeVPNAssignmentLease)
|
|
r.Post("/{clusterID}/nodes/{nodeID}/vpn/assignments/{vpnConnectionID}/lease/{leaseID}/renew", m.renewNodeVPNAssignmentLease)
|
|
r.Post("/{clusterID}/nodes/{nodeID}/vpn/assignments/{vpnConnectionID}/status", m.reportNodeVPNAssignmentStatus)
|
|
r.Get("/{clusterID}/vpn-connections/{vpnConnectionID}/tunnel/stats", m.getVPNPacketStats)
|
|
r.Post("/{clusterID}/vpn-connections/{vpnConnectionID}/tunnel/reset", m.resetVPNPacketQueues)
|
|
r.Post("/{clusterID}/vpn-connections/{vpnConnectionID}/tunnel/client/packets", m.postVPNClientPacket)
|
|
r.Get("/{clusterID}/vpn-connections/{vpnConnectionID}/tunnel/client/packets", m.getVPNClientPacket)
|
|
r.Post("/{clusterID}/vpn-connections/{vpnConnectionID}/tunnel/gateway/packets", m.postVPNGatewayPacket)
|
|
r.Get("/{clusterID}/vpn-connections/{vpnConnectionID}/tunnel/gateway/packets", m.getVPNGatewayPacket)
|
|
r.Get("/{clusterID}/vpn/client-diagnostics", m.listVPNClientDiagnosticStatuses)
|
|
r.Post("/{clusterID}/vpn/client-diagnostics/{deviceID}/status", m.reportVPNClientDiagnosticStatus)
|
|
r.Get("/{clusterID}/vpn/client-diagnostics/{deviceID}/status", m.getVPNClientDiagnosticStatus)
|
|
r.Post("/{clusterID}/vpn/client-diagnostics/{deviceID}/commands", m.enqueueVPNClientDiagnosticCommand)
|
|
r.Get("/{clusterID}/vpn/client-diagnostics/{deviceID}/commands", m.getVPNClientDiagnosticCommand)
|
|
r.Get("/{clusterID}/vpn/client-profile", m.getVPNClientProfile)
|
|
r.Get("/{clusterID}/authority", m.getClusterAuthority)
|
|
r.Put("/{clusterID}/authority", m.updateClusterAuthority)
|
|
r.Get("/{clusterID}/audit", m.listAuditEvents)
|
|
r.Get("/{clusterID}/events", m.streamClusterEvents)
|
|
})
|
|
router.Get("/cluster-admin-summaries", m.listClusterAdminSummaries)
|
|
router.Get("/fabric/testing-flags", m.listFabricTestingFlags)
|
|
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) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"clusters": items})
|
|
}
|
|
|
|
func (m *Module) getCluster(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetCluster(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{"cluster": item})
|
|
}
|
|
|
|
func (m *Module) createCluster(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Slug string `json:"slug"`
|
|
Name string `json:"name"`
|
|
Region *string `json:"region"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid cluster payload")
|
|
return
|
|
}
|
|
item, err := m.service.CreateCluster(r.Context(), CreateClusterInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
Slug: payload.Slug,
|
|
Name: payload.Name,
|
|
Region: payload.Region,
|
|
Metadata: payload.Metadata,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"cluster": item})
|
|
}
|
|
|
|
func (m *Module) updateCluster(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Name string `json:"name"`
|
|
Status string `json:"status"`
|
|
Region *string `json:"region"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid cluster payload")
|
|
return
|
|
}
|
|
item, err := m.service.UpdateCluster(r.Context(), UpdateClusterInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
Name: payload.Name,
|
|
Status: payload.Status,
|
|
Region: payload.Region,
|
|
Metadata: payload.Metadata,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
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) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"nodes": items})
|
|
}
|
|
|
|
func (m *Module) listNodeGroups(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListNodeGroups(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{"node_groups": items})
|
|
}
|
|
|
|
func (m *Module) createNodeGroup(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
ParentGroupID *string `json:"parent_group_id"`
|
|
Name string `json:"name"`
|
|
Description *string `json:"description"`
|
|
SortOrder int `json:"sort_order"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid node group payload")
|
|
return
|
|
}
|
|
item, err := m.service.CreateNodeGroup(r.Context(), CreateNodeGroupInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
ParentGroupID: payload.ParentGroupID,
|
|
Name: payload.Name,
|
|
Description: payload.Description,
|
|
SortOrder: payload.SortOrder,
|
|
Metadata: payload.Metadata,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"node_group": item})
|
|
}
|
|
|
|
func (m *Module) createJoinToken(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Scope json.RawMessage `json:"scope"`
|
|
ExpiresAt *time.Time `json:"expires_at"`
|
|
MaxUses int `json:"max_uses"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid join token payload")
|
|
return
|
|
}
|
|
expiresAt := time.Time{}
|
|
if payload.ExpiresAt != nil {
|
|
expiresAt = *payload.ExpiresAt
|
|
}
|
|
item, err := m.service.CreateJoinToken(r.Context(), CreateJoinTokenInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
Scope: payload.Scope,
|
|
ExpiresAt: expiresAt,
|
|
MaxUses: payload.MaxUses,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"join_token": item})
|
|
}
|
|
|
|
func (m *Module) createJoinRequest(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
JoinToken string `json:"join_token"`
|
|
NodeName string `json:"node_name"`
|
|
NodeFingerprint string `json:"node_fingerprint"`
|
|
PublicKey string `json:"public_key"`
|
|
ReportedCapabilities json.RawMessage `json:"reported_capabilities"`
|
|
ReportedFacts json.RawMessage `json:"reported_facts"`
|
|
RequestedRoles json.RawMessage `json:"requested_roles"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid join request payload")
|
|
return
|
|
}
|
|
item, err := m.service.CreateJoinRequest(r.Context(), CreateJoinRequestInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
JoinToken: payload.JoinToken,
|
|
NodeName: payload.NodeName,
|
|
NodeFingerprint: payload.NodeFingerprint,
|
|
PublicKey: payload.PublicKey,
|
|
ReportedCapabilities: payload.ReportedCapabilities,
|
|
ReportedFacts: payload.ReportedFacts,
|
|
RequestedRoles: payload.RequestedRoles,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"join_request": item})
|
|
}
|
|
|
|
func (m *Module) listJoinRequests(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListJoinRequests(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{"join_requests": items})
|
|
}
|
|
|
|
func (m *Module) approveJoinRequest(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
NodeKey string `json:"node_key"`
|
|
OwnershipType string `json:"ownership_type"`
|
|
OwnerOrganizationID *string `json:"owner_organization_id"`
|
|
NodeGroupID *string `json:"node_group_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid join request approval payload")
|
|
return
|
|
}
|
|
item, err := m.service.ApproveJoinRequest(r.Context(), ApproveJoinRequestInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
JoinRequestID: chi.URLParam(r, "requestID"),
|
|
NodeKey: payload.NodeKey,
|
|
OwnershipType: payload.OwnershipType,
|
|
OwnerOrganizationID: payload.OwnerOrganizationID,
|
|
NodeGroupID: payload.NodeGroupID,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, item)
|
|
}
|
|
|
|
func (m *Module) rejectJoinRequest(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid join request rejection payload")
|
|
return
|
|
}
|
|
item, err := m.service.RejectJoinRequest(r.Context(), RejectJoinRequestInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
JoinRequestID: chi.URLParam(r, "requestID"),
|
|
Reason: payload.Reason,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"join_request": item})
|
|
}
|
|
|
|
func (m *Module) revokeJoinToken(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid join token revoke payload")
|
|
return
|
|
}
|
|
item, err := m.service.RevokeJoinToken(r.Context(), RevokeJoinTokenInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
TokenID: chi.URLParam(r, "tokenID"),
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"join_token": item})
|
|
}
|
|
|
|
func (m *Module) listJoinTokens(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListJoinTokens(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{"join_tokens": items})
|
|
}
|
|
|
|
func (m *Module) assignNodeRole(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
OrganizationID *string `json:"organization_id"`
|
|
Role string `json:"role"`
|
|
Status string `json:"status"`
|
|
Policy json.RawMessage `json:"policy"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid node role payload")
|
|
return
|
|
}
|
|
item, err := m.service.AssignNodeRole(r.Context(), AssignNodeRoleInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
OrganizationID: payload.OrganizationID,
|
|
Role: payload.Role,
|
|
Status: payload.Status,
|
|
Policy: payload.Policy,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"role_assignment": item})
|
|
}
|
|
|
|
func (m *Module) listNodeRoles(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListNodeRoleAssignments(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID"))
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"role_assignments": items})
|
|
}
|
|
|
|
func (m *Module) recordHeartbeat(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
HealthStatus string `json:"health_status"`
|
|
ReportedVersion *string `json:"reported_version"`
|
|
Capabilities json.RawMessage `json:"capabilities"`
|
|
ServiceStates json.RawMessage `json:"service_states"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid node heartbeat payload")
|
|
return
|
|
}
|
|
item, err := m.service.RecordHeartbeat(r.Context(), RecordHeartbeatInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
HealthStatus: payload.HealthStatus,
|
|
ReportedVersion: payload.ReportedVersion,
|
|
Capabilities: payload.Capabilities,
|
|
ServiceStates: payload.ServiceStates,
|
|
Metadata: payload.Metadata,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
flags, _ := m.service.GetEffectiveNodeTestingFlags(r.Context(), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID"))
|
|
updateHint := m.service.GetNodeUpdateHint(r.Context(), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID"))
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"heartbeat": item, "testing_flags": flags, "update_hint": updateHint})
|
|
}
|
|
|
|
func (m *Module) listNodeHeartbeats(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
items, err := m.service.ListNodeHeartbeats(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID"), limit)
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"heartbeats": items})
|
|
}
|
|
|
|
func (m *Module) createReleaseVersion(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Product string `json:"product"`
|
|
Version string `json:"version"`
|
|
Channel string `json:"channel"`
|
|
Status string `json:"status"`
|
|
Compatibility json.RawMessage `json:"compatibility"`
|
|
Changelog *string `json:"changelog"`
|
|
Artifacts []ReleaseArtifactInput `json:"artifacts"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid release payload")
|
|
return
|
|
}
|
|
item, err := m.service.CreateReleaseVersion(r.Context(), CreateReleaseVersionInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
Product: payload.Product,
|
|
Version: payload.Version,
|
|
Channel: payload.Channel,
|
|
Status: payload.Status,
|
|
Compatibility: payload.Compatibility,
|
|
Changelog: payload.Changelog,
|
|
Artifacts: payload.Artifacts,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"release_version": item})
|
|
}
|
|
|
|
func (m *Module) listReleaseVersions(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListReleaseVersions(
|
|
r.Context(),
|
|
r.URL.Query().Get("actor_user_id"),
|
|
chi.URLParam(r, "clusterID"),
|
|
r.URL.Query().Get("product"),
|
|
r.URL.Query().Get("channel"),
|
|
)
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"release_versions": items})
|
|
}
|
|
|
|
func (m *Module) upsertNodeUpdatePolicy(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Product string `json:"product"`
|
|
Channel string `json:"channel"`
|
|
TargetVersion *string `json:"target_version"`
|
|
Strategy string `json:"strategy"`
|
|
Enabled bool `json:"enabled"`
|
|
RollbackAllowed bool `json:"rollback_allowed"`
|
|
HealthWindowSec int `json:"health_window_seconds"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid update policy payload")
|
|
return
|
|
}
|
|
item, err := m.service.UpsertNodeUpdatePolicy(r.Context(), UpsertNodeUpdatePolicyInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
Product: payload.Product,
|
|
Channel: payload.Channel,
|
|
TargetVersion: payload.TargetVersion,
|
|
Strategy: payload.Strategy,
|
|
Enabled: payload.Enabled,
|
|
RollbackAllowed: payload.RollbackAllowed,
|
|
HealthWindowSec: payload.HealthWindowSec,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"node_update_policy": item})
|
|
}
|
|
|
|
func (m *Module) getNodeUpdatePlan(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetNodeUpdatePlan(r.Context(), GetNodeUpdatePlanInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
Product: r.URL.Query().Get("product"),
|
|
CurrentVersion: r.URL.Query().Get("current_version"),
|
|
OS: r.URL.Query().Get("os"),
|
|
Arch: r.URL.Query().Get("arch"),
|
|
InstallType: r.URL.Query().Get("install_type"),
|
|
Channel: r.URL.Query().Get("channel"),
|
|
ArtifactOrigin: requestOrigin(r),
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"node_update_plan": item})
|
|
}
|
|
|
|
func requestOrigin(r *http.Request) string {
|
|
proto := strings.TrimSpace(r.Header.Get("X-Forwarded-Proto"))
|
|
if proto == "" {
|
|
proto = strings.TrimSpace(r.Header.Get("X-Forwarded-Scheme"))
|
|
}
|
|
if proto == "" {
|
|
if r.TLS != nil {
|
|
proto = "https"
|
|
} else {
|
|
proto = "http"
|
|
}
|
|
}
|
|
host := strings.TrimSpace(r.Header.Get("X-Forwarded-Host"))
|
|
if host == "" {
|
|
host = strings.TrimSpace(r.Host)
|
|
}
|
|
if host == "" {
|
|
return ""
|
|
}
|
|
if comma := strings.Index(host, ","); comma >= 0 {
|
|
host = strings.TrimSpace(host[:comma])
|
|
}
|
|
if comma := strings.Index(proto, ","); comma >= 0 {
|
|
proto = strings.TrimSpace(proto[:comma])
|
|
}
|
|
if !strings.Contains(host, ":") {
|
|
if port := strings.TrimSpace(r.Header.Get("X-Forwarded-Port")); port != "" && port != "80" && port != "443" {
|
|
host += ":" + port
|
|
}
|
|
}
|
|
if proto == "" || host == "" {
|
|
return ""
|
|
}
|
|
return remapDirectBackendDownloadOrigin(proto, host)
|
|
}
|
|
|
|
func remapDirectBackendDownloadOrigin(proto, host string) string {
|
|
httpPort := strings.TrimSpace(os.Getenv("HTTP_PORT"))
|
|
if httpPort == "" {
|
|
httpPort = "18121"
|
|
}
|
|
downloadPort := strings.TrimSpace(os.Getenv("RAP_DOWNLOAD_PORT"))
|
|
if downloadPort == "" {
|
|
downloadPort = "18080"
|
|
}
|
|
suffix := ":" + httpPort
|
|
if !strings.HasSuffix(host, suffix) {
|
|
return proto + "://" + host
|
|
}
|
|
hostOnly := strings.TrimSuffix(host, suffix)
|
|
return proto + "://" + hostOnly + ":" + downloadPort
|
|
}
|
|
|
|
func (m *Module) reportNodeUpdateStatus(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
Product string `json:"product"`
|
|
CurrentVersion string `json:"current_version"`
|
|
TargetVersion string `json:"target_version"`
|
|
Phase string `json:"phase"`
|
|
Status string `json:"status"`
|
|
AttemptID string `json:"attempt_id"`
|
|
ErrorMessage *string `json:"error_message"`
|
|
RollbackVersion *string `json:"rollback_version"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
ObservedAt *time.Time `json:"observed_at"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid update status payload")
|
|
return
|
|
}
|
|
observedAt := time.Time{}
|
|
if payload.ObservedAt != nil {
|
|
observedAt = *payload.ObservedAt
|
|
}
|
|
item, err := m.service.ReportNodeUpdateStatus(r.Context(), ReportNodeUpdateStatusInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
Product: payload.Product,
|
|
CurrentVersion: payload.CurrentVersion,
|
|
TargetVersion: payload.TargetVersion,
|
|
Phase: payload.Phase,
|
|
Status: payload.Status,
|
|
AttemptID: payload.AttemptID,
|
|
ErrorMessage: payload.ErrorMessage,
|
|
RollbackVersion: payload.RollbackVersion,
|
|
Payload: payload.Payload,
|
|
ObservedAt: observedAt,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"node_update_status": item})
|
|
}
|
|
|
|
func (m *Module) listNodeUpdateStatuses(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
items, err := m.service.ListNodeUpdateStatuses(
|
|
r.Context(),
|
|
r.URL.Query().Get("actor_user_id"),
|
|
chi.URLParam(r, "clusterID"),
|
|
chi.URLParam(r, "nodeID"),
|
|
limit,
|
|
)
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"node_update_statuses": items})
|
|
}
|
|
|
|
func (m *Module) getStaleNodeRiskReport(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetStaleNodeRiskReport(r.Context(), GetStaleNodeRiskReportInput{
|
|
ActorUserID: r.URL.Query().Get("actor_user_id"),
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"stale_node_risk_report": item})
|
|
}
|
|
|
|
func (m *Module) getNodeBridgeReplayPlan(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetNodeBridgeReplayPlan(r.Context(), GetNodeBridgeReplayPlanInput{
|
|
ActorUserID: r.URL.Query().Get("actor_user_id"),
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"node_bridge_replay_plan": item})
|
|
}
|
|
|
|
func (m *Module) getEffectiveNodeTestingFlags(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetEffectiveNodeTestingFlags(r.Context(), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID"))
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"testing_flags": item})
|
|
}
|
|
|
|
func (m *Module) getNodeSyntheticMeshConfig(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetNodeSyntheticMeshConfig(r.Context(), GetNodeSyntheticMeshConfigInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"synthetic_mesh_config": item})
|
|
}
|
|
|
|
func (m *Module) recordNodeTelemetry(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
CPUPercent *float64 `json:"cpu_percent"`
|
|
MemoryUsedBytes *int64 `json:"memory_used_bytes"`
|
|
MemoryTotalBytes *int64 `json:"memory_total_bytes"`
|
|
DiskUsedBytes *int64 `json:"disk_used_bytes"`
|
|
DiskTotalBytes *int64 `json:"disk_total_bytes"`
|
|
NetworkRxBytes *int64 `json:"network_rx_bytes"`
|
|
NetworkTxBytes *int64 `json:"network_tx_bytes"`
|
|
ProcessCount *int `json:"process_count"`
|
|
Payload json.RawMessage `json:"payload"`
|
|
ObservedAt *time.Time `json:"observed_at"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid node telemetry payload")
|
|
return
|
|
}
|
|
observedAt := time.Time{}
|
|
if payload.ObservedAt != nil {
|
|
observedAt = *payload.ObservedAt
|
|
}
|
|
item, err := m.service.RecordNodeTelemetry(r.Context(), RecordNodeTelemetryInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
CPUPercent: payload.CPUPercent,
|
|
MemoryUsedBytes: payload.MemoryUsedBytes,
|
|
MemoryTotalBytes: payload.MemoryTotalBytes,
|
|
DiskUsedBytes: payload.DiskUsedBytes,
|
|
DiskTotalBytes: payload.DiskTotalBytes,
|
|
NetworkRxBytes: payload.NetworkRxBytes,
|
|
NetworkTxBytes: payload.NetworkTxBytes,
|
|
ProcessCount: payload.ProcessCount,
|
|
Payload: payload.Payload,
|
|
ObservedAt: observedAt,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"telemetry": item})
|
|
}
|
|
|
|
func (m *Module) listNodeTelemetry(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
items, err := m.service.ListNodeTelemetry(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID"), limit)
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"telemetry": items})
|
|
}
|
|
|
|
func (m *Module) attachExistingNodeToCluster(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Roles []string `json:"roles"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid membership attach payload")
|
|
return
|
|
}
|
|
item, err := m.service.AttachExistingNodeToCluster(r.Context(), AttachExistingNodeInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
Roles: payload.Roles,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"node": item})
|
|
}
|
|
|
|
func (m *Module) assignNodeGroup(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
GroupID *string `json:"group_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid node group assignment payload")
|
|
return
|
|
}
|
|
item, err := m.service.AssignNodeToGroup(r.Context(), AssignNodeGroupInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
GroupID: payload.GroupID,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"node": item})
|
|
}
|
|
|
|
func (m *Module) revokeNodeIdentity(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid node identity revoke payload")
|
|
return
|
|
}
|
|
err := m.service.RevokeNodeIdentity(r.Context(), RevokeNodeIdentityInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
Reason: payload.Reason,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"status": "accepted"})
|
|
}
|
|
|
|
func (m *Module) disableMembership(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid membership disable payload")
|
|
return
|
|
}
|
|
err := m.service.DisableClusterMembership(r.Context(), DisableMembershipInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
Reason: payload.Reason,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"status": "accepted"})
|
|
}
|
|
|
|
func (m *Module) deleteClusterNode(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid node delete payload")
|
|
return
|
|
}
|
|
err := m.service.DeleteClusterNode(r.Context(), DeleteClusterNodeInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
Reason: payload.Reason,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"status": "accepted"})
|
|
}
|
|
|
|
func (m *Module) setDesiredWorkload(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
DesiredState string `json:"desired_state"`
|
|
Version *string `json:"version"`
|
|
RuntimeMode string `json:"runtime_mode"`
|
|
ArtifactRef *string `json:"artifact_ref"`
|
|
Config json.RawMessage `json:"config"`
|
|
Environment json.RawMessage `json:"environment"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid desired workload payload")
|
|
return
|
|
}
|
|
item, err := m.service.SetDesiredWorkload(r.Context(), SetDesiredWorkloadInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
ServiceType: chi.URLParam(r, "serviceType"),
|
|
DesiredState: payload.DesiredState,
|
|
Version: payload.Version,
|
|
RuntimeMode: payload.RuntimeMode,
|
|
ArtifactRef: payload.ArtifactRef,
|
|
Config: payload.Config,
|
|
Environment: payload.Environment,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"desired_workload": item})
|
|
}
|
|
|
|
func (m *Module) listDesiredWorkloads(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListDesiredWorkloads(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID"))
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"desired_workloads": items})
|
|
}
|
|
|
|
func (m *Module) reportWorkloadStatus(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ReportedState string `json:"reported_state"`
|
|
RuntimeMode string `json:"runtime_mode"`
|
|
Version *string `json:"version"`
|
|
StatusPayload json.RawMessage `json:"status_payload"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid workload status payload")
|
|
return
|
|
}
|
|
item, err := m.service.ReportWorkloadStatus(r.Context(), ReportWorkloadStatusInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
ServiceType: chi.URLParam(r, "serviceType"),
|
|
ReportedState: payload.ReportedState,
|
|
RuntimeMode: payload.RuntimeMode,
|
|
Version: payload.Version,
|
|
StatusPayload: payload.StatusPayload,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"workload_status": item})
|
|
}
|
|
|
|
func (m *Module) listWorkloadStatuses(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListLatestWorkloadStatuses(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID"))
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"workload_statuses": items})
|
|
}
|
|
|
|
func (m *Module) reportMeshLink(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
SourceNodeID string `json:"source_node_id"`
|
|
TargetNodeID string `json:"target_node_id"`
|
|
LinkStatus string `json:"link_status"`
|
|
LatencyMs *int `json:"latency_ms"`
|
|
QualityScore *int `json:"quality_score"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid mesh link payload")
|
|
return
|
|
}
|
|
item, err := m.service.ReportMeshLink(r.Context(), ReportMeshLinkInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
SourceNodeID: payload.SourceNodeID,
|
|
TargetNodeID: payload.TargetNodeID,
|
|
LinkStatus: payload.LinkStatus,
|
|
LatencyMs: payload.LatencyMs,
|
|
QualityScore: payload.QualityScore,
|
|
Metadata: payload.Metadata,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"mesh_link": item})
|
|
}
|
|
|
|
func (m *Module) listMeshLinks(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListMeshLinks(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{"mesh_links": items})
|
|
}
|
|
|
|
func (m *Module) createRouteIntent(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
SourceSelector json.RawMessage `json:"source_selector"`
|
|
DestinationSelector json.RawMessage `json:"destination_selector"`
|
|
ServiceClass string `json:"service_class"`
|
|
Priority int `json:"priority"`
|
|
Policy json.RawMessage `json:"policy"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid route intent payload")
|
|
return
|
|
}
|
|
item, err := m.service.CreateRouteIntent(r.Context(), CreateRouteIntentInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
SourceSelector: payload.SourceSelector,
|
|
DestinationSelector: payload.DestinationSelector,
|
|
ServiceClass: payload.ServiceClass,
|
|
Priority: payload.Priority,
|
|
Policy: payload.Policy,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"route_intent": item})
|
|
}
|
|
|
|
func (m *Module) listRouteIntents(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListRouteIntents(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{"route_intents": items})
|
|
}
|
|
|
|
func (m *Module) expireRouteIntent(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid route intent expire payload")
|
|
return
|
|
}
|
|
item, err := m.service.ExpireRouteIntent(r.Context(), RouteIntentLifecycleInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
RouteIntentID: chi.URLParam(r, "routeIntentID"),
|
|
Reason: payload.Reason,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"route_intent": item})
|
|
}
|
|
|
|
func (m *Module) disableRouteIntent(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid route intent disable payload")
|
|
return
|
|
}
|
|
item, err := m.service.DisableRouteIntent(r.Context(), RouteIntentLifecycleInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
RouteIntentID: chi.URLParam(r, "routeIntentID"),
|
|
Reason: payload.Reason,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"route_intent": item})
|
|
}
|
|
|
|
func (m *Module) listQoSPolicies(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListQoSPolicies(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{"qos_policies": items})
|
|
}
|
|
|
|
func (m *Module) issueFabricServiceChannelLease(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
OrganizationID string `json:"organization_id"`
|
|
UserID string `json:"user_id"`
|
|
ResourceID string `json:"resource_id"`
|
|
ServiceClass string `json:"service_class"`
|
|
EntryNodeIDs []string `json:"entry_node_ids"`
|
|
ExitNodeIDs []string `json:"exit_node_ids"`
|
|
PreferredEntryNodeID string `json:"preferred_entry_node_id"`
|
|
PreferredExitNodeID string `json:"preferred_exit_node_id"`
|
|
RequiredRoles []string `json:"required_roles"`
|
|
AllowedChannels []string `json:"allowed_channels"`
|
|
QoS json.RawMessage `json:"qos"`
|
|
Failover json.RawMessage `json:"failover"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
TTLSeconds int `json:"ttl_seconds"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid fabric service channel lease payload")
|
|
return
|
|
}
|
|
item, err := m.service.IssueFabricServiceChannelLease(r.Context(), IssueFabricServiceChannelLeaseInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
OrganizationID: payload.OrganizationID,
|
|
UserID: payload.UserID,
|
|
ResourceID: payload.ResourceID,
|
|
ServiceClass: payload.ServiceClass,
|
|
EntryNodeIDs: payload.EntryNodeIDs,
|
|
ExitNodeIDs: payload.ExitNodeIDs,
|
|
PreferredEntryNodeID: payload.PreferredEntryNodeID,
|
|
PreferredExitNodeID: payload.PreferredExitNodeID,
|
|
RequiredRoles: payload.RequiredRoles,
|
|
AllowedChannels: payload.AllowedChannels,
|
|
QoS: payload.QoS,
|
|
Failover: payload.Failover,
|
|
Metadata: payload.Metadata,
|
|
TTL: time.Duration(payload.TTLSeconds) * time.Second,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"fabric_service_channel_lease": item})
|
|
}
|
|
|
|
func (m *Module) introspectFabricServiceChannelLease(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
Token string `json:"token"`
|
|
ResourceID string `json:"resource_id"`
|
|
ServiceClass string `json:"service_class"`
|
|
ChannelClass string `json:"channel_class"`
|
|
EntryNodeID string `json:"entry_node_id"`
|
|
RequestSourceIP string `json:"request_source_ip"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid fabric service channel introspection payload")
|
|
return
|
|
}
|
|
if payload.EntryNodeID == "" {
|
|
payload.EntryNodeID = r.Header.Get("X-RAP-Entry-Node")
|
|
}
|
|
if payload.RequestSourceIP == "" {
|
|
payload.RequestSourceIP = r.RemoteAddr
|
|
}
|
|
item, err := m.service.IntrospectFabricServiceChannelLease(r.Context(), IntrospectFabricServiceChannelLeaseInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
ChannelID: chi.URLParam(r, "channelID"),
|
|
ResourceID: payload.ResourceID,
|
|
ServiceClass: payload.ServiceClass,
|
|
ChannelClass: payload.ChannelClass,
|
|
Token: payload.Token,
|
|
EntryNodeID: payload.EntryNodeID,
|
|
RequestSourceIP: payload.RequestSourceIP,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
status := http.StatusOK
|
|
if !item.Allowed {
|
|
status = http.StatusForbidden
|
|
}
|
|
httpx.WriteJSON(w, status, map[string]any{"fabric_service_channel_introspection": item})
|
|
}
|
|
|
|
func (m *Module) listFabricServiceChannelLeases(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
items, err := m.service.ListFabricServiceChannelLeases(r.Context(), r.URL.Query().Get("actor_user_id"), ListFabricServiceChannelLeasesInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
ServiceClass: r.URL.Query().Get("service_class"),
|
|
EntryNodeID: r.URL.Query().Get("entry_node_id"),
|
|
ResourceID: r.URL.Query().Get("resource_id"),
|
|
IncludeExpired: r.URL.Query().Get("include_expired") == "true",
|
|
Limit: limit,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"fabric_service_channel_lease_maintenance": items})
|
|
}
|
|
|
|
func (m *Module) cleanupFabricServiceChannelLeases(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Limit int `json:"limit"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil && !errors.Is(err, io.EOF) {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid fabric service channel lease cleanup payload")
|
|
return
|
|
}
|
|
result, err := m.service.CleanupFabricServiceChannelLeases(r.Context(), CleanupFabricServiceChannelLeasesInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
Limit: payload.Limit,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"fabric_service_channel_lease_maintenance": result})
|
|
}
|
|
|
|
func (m *Module) getFabricServiceChannelAccessTelemetry(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
item, err := m.service.GetFabricServiceChannelAccessTelemetry(r.Context(), r.URL.Query().Get("actor_user_id"), GetFabricServiceChannelAccessTelemetryInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
Limit: limit,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"fabric_service_channel_access_telemetry": item})
|
|
}
|
|
|
|
func (m *Module) listFabricServiceChannelRouteFeedback(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListFabricServiceChannelRouteFeedback(r.Context(), r.URL.Query().Get("actor_user_id"), ListFabricServiceChannelRouteFeedbackInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
ReporterNodeID: r.URL.Query().Get("reporter_node_id"),
|
|
RouteID: r.URL.Query().Get("route_id"),
|
|
ServiceClass: r.URL.Query().Get("service_class"),
|
|
FeedbackStatus: r.URL.Query().Get("feedback_status"),
|
|
IncludeExpired: r.URL.Query().Get("include_expired") == "true",
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"route_feedback": items})
|
|
}
|
|
|
|
func (m *Module) listFabricServiceChannelRouteRebuildAttempts(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
offset, _ := strconv.Atoi(r.URL.Query().Get("offset"))
|
|
items, err := m.service.ListFabricServiceChannelRouteRebuildAttempts(r.Context(), r.URL.Query().Get("actor_user_id"), ListFabricServiceChannelRouteRebuildAttemptsInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
ReporterNodeID: r.URL.Query().Get("reporter_node_id"),
|
|
RouteID: r.URL.Query().Get("route_id"),
|
|
ReplacementRouteID: r.URL.Query().Get("replacement_route_id"),
|
|
ServiceClass: r.URL.Query().Get("service_class"),
|
|
RebuildStatus: r.URL.Query().Get("rebuild_status"),
|
|
RebuildRequestID: r.URL.Query().Get("rebuild_request_id"),
|
|
Generation: r.URL.Query().Get("generation"),
|
|
FeedbackSource: r.URL.Query().Get("feedback_source"),
|
|
FeedbackChannelID: r.URL.Query().Get("feedback_channel_id"),
|
|
FeedbackViolationStatus: r.URL.Query().Get("feedback_violation_status"),
|
|
EnrichmentMode: r.URL.Query().Get("enrichment"),
|
|
Limit: limit,
|
|
Offset: offset,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"rebuild_attempts": items})
|
|
}
|
|
|
|
func (m *Module) getFabricServiceChannelRouteRebuildHealthSummary(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
summary, err := m.service.GetFabricServiceChannelRouteRebuildHealthSummary(r.Context(), r.URL.Query().Get("actor_user_id"), GetFabricServiceChannelRouteRebuildHealthSummaryInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
Limit: limit,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"rebuild_health": summary})
|
|
}
|
|
|
|
func (m *Module) getFabricServiceChannelReadiness(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
readiness, err := m.service.GetFabricServiceChannelReadiness(r.Context(), r.URL.Query().Get("actor_user_id"), GetFabricServiceChannelReadinessInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
Limit: limit,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"fabric_service_channel_readiness": readiness})
|
|
}
|
|
|
|
func (m *Module) getFabricServiceChannelSchemaStatus(w http.ResponseWriter, r *http.Request) {
|
|
status, err := m.service.GetFabricServiceChannelSchemaStatus(r.Context(), r.URL.Query().Get("actor_user_id"), GetFabricServiceChannelSchemaStatusInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"fabric_service_channel_schema_status": status})
|
|
}
|
|
|
|
func (m *Module) getFabricServiceChannelRebuildSnapshotMaintenanceHealth(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
minAgeSeconds, _ := strconv.ParseInt(r.URL.Query().Get("min_age_seconds"), 10, 64)
|
|
heartbeatThreshold, _ := strconv.Atoi(r.URL.Query().Get("heartbeat_threshold"))
|
|
health, err := m.service.GetFabricServiceChannelRebuildSnapshotMaintenanceHealth(r.Context(), r.URL.Query().Get("actor_user_id"), GetFabricServiceChannelRebuildSnapshotMaintenanceHealthInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
Limit: limit,
|
|
MinAgeSeconds: minAgeSeconds,
|
|
HeartbeatThreshold: heartbeatThreshold,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"rebuild_snapshot_health": health})
|
|
}
|
|
|
|
func (m *Module) warmupFabricServiceChannelRebuildSnapshots(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Limit int `json:"limit"`
|
|
StaleAfterSeconds int64 `json:"stale_after_seconds"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil && !errors.Is(err, io.EOF) {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid rebuild snapshot warmup payload")
|
|
return
|
|
}
|
|
result, err := m.service.WarmupFabricServiceChannelRebuildSnapshots(r.Context(), WarmupFabricServiceChannelRebuildSnapshotsInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
Limit: payload.Limit,
|
|
StaleAfterSeconds: payload.StaleAfterSeconds,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"rebuild_snapshot_warmup": result})
|
|
}
|
|
|
|
func (m *Module) listFabricServiceChannelRouteRebuildIncidents(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
incidents, err := m.service.ListFabricServiceChannelRouteRebuildIncidents(r.Context(), r.URL.Query().Get("actor_user_id"), ListFabricServiceChannelRouteRebuildIncidentsInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
Limit: limit,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"rebuild_incidents": incidents})
|
|
}
|
|
|
|
func (m *Module) recordFabricServiceChannelRouteRebuildInvestigation(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
ReporterNodeID string `json:"reporter_node_id"`
|
|
RouteID string `json:"route_id"`
|
|
ServiceClass string `json:"service_class"`
|
|
Generation string `json:"generation"`
|
|
GuardStatus string `json:"guard_status"`
|
|
IncidentID string `json:"incident_id"`
|
|
FeedbackSource string `json:"feedback_source"`
|
|
FeedbackChannelID string `json:"feedback_channel_id"`
|
|
FeedbackViolationStatus string `json:"feedback_violation_status"`
|
|
DrilldownSource string `json:"drilldown_source"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid rebuild investigation payload")
|
|
return
|
|
}
|
|
if err := m.service.RecordFabricServiceChannelRouteRebuildInvestigation(r.Context(), RecordFabricServiceChannelRouteRebuildInvestigationInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
ReporterNodeID: payload.ReporterNodeID,
|
|
RouteID: payload.RouteID,
|
|
ServiceClass: payload.ServiceClass,
|
|
Generation: payload.Generation,
|
|
GuardStatus: payload.GuardStatus,
|
|
IncidentID: payload.IncidentID,
|
|
FeedbackSource: payload.FeedbackSource,
|
|
FeedbackChannelID: payload.FeedbackChannelID,
|
|
FeedbackViolationStatus: payload.FeedbackViolationStatus,
|
|
DrilldownSource: payload.DrilldownSource,
|
|
Reason: payload.Reason,
|
|
}); writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"status": "recorded"})
|
|
}
|
|
|
|
func (m *Module) listFabricServiceChannelRebuildInvestigationBreadcrumbs(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
currentWindowSeconds, _ := strconv.ParseInt(r.URL.Query().Get("current_window_seconds"), 10, 64)
|
|
historyWindowSeconds, _ := strconv.ParseInt(r.URL.Query().Get("history_window_seconds"), 10, 64)
|
|
breadcrumbs, err := m.service.ListFabricServiceChannelRebuildInvestigationBreadcrumbs(r.Context(), r.URL.Query().Get("actor_user_id"), ListFabricServiceChannelRebuildInvestigationBreadcrumbsInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
Limit: limit,
|
|
CurrentWindowSeconds: currentWindowSeconds,
|
|
HistoryWindowSeconds: historyWindowSeconds,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"rebuild_investigation_breadcrumbs": breadcrumbs})
|
|
}
|
|
|
|
func (m *Module) silenceFabricServiceChannelRouteRebuildAlert(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
IncidentSource string `json:"incident_source"`
|
|
ChannelID string `json:"channel_id"`
|
|
ReporterNodeID string `json:"reporter_node_id"`
|
|
RouteID string `json:"route_id"`
|
|
GuardStatus string `json:"guard_status"`
|
|
Generation string `json:"generation"`
|
|
Reason string `json:"reason"`
|
|
TTLSeconds int64 `json:"ttl_seconds"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid rebuild alert silence payload")
|
|
return
|
|
}
|
|
silence, err := m.service.SilenceFabricServiceChannelRouteRebuildAlert(r.Context(), SilenceFabricServiceChannelRouteRebuildAlertInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
IncidentSource: payload.IncidentSource,
|
|
ChannelID: payload.ChannelID,
|
|
ReporterNodeID: payload.ReporterNodeID,
|
|
RouteID: payload.RouteID,
|
|
GuardStatus: payload.GuardStatus,
|
|
Generation: payload.Generation,
|
|
Reason: payload.Reason,
|
|
TTL: time.Duration(payload.TTLSeconds) * time.Second,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"rebuild_alert_silence": silence})
|
|
}
|
|
|
|
func (m *Module) listFabricServiceChannelRouteRebuildAlertSilences(w http.ResponseWriter, r *http.Request) {
|
|
silences, err := m.service.ListFabricServiceChannelRouteRebuildAlertSilences(
|
|
r.Context(),
|
|
r.URL.Query().Get("actor_user_id"),
|
|
chi.URLParam(r, "clusterID"),
|
|
time.Time{},
|
|
)
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"rebuild_alert_silences": silences})
|
|
}
|
|
|
|
func (m *Module) unsilenceFabricServiceChannelRouteRebuildAlert(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if r.Body != nil {
|
|
_ = json.NewDecoder(r.Body).Decode(&payload)
|
|
}
|
|
if payload.ActorUserID == "" {
|
|
payload.ActorUserID = r.URL.Query().Get("actor_user_id")
|
|
}
|
|
silence, err := m.service.UnsilenceFabricServiceChannelRouteRebuildAlert(r.Context(), UnsilenceFabricServiceChannelRouteRebuildAlertInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
SilenceID: chi.URLParam(r, "silenceID"),
|
|
Reason: payload.Reason,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"rebuild_alert_silence": silence})
|
|
}
|
|
|
|
func (m *Module) expireFabricServiceChannelRouteFeedback(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
ReporterNodeID string `json:"reporter_node_id"`
|
|
RouteID string `json:"route_id"`
|
|
ServiceClass string `json:"service_class"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid route feedback expire payload")
|
|
return
|
|
}
|
|
result, err := m.service.ExpireFabricServiceChannelRouteFeedback(r.Context(), ExpireFabricServiceChannelRouteFeedbackInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
ReporterNodeID: payload.ReporterNodeID,
|
|
RouteID: payload.RouteID,
|
|
ServiceClass: payload.ServiceClass,
|
|
Reason: payload.Reason,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"route_feedback_expire": result})
|
|
}
|
|
|
|
func (m *Module) getFabricServiceChannelRecoveryPolicy(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetFabricServiceChannelRecoveryPolicy(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{"fabric_service_channel_recovery_policy": item})
|
|
}
|
|
|
|
func (m *Module) updateFabricServiceChannelRecoveryPolicy(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
HysteresisPenalty int `json:"hysteresis_penalty"`
|
|
PromotionMinSamples int `json:"promotion_min_samples"`
|
|
DemotionFailureThreshold int `json:"demotion_failure_threshold"`
|
|
DemotionDropThreshold int `json:"demotion_drop_threshold"`
|
|
DemotionSlowThreshold int `json:"demotion_slow_threshold"`
|
|
DemotionRebuildEnabled *bool `json:"demotion_rebuild_enabled"`
|
|
DemotionFencedEnabled *bool `json:"demotion_fenced_enabled"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid recovery policy payload")
|
|
return
|
|
}
|
|
item, err := m.service.UpdateFabricServiceChannelRecoveryPolicy(r.Context(), UpdateFabricServiceChannelRecoveryPolicyInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
HysteresisPenalty: payload.HysteresisPenalty,
|
|
PromotionMinSamples: payload.PromotionMinSamples,
|
|
DemotionFailureThreshold: payload.DemotionFailureThreshold,
|
|
DemotionDropThreshold: payload.DemotionDropThreshold,
|
|
DemotionSlowThreshold: payload.DemotionSlowThreshold,
|
|
DemotionRebuildEnabled: payload.DemotionRebuildEnabled,
|
|
DemotionFencedEnabled: payload.DemotionFencedEnabled,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"fabric_service_channel_recovery_policy": item})
|
|
}
|
|
|
|
func (m *Module) getFabricServiceChannelAdaptivePolicy(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetFabricServiceChannelAdaptivePolicy(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{"fabric_service_channel_adaptive_policy": item})
|
|
}
|
|
|
|
func (m *Module) updateFabricServiceChannelAdaptivePolicy(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
MaxParallelWindow int `json:"max_parallel_window"`
|
|
BulkPressureChannelThreshold int `json:"bulk_pressure_channel_threshold"`
|
|
QueuePressureHighWatermark int `json:"queue_pressure_high_watermark"`
|
|
QueuePressureMaxInFlight int `json:"queue_pressure_max_in_flight"`
|
|
ClassWindows map[string]int `json:"class_windows"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid adaptive policy payload")
|
|
return
|
|
}
|
|
item, err := m.service.UpdateFabricServiceChannelAdaptivePolicy(r.Context(), UpdateFabricServiceChannelAdaptivePolicyInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
MaxParallelWindow: payload.MaxParallelWindow,
|
|
BulkPressureChannelThreshold: payload.BulkPressureChannelThreshold,
|
|
QueuePressureHighWatermark: payload.QueuePressureHighWatermark,
|
|
QueuePressureMaxInFlight: payload.QueuePressureMaxInFlight,
|
|
ClassWindows: payload.ClassWindows,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"fabric_service_channel_adaptive_policy": item})
|
|
}
|
|
|
|
func (m *Module) getFabricServiceChannelPoolPolicy(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetFabricServiceChannelPoolPolicy(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{"fabric_service_channel_pool_policy": item})
|
|
}
|
|
|
|
func (m *Module) updateFabricServiceChannelPoolPolicy(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
EntryPoolNodeIDs []string `json:"entry_pool_node_ids"`
|
|
ExitPoolNodeIDs []string `json:"exit_pool_node_ids"`
|
|
PreferredEntryNodeID string `json:"preferred_entry_node_id"`
|
|
PreferredExitNodeID string `json:"preferred_exit_node_id"`
|
|
SelectionStrategy string `json:"selection_strategy"`
|
|
RouteRebuild string `json:"route_rebuild"`
|
|
EntryFailover string `json:"entry_failover"`
|
|
ExitFailover string `json:"exit_failover"`
|
|
BackendFallbackAllowed *bool `json:"backend_fallback_allowed"`
|
|
StickySession *bool `json:"sticky_session"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid pool policy payload")
|
|
return
|
|
}
|
|
item, err := m.service.UpdateFabricServiceChannelPoolPolicy(r.Context(), UpdateFabricServiceChannelPoolPolicyInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
EntryPoolNodeIDs: payload.EntryPoolNodeIDs,
|
|
ExitPoolNodeIDs: payload.ExitPoolNodeIDs,
|
|
PreferredEntryNodeID: payload.PreferredEntryNodeID,
|
|
PreferredExitNodeID: payload.PreferredExitNodeID,
|
|
SelectionStrategy: payload.SelectionStrategy,
|
|
RouteRebuild: payload.RouteRebuild,
|
|
EntryFailover: payload.EntryFailover,
|
|
ExitFailover: payload.ExitFailover,
|
|
BackendFallbackAllowed: payload.BackendFallbackAllowed,
|
|
StickySession: payload.StickySession,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"fabric_service_channel_pool_policy": item})
|
|
}
|
|
|
|
func (m *Module) getFabricServiceChannelBreadcrumbWindowPolicy(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetFabricServiceChannelBreadcrumbWindowPolicy(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{"fabric_service_channel_breadcrumb_window_policy": item})
|
|
}
|
|
|
|
func (m *Module) updateFabricServiceChannelBreadcrumbWindowPolicy(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
CurrentWindowSeconds int64 `json:"current_window_seconds"`
|
|
HistoryWindowSeconds int64 `json:"history_window_seconds"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid service-channel breadcrumb window policy payload")
|
|
return
|
|
}
|
|
item, err := m.service.UpdateFabricServiceChannelBreadcrumbWindowPolicy(r.Context(), UpdateFabricServiceChannelBreadcrumbWindowPolicyInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
CurrentWindowSeconds: payload.CurrentWindowSeconds,
|
|
HistoryWindowSeconds: payload.HistoryWindowSeconds,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"fabric_service_channel_breadcrumb_window_policy": item})
|
|
}
|
|
|
|
func (m *Module) createVPNConnection(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
OrganizationID string `json:"organization_id"`
|
|
Name string `json:"name"`
|
|
TargetEndpoint json.RawMessage `json:"target_endpoint"`
|
|
ProtocolFamily string `json:"protocol_family"`
|
|
CredentialRef *string `json:"credential_ref"`
|
|
Mode string `json:"mode"`
|
|
DesiredState string `json:"desired_state"`
|
|
AllowedNodePolicy json.RawMessage `json:"allowed_node_policy"`
|
|
RoutingUsage json.RawMessage `json:"routing_usage"`
|
|
RoutePolicy json.RawMessage `json:"route_policy"`
|
|
QoSPolicy json.RawMessage `json:"qos_policy"`
|
|
PlacementPolicy json.RawMessage `json:"placement_policy"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn connection payload")
|
|
return
|
|
}
|
|
item, err := m.service.CreateVPNConnection(r.Context(), CreateVPNConnectionInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
OrganizationID: payload.OrganizationID,
|
|
Name: payload.Name,
|
|
TargetEndpoint: payload.TargetEndpoint,
|
|
ProtocolFamily: payload.ProtocolFamily,
|
|
CredentialRef: payload.CredentialRef,
|
|
Mode: payload.Mode,
|
|
DesiredState: payload.DesiredState,
|
|
AllowedNodePolicy: payload.AllowedNodePolicy,
|
|
RoutingUsage: payload.RoutingUsage,
|
|
RoutePolicy: payload.RoutePolicy,
|
|
QoSPolicy: payload.QoSPolicy,
|
|
PlacementPolicy: payload.PlacementPolicy,
|
|
Metadata: payload.Metadata,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"vpn_connection": item})
|
|
}
|
|
|
|
func (m *Module) listVPNConnections(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListVPNConnections(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{"vpn_connections": items})
|
|
}
|
|
|
|
func (m *Module) getVPNConnection(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetVPNConnection(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID"), chi.URLParam(r, "vpnConnectionID"))
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"vpn_connection": item})
|
|
}
|
|
|
|
func (m *Module) updateVPNConnectionDesiredState(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
DesiredState string `json:"desired_state"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn connection desired state payload")
|
|
return
|
|
}
|
|
item, err := m.service.UpdateVPNConnectionDesiredState(r.Context(), UpdateVPNConnectionDesiredStateInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
|
DesiredState: payload.DesiredState,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"vpn_connection": item})
|
|
}
|
|
|
|
func (m *Module) upsertVPNConnectionRoutePolicy(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
RouteType string `json:"route_type"`
|
|
Destination string `json:"destination"`
|
|
Action string `json:"action"`
|
|
ServiceType *string `json:"service_type"`
|
|
Priority int `json:"priority"`
|
|
Policy json.RawMessage `json:"policy"`
|
|
Status string `json:"status"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn route policy payload")
|
|
return
|
|
}
|
|
item, err := m.service.UpsertVPNConnectionRoutePolicy(r.Context(), UpsertVPNConnectionRoutePolicyInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
|
RouteType: payload.RouteType,
|
|
Destination: payload.Destination,
|
|
Action: payload.Action,
|
|
ServiceType: payload.ServiceType,
|
|
Priority: payload.Priority,
|
|
Policy: payload.Policy,
|
|
Status: payload.Status,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"route_policy": item})
|
|
}
|
|
|
|
func (m *Module) listVPNConnectionRoutePolicies(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListVPNConnectionRoutePolicies(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID"), chi.URLParam(r, "vpnConnectionID"))
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"route_policies": items})
|
|
}
|
|
|
|
func (m *Module) setVPNConnectionAllowedNodes(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
NodeIDs []string `json:"node_ids"`
|
|
RolePreference string `json:"role_preference"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn allowed nodes payload")
|
|
return
|
|
}
|
|
items, err := m.service.SetVPNConnectionAllowedNodes(r.Context(), SetVPNConnectionAllowedNodesInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
|
NodeIDs: payload.NodeIDs,
|
|
RolePreference: payload.RolePreference,
|
|
Metadata: payload.Metadata,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"allowed_nodes": items})
|
|
}
|
|
|
|
func (m *Module) listVPNConnectionAllowedNodes(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListVPNConnectionAllowedNodes(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID"), chi.URLParam(r, "vpnConnectionID"))
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"allowed_nodes": items})
|
|
}
|
|
|
|
func (m *Module) acquireVPNConnectionLease(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
OwnerNodeID string `json:"owner_node_id"`
|
|
TTLSeconds int `json:"ttl_seconds"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn lease acquire payload")
|
|
return
|
|
}
|
|
item, err := m.service.AcquireVPNConnectionLease(r.Context(), AcquireVPNConnectionLeaseInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
|
OwnerNodeID: payload.OwnerNodeID,
|
|
TTL: time.Duration(payload.TTLSeconds) * time.Second,
|
|
Metadata: payload.Metadata,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"lease": item})
|
|
}
|
|
|
|
func (m *Module) renewVPNConnectionLease(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
OwnerNodeID string `json:"owner_node_id"`
|
|
FencingToken string `json:"fencing_token"`
|
|
TTLSeconds int `json:"ttl_seconds"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn lease renew payload")
|
|
return
|
|
}
|
|
item, err := m.service.RenewVPNConnectionLease(r.Context(), RenewVPNConnectionLeaseInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
|
LeaseID: chi.URLParam(r, "leaseID"),
|
|
OwnerNodeID: payload.OwnerNodeID,
|
|
FencingToken: payload.FencingToken,
|
|
TTL: time.Duration(payload.TTLSeconds) * time.Second,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"lease": item})
|
|
}
|
|
|
|
func (m *Module) releaseVPNConnectionLease(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
OwnerNodeID string `json:"owner_node_id"`
|
|
FencingToken string `json:"fencing_token"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn lease release payload")
|
|
return
|
|
}
|
|
item, err := m.service.ReleaseVPNConnectionLease(r.Context(), ReleaseVPNConnectionLeaseInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
|
LeaseID: chi.URLParam(r, "leaseID"),
|
|
OwnerNodeID: payload.OwnerNodeID,
|
|
FencingToken: payload.FencingToken,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"lease": item})
|
|
}
|
|
|
|
func (m *Module) fenceVPNConnectionLease(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
Reason string `json:"reason"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn lease fence payload")
|
|
return
|
|
}
|
|
item, err := m.service.FenceVPNConnectionLease(r.Context(), FenceVPNConnectionLeaseInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
|
LeaseID: chi.URLParam(r, "leaseID"),
|
|
Reason: payload.Reason,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"lease": item})
|
|
}
|
|
|
|
func (m *Module) getActiveVPNConnectionLease(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetActiveVPNConnectionLease(r.Context(), r.URL.Query().Get("actor_user_id"), chi.URLParam(r, "clusterID"), chi.URLParam(r, "vpnConnectionID"))
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"lease": item})
|
|
}
|
|
|
|
func (m *Module) expireStaleVPNConnectionLeases(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn stale lease payload")
|
|
return
|
|
}
|
|
items, err := m.service.ExpireStaleVPNConnectionLeases(r.Context(), ExpireStaleVPNConnectionLeasesInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"expired_leases": items})
|
|
}
|
|
|
|
func (m *Module) listNodeVPNAssignments(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListNodeVPNAssignments(r.Context(), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID"))
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"vpn_assignments": items})
|
|
}
|
|
|
|
func (m *Module) acquireNodeVPNAssignmentLease(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
TTLSeconds int `json:"ttl_seconds"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn node lease acquire payload")
|
|
return
|
|
}
|
|
item, err := m.service.AcquireNodeVPNAssignmentLease(r.Context(), AcquireNodeVPNAssignmentLeaseInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
|
OwnerNodeID: chi.URLParam(r, "nodeID"),
|
|
TTL: time.Duration(payload.TTLSeconds) * time.Second,
|
|
Metadata: payload.Metadata,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusCreated, map[string]any{"lease": NodeVPNAssignmentLease{
|
|
LeaseID: item.ID,
|
|
OwnerNodeID: item.OwnerNodeID,
|
|
LeaseGeneration: item.LeaseGeneration,
|
|
Status: item.Status,
|
|
RenewedAt: item.RenewedAt,
|
|
ExpiresAt: item.ExpiresAt,
|
|
}})
|
|
}
|
|
|
|
func (m *Module) renewNodeVPNAssignmentLease(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
TTLSeconds int `json:"ttl_seconds"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn node lease renew payload")
|
|
return
|
|
}
|
|
item, err := m.service.RenewNodeVPNAssignmentLease(r.Context(), RenewNodeVPNAssignmentLeaseInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
|
LeaseID: chi.URLParam(r, "leaseID"),
|
|
OwnerNodeID: chi.URLParam(r, "nodeID"),
|
|
TTL: time.Duration(payload.TTLSeconds) * time.Second,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"lease": NodeVPNAssignmentLease{
|
|
LeaseID: item.ID,
|
|
OwnerNodeID: item.OwnerNodeID,
|
|
LeaseGeneration: item.LeaseGeneration,
|
|
Status: item.Status,
|
|
RenewedAt: item.RenewedAt,
|
|
ExpiresAt: item.ExpiresAt,
|
|
}})
|
|
}
|
|
|
|
func (m *Module) reportNodeVPNAssignmentStatus(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ObservedStatus string `json:"observed_status"`
|
|
StatusPayload json.RawMessage `json:"status_payload"`
|
|
ObservedAt time.Time `json:"observed_at"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn assignment status payload")
|
|
return
|
|
}
|
|
item, err := m.service.ReportNodeVPNAssignmentStatus(r.Context(), ReportNodeVPNAssignmentStatusInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
NodeID: chi.URLParam(r, "nodeID"),
|
|
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
|
ObservedStatus: payload.ObservedStatus,
|
|
StatusPayload: payload.StatusPayload,
|
|
ObservedAt: payload.ObservedAt,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"vpn_assignment_status": item})
|
|
}
|
|
|
|
func (m *Module) getVPNClientProfile(w http.ResponseWriter, r *http.Request) {
|
|
preferredEntryNodeID := strings.TrimSpace(r.URL.Query().Get("entry_node_id"))
|
|
if preferredEntryNodeID == "" {
|
|
preferredEntryNodeID = strings.TrimSpace(r.Header.Get("X-RAP-Entry-Node"))
|
|
}
|
|
preferredExitNodeID := strings.TrimSpace(r.URL.Query().Get("exit_node_id"))
|
|
if preferredExitNodeID == "" {
|
|
preferredExitNodeID = strings.TrimSpace(r.Header.Get("X-RAP-Exit-Node"))
|
|
}
|
|
item, err := m.service.GetVPNClientProfile(
|
|
r.Context(),
|
|
chi.URLParam(r, "clusterID"),
|
|
r.URL.Query().Get("organization_id"),
|
|
r.URL.Query().Get("user_id"),
|
|
preferredEntryNodeID,
|
|
preferredExitNodeID,
|
|
)
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"vpn_client_profile": item})
|
|
}
|
|
|
|
func (m *Module) getVPNPacketStats(w http.ResponseWriter, r *http.Request) {
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
|
"vpn_packet_stats": m.vpnPacketHub.Snapshot(
|
|
chi.URLParam(r, "clusterID"),
|
|
chi.URLParam(r, "vpnConnectionID"),
|
|
),
|
|
})
|
|
}
|
|
|
|
func (m *Module) reportVPNClientDiagnosticStatus(w http.ResponseWriter, r *http.Request) {
|
|
var payload map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn client diagnostic status payload")
|
|
return
|
|
}
|
|
item := m.vpnClientDiagnosticHub.Report(
|
|
chi.URLParam(r, "clusterID"),
|
|
chi.URLParam(r, "deviceID"),
|
|
payload,
|
|
)
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"vpn_client_diagnostic_status": item})
|
|
}
|
|
|
|
func (m *Module) listVPNClientDiagnosticStatuses(w http.ResponseWriter, r *http.Request) {
|
|
items := m.vpnClientDiagnosticHub.List(chi.URLParam(r, "clusterID"))
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"vpn_client_diagnostic_statuses": items})
|
|
}
|
|
|
|
func (m *Module) getVPNClientDiagnosticStatus(w http.ResponseWriter, r *http.Request) {
|
|
item, ok := m.vpnClientDiagnosticHub.Status(
|
|
chi.URLParam(r, "clusterID"),
|
|
chi.URLParam(r, "deviceID"),
|
|
)
|
|
if !ok {
|
|
httpx.WriteError(w, http.StatusNotFound, "vpn client diagnostic status not found")
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"vpn_client_diagnostic_status": item})
|
|
}
|
|
|
|
func (m *Module) enqueueVPNClientDiagnosticCommand(w http.ResponseWriter, r *http.Request) {
|
|
var payload map[string]any
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn client diagnostic command payload")
|
|
return
|
|
}
|
|
commandType, _ := payload["type"].(string)
|
|
if strings.TrimSpace(commandType) == "" {
|
|
httpx.WriteError(w, http.StatusBadRequest, "vpn client diagnostic command type is required")
|
|
return
|
|
}
|
|
item := m.vpnClientDiagnosticHub.Enqueue(
|
|
chi.URLParam(r, "clusterID"),
|
|
chi.URLParam(r, "deviceID"),
|
|
payload,
|
|
)
|
|
httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"vpn_client_diagnostic_command": item})
|
|
}
|
|
|
|
func (m *Module) getVPNClientDiagnosticCommand(w http.ResponseWriter, r *http.Request) {
|
|
timeout := 25 * time.Second
|
|
if raw := r.URL.Query().Get("timeout_ms"); raw != "" {
|
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 && parsed <= 30000 {
|
|
timeout = time.Duration(parsed) * time.Millisecond
|
|
}
|
|
}
|
|
item, ok := m.vpnClientDiagnosticHub.Pop(
|
|
r.Context(),
|
|
chi.URLParam(r, "clusterID"),
|
|
chi.URLParam(r, "deviceID"),
|
|
timeout,
|
|
)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"vpn_client_diagnostic_command": item})
|
|
}
|
|
|
|
func (m *Module) postVPNClientPacket(w http.ResponseWriter, r *http.Request) {
|
|
m.handleVPNPacketPost(w, r, vpnDirectionClientToGateway)
|
|
}
|
|
|
|
func (m *Module) getVPNClientPacket(w http.ResponseWriter, r *http.Request) {
|
|
m.handleVPNPacketGet(w, r, vpnDirectionGatewayToClient)
|
|
}
|
|
|
|
func (m *Module) postVPNGatewayPacket(w http.ResponseWriter, r *http.Request) {
|
|
m.handleVPNPacketPost(w, r, vpnDirectionGatewayToClient)
|
|
}
|
|
|
|
func (m *Module) getVPNGatewayPacket(w http.ResponseWriter, r *http.Request) {
|
|
m.handleVPNPacketGet(w, r, vpnDirectionClientToGateway)
|
|
}
|
|
|
|
func (m *Module) resetVPNPacketQueues(w http.ResponseWriter, r *http.Request) {
|
|
clusterID := chi.URLParam(r, "clusterID")
|
|
vpnConnectionID := chi.URLParam(r, "vpnConnectionID")
|
|
clientToGateway := m.vpnPacketHub.Clear(vpnPacketKey{
|
|
ClusterID: clusterID,
|
|
VPNConnectionID: vpnConnectionID,
|
|
Direction: vpnDirectionClientToGateway,
|
|
})
|
|
gatewayToClient := m.vpnPacketHub.Clear(vpnPacketKey{
|
|
ClusterID: clusterID,
|
|
VPNConnectionID: vpnConnectionID,
|
|
Direction: vpnDirectionGatewayToClient,
|
|
})
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
|
"vpn_packet_queues_reset": map[string]any{
|
|
"client_to_gateway": clientToGateway,
|
|
"gateway_to_client": gatewayToClient,
|
|
},
|
|
})
|
|
}
|
|
|
|
func (m *Module) handleVPNPacketPost(w http.ResponseWriter, r *http.Request, direction string) {
|
|
maxBodyBytes := int64(vpnPacketMaxBytes)
|
|
if r.URL.Query().Get("batch") == "true" {
|
|
maxBodyBytes = int64(vpnPacketBatchMaxBytes)
|
|
}
|
|
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, maxBodyBytes))
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid vpn packet payload")
|
|
return
|
|
}
|
|
if len(body) == 0 {
|
|
httpx.WriteError(w, http.StatusBadRequest, "empty vpn packet payload")
|
|
return
|
|
}
|
|
key := vpnPacketKey{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
|
Direction: direction,
|
|
}
|
|
if r.URL.Query().Get("batch") == "true" {
|
|
packets, err := decodeVPNPacketBatch(body)
|
|
if err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
|
return
|
|
}
|
|
for _, packet := range packets {
|
|
if err := m.vpnPacketHub.Push(key, packet); err != nil {
|
|
httpx.WriteError(w, http.StatusServiceUnavailable, err.Error())
|
|
return
|
|
}
|
|
}
|
|
w.WriteHeader(http.StatusAccepted)
|
|
return
|
|
}
|
|
if err := m.vpnPacketHub.Push(key, body); err != nil {
|
|
httpx.WriteError(w, http.StatusServiceUnavailable, err.Error())
|
|
return
|
|
}
|
|
w.WriteHeader(http.StatusAccepted)
|
|
}
|
|
|
|
func (m *Module) handleVPNPacketGet(w http.ResponseWriter, r *http.Request, direction string) {
|
|
timeout := 25 * time.Second
|
|
if raw := r.URL.Query().Get("timeout_ms"); raw != "" {
|
|
if parsed, err := strconv.Atoi(raw); err == nil && parsed >= 0 && parsed <= 30000 {
|
|
timeout = time.Duration(parsed) * time.Millisecond
|
|
}
|
|
}
|
|
key := vpnPacketKey{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
VPNConnectionID: chi.URLParam(r, "vpnConnectionID"),
|
|
Direction: direction,
|
|
}
|
|
if r.URL.Query().Get("batch") == "true" {
|
|
packets := m.vpnPacketHub.PopBatch(r.Context(), key, timeout, vpnPacketBatchMaxPackets, vpnPacketBatchMaxBytes)
|
|
if len(packets) == 0 {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/vnd.rap.vpn-packet-batch.v1")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(encodeVPNPacketBatch(packets))
|
|
return
|
|
}
|
|
packet, ok := m.vpnPacketHub.Pop(r.Context(), key, timeout)
|
|
if !ok {
|
|
w.WriteHeader(http.StatusNoContent)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "application/octet-stream")
|
|
w.WriteHeader(http.StatusOK)
|
|
_, _ = w.Write(packet)
|
|
}
|
|
|
|
const (
|
|
vpnDirectionClientToGateway = "client_to_gateway"
|
|
vpnDirectionGatewayToClient = "gateway_to_client"
|
|
|
|
vpnPacketMaxBytes = 65535
|
|
vpnPacketFlowShardCount = 16
|
|
vpnPacketFlowShardDepth = 4096
|
|
vpnPacketQueueDepth = vpnPacketFlowShardCount * vpnPacketFlowShardDepth
|
|
vpnPacketBatchMaxPackets = 1024
|
|
vpnPacketBatchMaxBytes = 4 * 1024 * 1024
|
|
vpnPacketBatchGatherTimeout = 3 * time.Millisecond
|
|
vpnPacketStatsWindow = 5 * time.Second
|
|
)
|
|
|
|
type vpnPacketKey struct {
|
|
ClusterID string
|
|
VPNConnectionID string
|
|
Direction string
|
|
}
|
|
|
|
type vpnPacketHub struct {
|
|
queuesMu sync.RWMutex
|
|
statsMu sync.Mutex
|
|
queues map[vpnPacketKey]*vpnPacketQueue
|
|
stats map[vpnPacketKey]vpnPacketStats
|
|
}
|
|
|
|
type vpnPacketQueue struct {
|
|
shards []chan []byte
|
|
popMu sync.Mutex
|
|
popCursor int
|
|
}
|
|
|
|
type vpnPacketStats struct {
|
|
Pushed uint64
|
|
Popped uint64
|
|
Dropped uint64
|
|
QueueFullDrops uint64
|
|
RequeueDrops uint64
|
|
ClearedStale uint64
|
|
PushedBytes uint64
|
|
PoppedBytes uint64
|
|
WindowStartedAt time.Time
|
|
WindowPushed uint64
|
|
WindowPopped uint64
|
|
WindowPushedBytes uint64
|
|
WindowPoppedBytes uint64
|
|
QueueDepthHigh int
|
|
QueueDepthHighAt time.Time
|
|
ShardDepthHigh int
|
|
ShardDepthHighAt time.Time
|
|
LastPushSize int
|
|
LastPopSize int
|
|
LastPushAt time.Time
|
|
LastPopAt time.Time
|
|
LastPushSummary string
|
|
LastPopSummary string
|
|
Recent []vpnPacketTrace
|
|
}
|
|
|
|
type vpnPacketTrace struct {
|
|
Event string `json:"event"`
|
|
Summary string `json:"summary"`
|
|
Size int `json:"size"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
func newVPNPacketHub() *vpnPacketHub {
|
|
return &vpnPacketHub{
|
|
queues: map[vpnPacketKey]*vpnPacketQueue{},
|
|
stats: map[vpnPacketKey]vpnPacketStats{},
|
|
}
|
|
}
|
|
|
|
func (h *vpnPacketHub) Push(key vpnPacketKey, packet []byte) error {
|
|
queue := h.queue(key)
|
|
if queue.push(packet) {
|
|
_, queueDepth, shardDepth := queue.depths()
|
|
h.recordPush(key, packet, time.Now().UTC(), queueDepth, shardDepth)
|
|
return nil
|
|
}
|
|
h.recordQueueFullDrop(key, packet, time.Now().UTC())
|
|
return fmt.Errorf("vpn packet queue is full")
|
|
}
|
|
|
|
func (h *vpnPacketHub) Pop(ctx context.Context, key vpnPacketKey, timeout time.Duration) ([]byte, bool) {
|
|
queue := h.queue(key)
|
|
if packet, ok := queue.pop(ctx, timeout); ok {
|
|
h.recordPop(key, packet, time.Now().UTC())
|
|
return packet, true
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (h *vpnPacketHub) PopBatch(ctx context.Context, key vpnPacketKey, timeout time.Duration, maxPackets, maxBytes int) [][]byte {
|
|
if maxPackets <= 0 {
|
|
maxPackets = 1
|
|
}
|
|
if maxBytes <= 0 {
|
|
maxBytes = vpnPacketMaxBytes
|
|
}
|
|
first, ok := h.Pop(ctx, key, timeout)
|
|
if !ok {
|
|
return nil
|
|
}
|
|
packets := [][]byte{first}
|
|
total := len(first)
|
|
gatherUntil := time.Now().Add(vpnPacketBatchGatherTimeout)
|
|
queue := h.queue(key)
|
|
for len(packets) < maxPackets && total < maxBytes {
|
|
packet, ok := queue.pop(ctx, 0)
|
|
if !ok {
|
|
remaining := time.Until(gatherUntil)
|
|
if remaining <= 0 {
|
|
return packets
|
|
}
|
|
packet, ok = queue.pop(ctx, remaining)
|
|
if !ok {
|
|
return packets
|
|
}
|
|
}
|
|
if total+len(packet)+4 > maxBytes {
|
|
if !queue.requeue(packet) {
|
|
h.recordRequeueDrop(key, packet, time.Now().UTC())
|
|
}
|
|
return packets
|
|
}
|
|
h.recordPop(key, packet, time.Now().UTC())
|
|
packets = append(packets, packet)
|
|
total += len(packet) + 4
|
|
}
|
|
return packets
|
|
}
|
|
|
|
func (h *vpnPacketHub) addWindowSamplesLocked(
|
|
stats *vpnPacketStats,
|
|
now time.Time,
|
|
pushedPackets, poppedPackets,
|
|
pushedBytes, poppedBytes uint64,
|
|
) {
|
|
if stats.WindowStartedAt.IsZero() || now.Sub(stats.WindowStartedAt) >= vpnPacketStatsWindow {
|
|
stats.WindowStartedAt = now
|
|
stats.WindowPushed = 0
|
|
stats.WindowPopped = 0
|
|
stats.WindowPushedBytes = 0
|
|
stats.WindowPoppedBytes = 0
|
|
}
|
|
stats.WindowPushed += pushedPackets
|
|
stats.WindowPopped += poppedPackets
|
|
stats.WindowPushedBytes += pushedBytes
|
|
stats.WindowPoppedBytes += poppedBytes
|
|
}
|
|
|
|
func computeVPNRateStats(now time.Time, startedAt time.Time, packets, bytes uint64) (float64, float64) {
|
|
if packets == 0 || bytes == 0 || startedAt.IsZero() {
|
|
return 0, 0
|
|
}
|
|
elapsed := now.Sub(startedAt).Seconds()
|
|
if elapsed <= 0 {
|
|
return 0, 0
|
|
}
|
|
pps := float64(packets) / elapsed
|
|
mbps := float64(bytes*8) / (elapsed * 1_000_000)
|
|
return pps, mbps
|
|
}
|
|
|
|
func (h *vpnPacketHub) appendRateStats(metrics map[string]any, now time.Time, stats vpnPacketStats) map[string]any {
|
|
pushPps, pushMbps := computeVPNRateStats(now, stats.WindowStartedAt, stats.WindowPushed, stats.WindowPushedBytes)
|
|
popPps, popMbps := computeVPNRateStats(now, stats.WindowStartedAt, stats.WindowPopped, stats.WindowPoppedBytes)
|
|
metrics["rate_window_seconds"] = vpnPacketStatsWindow.Seconds()
|
|
metrics["window_push_rate_pps"] = pushPps
|
|
metrics["window_push_rate_mbps"] = pushMbps
|
|
metrics["window_pop_rate_pps"] = popPps
|
|
metrics["window_pop_rate_mbps"] = popMbps
|
|
metrics["window_push_packets"] = stats.WindowPushed
|
|
metrics["window_pop_packets"] = stats.WindowPopped
|
|
metrics["window_push_bytes"] = stats.WindowPushedBytes
|
|
metrics["window_pop_bytes"] = stats.WindowPoppedBytes
|
|
return metrics
|
|
}
|
|
|
|
func (h *vpnPacketHub) Clear(key vpnPacketKey) int {
|
|
queue := h.queue(key)
|
|
cleared := queue.clear()
|
|
h.recordClear(key, cleared)
|
|
return cleared
|
|
}
|
|
|
|
func encodeVPNPacketBatch(packets [][]byte) []byte {
|
|
total := 0
|
|
for _, packet := range packets {
|
|
total += 4 + len(packet)
|
|
}
|
|
out := make([]byte, total)
|
|
offset := 0
|
|
for _, packet := range packets {
|
|
binary.BigEndian.PutUint32(out[offset:offset+4], uint32(len(packet)))
|
|
offset += 4
|
|
copy(out[offset:offset+len(packet)], packet)
|
|
offset += len(packet)
|
|
}
|
|
return out
|
|
}
|
|
|
|
func decodeVPNPacketBatch(payload []byte) ([][]byte, error) {
|
|
var packets [][]byte
|
|
for offset := 0; offset < len(payload); {
|
|
if offset+4 > len(payload) {
|
|
return nil, fmt.Errorf("truncated vpn packet batch header")
|
|
}
|
|
size := int(binary.BigEndian.Uint32(payload[offset : offset+4]))
|
|
offset += 4
|
|
if size <= 0 || size > vpnPacketMaxBytes {
|
|
return nil, fmt.Errorf("invalid vpn packet batch item size")
|
|
}
|
|
if offset+size > len(payload) {
|
|
return nil, fmt.Errorf("truncated vpn packet batch item")
|
|
}
|
|
packets = append(packets, append([]byte(nil), payload[offset:offset+size]...))
|
|
offset += size
|
|
}
|
|
if len(packets) == 0 {
|
|
return nil, fmt.Errorf("empty vpn packet batch")
|
|
}
|
|
return packets, nil
|
|
}
|
|
|
|
func (h *vpnPacketHub) queue(key vpnPacketKey) *vpnPacketQueue {
|
|
h.queuesMu.RLock()
|
|
queue := h.queues[key]
|
|
h.queuesMu.RUnlock()
|
|
if queue != nil {
|
|
return queue
|
|
}
|
|
h.queuesMu.Lock()
|
|
defer h.queuesMu.Unlock()
|
|
queue = h.queues[key]
|
|
if queue != nil {
|
|
return queue
|
|
}
|
|
queue = newVPNPacketQueue()
|
|
h.queues[key] = queue
|
|
return queue
|
|
}
|
|
|
|
func newVPNPacketQueue() *vpnPacketQueue {
|
|
shardCount := vpnPacketFlowShardCount
|
|
if shardCount <= 0 {
|
|
shardCount = 1
|
|
}
|
|
shardDepth := vpnPacketFlowShardDepth
|
|
if shardDepth <= 0 {
|
|
shardDepth = 1
|
|
}
|
|
queue := &vpnPacketQueue{
|
|
shards: make([]chan []byte, shardCount),
|
|
}
|
|
for i := range queue.shards {
|
|
queue.shards[i] = make(chan []byte, shardDepth)
|
|
}
|
|
return queue
|
|
}
|
|
|
|
func (q *vpnPacketQueue) push(packet []byte) bool {
|
|
return q.enqueue(append([]byte(nil), packet...))
|
|
}
|
|
|
|
func (q *vpnPacketQueue) requeue(packet []byte) bool {
|
|
return q.enqueue(packet)
|
|
}
|
|
|
|
func (q *vpnPacketQueue) enqueue(packet []byte) bool {
|
|
if len(q.shards) == 0 {
|
|
return false
|
|
}
|
|
shard := vpnPacketFlowShard(packet, len(q.shards))
|
|
select {
|
|
case q.shards[shard] <- packet:
|
|
return true
|
|
default:
|
|
return false
|
|
}
|
|
}
|
|
|
|
func (q *vpnPacketQueue) pop(ctx context.Context, timeout time.Duration) ([]byte, bool) {
|
|
q.popMu.Lock()
|
|
defer q.popMu.Unlock()
|
|
if packet, ok := q.popNonBlockingLocked(); ok {
|
|
return packet, true
|
|
}
|
|
if timeout <= 0 || len(q.shards) == 0 {
|
|
return nil, false
|
|
}
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
cases := make([]reflect.SelectCase, 0, len(q.shards)+2)
|
|
cases = append(cases,
|
|
reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(ctx.Done())},
|
|
reflect.SelectCase{Dir: reflect.SelectRecv, Chan: reflect.ValueOf(timer.C)},
|
|
)
|
|
start := q.popCursor % len(q.shards)
|
|
for i := range q.shards {
|
|
cases = append(cases, reflect.SelectCase{
|
|
Dir: reflect.SelectRecv,
|
|
Chan: reflect.ValueOf(q.shards[(start+i)%len(q.shards)]),
|
|
})
|
|
}
|
|
chosen, value, ok := reflect.Select(cases)
|
|
if chosen < 2 || !ok {
|
|
return nil, false
|
|
}
|
|
shard := (start + chosen - 2) % len(q.shards)
|
|
q.popCursor = (shard + 1) % len(q.shards)
|
|
packet, ok := value.Interface().([]byte)
|
|
return packet, ok
|
|
}
|
|
|
|
func (q *vpnPacketQueue) popNonBlockingLocked() ([]byte, bool) {
|
|
if len(q.shards) == 0 {
|
|
return nil, false
|
|
}
|
|
start := q.popCursor % len(q.shards)
|
|
for i := range q.shards {
|
|
shard := (start + i) % len(q.shards)
|
|
select {
|
|
case packet := <-q.shards[shard]:
|
|
q.popCursor = (shard + 1) % len(q.shards)
|
|
return packet, true
|
|
default:
|
|
}
|
|
}
|
|
return nil, false
|
|
}
|
|
|
|
func (q *vpnPacketQueue) clear() int {
|
|
cleared := 0
|
|
for _, shard := range q.shards {
|
|
for {
|
|
select {
|
|
case <-shard:
|
|
cleared++
|
|
default:
|
|
goto nextShard
|
|
}
|
|
}
|
|
nextShard:
|
|
}
|
|
return cleared
|
|
}
|
|
|
|
func (q *vpnPacketQueue) depths() ([]int, int, int) {
|
|
depths := make([]int, len(q.shards))
|
|
total := 0
|
|
maxDepth := 0
|
|
for i, shard := range q.shards {
|
|
depth := len(shard)
|
|
depths[i] = depth
|
|
total += depth
|
|
if depth > maxDepth {
|
|
maxDepth = depth
|
|
}
|
|
}
|
|
return depths, total, maxDepth
|
|
}
|
|
|
|
func (h *vpnPacketHub) Snapshot(clusterID, vpnConnectionID string) map[string]any {
|
|
now := time.Now().UTC()
|
|
out := map[string]any{}
|
|
for _, direction := range []string{vpnDirectionClientToGateway, vpnDirectionGatewayToClient} {
|
|
key := vpnPacketKey{ClusterID: clusterID, VPNConnectionID: vpnConnectionID, Direction: direction}
|
|
h.statsMu.Lock()
|
|
stats := h.stats[key]
|
|
h.statsMu.Unlock()
|
|
queueDepth := 0
|
|
queueDepthMax := 0
|
|
queueDepths := []int{}
|
|
h.queuesMu.RLock()
|
|
queue := h.queues[key]
|
|
h.queuesMu.RUnlock()
|
|
if queue != nil {
|
|
queueDepths, queueDepth, queueDepthMax = queue.depths()
|
|
}
|
|
metric := map[string]any{
|
|
"pushed": stats.Pushed,
|
|
"pushed_bytes": stats.PushedBytes,
|
|
"popped": stats.Popped,
|
|
"popped_bytes": stats.PoppedBytes,
|
|
"dropped": stats.Dropped,
|
|
"queue_full_drops": stats.QueueFullDrops,
|
|
"requeue_drops": stats.RequeueDrops,
|
|
"cleared_stale_packets": stats.ClearedStale,
|
|
"queue_depth": queueDepth,
|
|
"queue_depths": queueDepths,
|
|
"queue_depth_max": queueDepthMax,
|
|
"queue_depth_high_watermark": stats.QueueDepthHigh,
|
|
"queue_depth_high_at": stats.QueueDepthHighAt,
|
|
"shard_depth_high_watermark": stats.ShardDepthHigh,
|
|
"shard_depth_high_at": stats.ShardDepthHighAt,
|
|
"queue_capacity": vpnPacketQueueDepth,
|
|
"queue_shard_capacity": vpnPacketFlowShardDepth,
|
|
"flow_shard_count": len(queueDepths),
|
|
"flow_isolation": "ipv4_5tuple_sharded_round_robin",
|
|
"last_push_size": stats.LastPushSize,
|
|
"last_pop_size": stats.LastPopSize,
|
|
"last_push_at": stats.LastPushAt,
|
|
"last_pop_at": stats.LastPopAt,
|
|
"last_push": stats.LastPushSummary,
|
|
"last_pop": stats.LastPopSummary,
|
|
"recent": stats.Recent,
|
|
}
|
|
h.appendRateStats(metric, now, stats)
|
|
out[direction] = metric
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (h *vpnPacketHub) recordPush(key vpnPacketKey, packet []byte, now time.Time, queueDepth, shardDepth int) {
|
|
if !h.statsMu.TryLock() {
|
|
return
|
|
}
|
|
defer h.statsMu.Unlock()
|
|
stats := h.stats[key]
|
|
stats.Pushed++
|
|
stats.PushedBytes += uint64(len(packet))
|
|
stats.LastPushSize = len(packet)
|
|
stats.LastPushAt = now
|
|
stats.LastPushSummary = vpnPacketSummary(packet)
|
|
if queueDepth > stats.QueueDepthHigh {
|
|
stats.QueueDepthHigh = queueDepth
|
|
stats.QueueDepthHighAt = now
|
|
}
|
|
if shardDepth > stats.ShardDepthHigh {
|
|
stats.ShardDepthHigh = shardDepth
|
|
stats.ShardDepthHighAt = now
|
|
}
|
|
h.addWindowSamplesLocked(&stats, now, 1, 0, uint64(len(packet)), 0)
|
|
stats.Recent = appendVPNPacketTrace(stats.Recent, "push", packet, stats.LastPushAt)
|
|
h.stats[key] = stats
|
|
}
|
|
|
|
func (h *vpnPacketHub) recordPop(key vpnPacketKey, packet []byte, now time.Time) {
|
|
if !h.statsMu.TryLock() {
|
|
return
|
|
}
|
|
defer h.statsMu.Unlock()
|
|
stats := h.stats[key]
|
|
stats.Popped++
|
|
stats.PoppedBytes += uint64(len(packet))
|
|
stats.LastPopSize = len(packet)
|
|
stats.LastPopAt = now
|
|
stats.LastPopSummary = vpnPacketSummary(packet)
|
|
h.addWindowSamplesLocked(&stats, now, 0, 1, 0, uint64(len(packet)))
|
|
stats.Recent = appendVPNPacketTrace(stats.Recent, "pop", packet, stats.LastPopAt)
|
|
h.stats[key] = stats
|
|
}
|
|
|
|
func (h *vpnPacketHub) recordQueueFullDrop(key vpnPacketKey, packet []byte, now time.Time) {
|
|
if !h.statsMu.TryLock() {
|
|
return
|
|
}
|
|
defer h.statsMu.Unlock()
|
|
stats := h.stats[key]
|
|
stats.Dropped++
|
|
stats.QueueFullDrops++
|
|
stats.Recent = appendVPNPacketTrace(stats.Recent, "drop_queue_full", packet, now)
|
|
h.stats[key] = stats
|
|
}
|
|
|
|
func (h *vpnPacketHub) recordRequeueDrop(key vpnPacketKey, packet []byte, now time.Time) {
|
|
if !h.statsMu.TryLock() {
|
|
return
|
|
}
|
|
defer h.statsMu.Unlock()
|
|
stats := h.stats[key]
|
|
stats.Dropped++
|
|
stats.RequeueDrops++
|
|
stats.Recent = appendVPNPacketTrace(stats.Recent, "drop_requeue_full", packet, now)
|
|
h.stats[key] = stats
|
|
}
|
|
|
|
func (h *vpnPacketHub) recordClear(key vpnPacketKey, cleared int) {
|
|
if cleared <= 0 {
|
|
return
|
|
}
|
|
h.statsMu.Lock()
|
|
defer h.statsMu.Unlock()
|
|
now := time.Now().UTC()
|
|
stats := h.stats[key]
|
|
stats.ClearedStale += uint64(cleared)
|
|
stats.Recent = append(stats.Recent, vpnPacketTrace{
|
|
Event: "clear",
|
|
Summary: fmt.Sprintf("cleared stale packets=%d", cleared),
|
|
Size: cleared,
|
|
CreatedAt: now,
|
|
})
|
|
const maxRecentVPNPacketTrace = 24
|
|
if len(stats.Recent) > maxRecentVPNPacketTrace {
|
|
stats.Recent = stats.Recent[len(stats.Recent)-maxRecentVPNPacketTrace:]
|
|
}
|
|
h.stats[key] = stats
|
|
}
|
|
|
|
func appendVPNPacketTrace(recent []vpnPacketTrace, event string, packet []byte, at time.Time) []vpnPacketTrace {
|
|
recent = append(recent, vpnPacketTrace{
|
|
Event: event,
|
|
Summary: vpnPacketSummary(packet),
|
|
Size: len(packet),
|
|
CreatedAt: at,
|
|
})
|
|
const maxRecentVPNPacketTrace = 24
|
|
if len(recent) > maxRecentVPNPacketTrace {
|
|
recent = recent[len(recent)-maxRecentVPNPacketTrace:]
|
|
}
|
|
return recent
|
|
}
|
|
|
|
func vpnPacketFlowShard(packet []byte, shardCount int) int {
|
|
if shardCount <= 1 {
|
|
return 0
|
|
}
|
|
if len(packet) < 20 {
|
|
return len(packet) % shardCount
|
|
}
|
|
version := (packet[0] >> 4) & 0x0f
|
|
if version != 4 {
|
|
return len(packet) % shardCount
|
|
}
|
|
ihl := int(packet[0]&0x0f) * 4
|
|
if ihl < 20 || len(packet) < ihl {
|
|
return len(packet) % shardCount
|
|
}
|
|
proto := uint32(packet[9])
|
|
srcIP := vpnIPv4Uint32(packet[12:16])
|
|
dstIP := vpnIPv4Uint32(packet[16:20])
|
|
srcPort := uint32(0)
|
|
dstPort := uint32(0)
|
|
if (proto == 6 || proto == 17) && len(packet) >= ihl+4 {
|
|
srcPort = uint32(vpnU16(packet[ihl : ihl+2]))
|
|
dstPort = uint32(vpnU16(packet[ihl+2 : ihl+4]))
|
|
} else if proto == 1 && len(packet) >= ihl+2 {
|
|
srcPort = uint32(packet[ihl])
|
|
dstPort = uint32(packet[ihl+1])
|
|
}
|
|
hash := srcIP ^ vpnRotateLeft32(dstIP, 7) ^ (proto << 24) ^ (srcPort << 11) ^ dstPort
|
|
hash ^= hash >> 16
|
|
hash *= 0x7feb352d
|
|
hash ^= hash >> 15
|
|
return int(hash % uint32(shardCount))
|
|
}
|
|
|
|
func vpnIPv4Uint32(raw []byte) uint32 {
|
|
if len(raw) < 4 {
|
|
return 0
|
|
}
|
|
return uint32(raw[0])<<24 | uint32(raw[1])<<16 | uint32(raw[2])<<8 | uint32(raw[3])
|
|
}
|
|
|
|
func vpnRotateLeft32(value uint32, shift uint) uint32 {
|
|
shift %= 32
|
|
if shift == 0 {
|
|
return value
|
|
}
|
|
return (value << shift) | (value >> (32 - shift))
|
|
}
|
|
|
|
func vpnPacketSummary(packet []byte) string {
|
|
if len(packet) < 20 {
|
|
return fmt.Sprintf("size=%d", len(packet))
|
|
}
|
|
version := (packet[0] >> 4) & 0x0f
|
|
if version != 4 {
|
|
return fmt.Sprintf("size=%d ip_version=%d", len(packet), version)
|
|
}
|
|
ihl := int(packet[0]&0x0f) * 4
|
|
if ihl < 20 || len(packet) < ihl {
|
|
return fmt.Sprintf("size=%d ipv4=truncated", len(packet))
|
|
}
|
|
proto := int(packet[9])
|
|
base := fmt.Sprintf("size=%d %s -> %s proto=%d", len(packet), vpnIPv4(packet[12:16]), vpnIPv4(packet[16:20]), proto)
|
|
if (proto == 6 || proto == 17) && len(packet) >= ihl+4 {
|
|
base += fmt.Sprintf(" %d->%d", vpnU16(packet[ihl:ihl+2]), vpnU16(packet[ihl+2:ihl+4]))
|
|
if proto == 6 && len(packet) >= ihl+14 {
|
|
base += " flags=" + vpnTCPFlags(packet[ihl+13])
|
|
}
|
|
} else if proto == 1 && len(packet) >= ihl+2 {
|
|
base += fmt.Sprintf(" icmp_type=%d icmp_code=%d", packet[ihl], packet[ihl+1])
|
|
}
|
|
return base
|
|
}
|
|
|
|
func vpnIPv4(raw []byte) string {
|
|
if len(raw) < 4 {
|
|
return "0.0.0.0"
|
|
}
|
|
return fmt.Sprintf("%d.%d.%d.%d", raw[0], raw[1], raw[2], raw[3])
|
|
}
|
|
|
|
func vpnU16(raw []byte) int {
|
|
if len(raw) < 2 {
|
|
return 0
|
|
}
|
|
return int(raw[0])<<8 | int(raw[1])
|
|
}
|
|
|
|
func vpnTCPFlags(flags byte) string {
|
|
out := strings.Builder{}
|
|
if flags&0x02 != 0 {
|
|
out.WriteByte('S')
|
|
}
|
|
if flags&0x10 != 0 {
|
|
out.WriteByte('A')
|
|
}
|
|
if flags&0x01 != 0 {
|
|
out.WriteByte('F')
|
|
}
|
|
if flags&0x04 != 0 {
|
|
out.WriteByte('R')
|
|
}
|
|
if flags&0x08 != 0 {
|
|
out.WriteByte('P')
|
|
}
|
|
if out.Len() == 0 {
|
|
return fmt.Sprintf("%d", flags)
|
|
}
|
|
return out.String()
|
|
}
|
|
|
|
type vpnClientDiagnosticKey struct {
|
|
ClusterID string
|
|
DeviceID string
|
|
}
|
|
|
|
type vpnClientDiagnosticStatus struct {
|
|
ClusterID string `json:"cluster_id"`
|
|
DeviceID string `json:"device_id"`
|
|
Payload map[string]any `json:"payload"`
|
|
ObservedAt time.Time `json:"observed_at"`
|
|
}
|
|
|
|
type vpnClientDiagnosticCommand struct {
|
|
ID string `json:"id"`
|
|
ClusterID string `json:"cluster_id"`
|
|
DeviceID string `json:"device_id"`
|
|
Payload map[string]any `json:"payload"`
|
|
CreatedAt time.Time `json:"created_at"`
|
|
}
|
|
|
|
type vpnClientDiagnosticHub struct {
|
|
mu sync.Mutex
|
|
statuses map[vpnClientDiagnosticKey]vpnClientDiagnosticStatus
|
|
queues map[vpnClientDiagnosticKey]chan vpnClientDiagnosticCommand
|
|
}
|
|
|
|
func newVPNClientDiagnosticHub() *vpnClientDiagnosticHub {
|
|
return &vpnClientDiagnosticHub{
|
|
statuses: map[vpnClientDiagnosticKey]vpnClientDiagnosticStatus{},
|
|
queues: map[vpnClientDiagnosticKey]chan vpnClientDiagnosticCommand{},
|
|
}
|
|
}
|
|
|
|
func (h *vpnClientDiagnosticHub) Report(clusterID, deviceID string, payload map[string]any) vpnClientDiagnosticStatus {
|
|
key := vpnClientDiagnosticKey{ClusterID: clusterID, DeviceID: deviceID}
|
|
item := vpnClientDiagnosticStatus{
|
|
ClusterID: clusterID,
|
|
DeviceID: deviceID,
|
|
Payload: cloneDiagnosticPayload(payload),
|
|
ObservedAt: time.Now().UTC(),
|
|
}
|
|
h.mu.Lock()
|
|
h.statuses[key] = item
|
|
h.mu.Unlock()
|
|
return item
|
|
}
|
|
|
|
func (h *vpnClientDiagnosticHub) Status(clusterID, deviceID string) (vpnClientDiagnosticStatus, bool) {
|
|
key := vpnClientDiagnosticKey{ClusterID: clusterID, DeviceID: deviceID}
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
item, ok := h.statuses[key]
|
|
return item, ok
|
|
}
|
|
|
|
func (h *vpnClientDiagnosticHub) List(clusterID string) []vpnClientDiagnosticStatus {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
out := make([]vpnClientDiagnosticStatus, 0)
|
|
for key, item := range h.statuses {
|
|
if key.ClusterID == clusterID {
|
|
out = append(out, item)
|
|
}
|
|
}
|
|
sort.Slice(out, func(i, j int) bool {
|
|
return out[i].ObservedAt.After(out[j].ObservedAt)
|
|
})
|
|
return out
|
|
}
|
|
|
|
func (h *vpnClientDiagnosticHub) Enqueue(clusterID, deviceID string, payload map[string]any) vpnClientDiagnosticCommand {
|
|
item := vpnClientDiagnosticCommand{
|
|
ID: fmt.Sprintf("vpn_diag_%d", time.Now().UnixNano()),
|
|
ClusterID: clusterID,
|
|
DeviceID: deviceID,
|
|
Payload: cloneDiagnosticPayload(payload),
|
|
CreatedAt: time.Now().UTC(),
|
|
}
|
|
queue := h.queue(vpnClientDiagnosticKey{ClusterID: clusterID, DeviceID: deviceID})
|
|
if vpnClientDiagnosticCommandIsPriorityStop(payload) {
|
|
drainVPNClientDiagnosticQueue(queue)
|
|
}
|
|
select {
|
|
case queue <- item:
|
|
default:
|
|
select {
|
|
case <-queue:
|
|
default:
|
|
}
|
|
queue <- item
|
|
}
|
|
return item
|
|
}
|
|
|
|
func vpnClientDiagnosticCommandIsPriorityStop(payload map[string]any) bool {
|
|
commandType, _ := payload["type"].(string)
|
|
return strings.TrimSpace(commandType) == "stop_vpn"
|
|
}
|
|
|
|
func drainVPNClientDiagnosticQueue(queue chan vpnClientDiagnosticCommand) {
|
|
for {
|
|
select {
|
|
case <-queue:
|
|
default:
|
|
return
|
|
}
|
|
}
|
|
}
|
|
|
|
func (h *vpnClientDiagnosticHub) Pop(ctx context.Context, clusterID, deviceID string, timeout time.Duration) (vpnClientDiagnosticCommand, bool) {
|
|
queue := h.queue(vpnClientDiagnosticKey{ClusterID: clusterID, DeviceID: deviceID})
|
|
if timeout <= 0 {
|
|
select {
|
|
case item := <-queue:
|
|
return item, true
|
|
default:
|
|
return vpnClientDiagnosticCommand{}, false
|
|
}
|
|
}
|
|
timer := time.NewTimer(timeout)
|
|
defer timer.Stop()
|
|
select {
|
|
case item := <-queue:
|
|
return item, true
|
|
case <-timer.C:
|
|
return vpnClientDiagnosticCommand{}, false
|
|
case <-ctx.Done():
|
|
return vpnClientDiagnosticCommand{}, false
|
|
}
|
|
}
|
|
|
|
func (h *vpnClientDiagnosticHub) queue(key vpnClientDiagnosticKey) chan vpnClientDiagnosticCommand {
|
|
h.mu.Lock()
|
|
defer h.mu.Unlock()
|
|
queue := h.queues[key]
|
|
if queue == nil {
|
|
queue = make(chan vpnClientDiagnosticCommand, 32)
|
|
h.queues[key] = queue
|
|
}
|
|
return queue
|
|
}
|
|
|
|
func cloneDiagnosticPayload(payload map[string]any) map[string]any {
|
|
out := map[string]any{}
|
|
for key, value := range payload {
|
|
out[key] = value
|
|
}
|
|
return out
|
|
}
|
|
|
|
func (m *Module) getClusterAuthority(w http.ResponseWriter, r *http.Request) {
|
|
item, err := m.service.GetClusterAuthorityState(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{"authority_state": item})
|
|
}
|
|
|
|
func (m *Module) updateClusterAuthority(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
AuthorityState string `json:"authority_state"`
|
|
MutationMode string `json:"mutation_mode"`
|
|
Notes *string `json:"notes"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid cluster authority payload")
|
|
return
|
|
}
|
|
item, err := m.service.UpdateClusterAuthorityState(r.Context(), UpdateClusterAuthorityInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
AuthorityState: payload.AuthorityState,
|
|
MutationMode: payload.MutationMode,
|
|
Notes: payload.Notes,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"authority_state": item})
|
|
}
|
|
|
|
func (m *Module) listClusterAdminSummaries(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListClusterAdminSummaries(r.Context(), r.URL.Query().Get("actor_user_id"))
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"cluster_summaries": items})
|
|
}
|
|
|
|
func (m *Module) listFabricTestingFlags(w http.ResponseWriter, r *http.Request) {
|
|
items, err := m.service.ListFabricTestingFlags(r.Context(), r.URL.Query().Get("actor_user_id"))
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"testing_flags": items})
|
|
}
|
|
|
|
func (m *Module) upsertFabricTestingFlag(w http.ResponseWriter, r *http.Request) {
|
|
var payload struct {
|
|
ActorUserID string `json:"actor_user_id"`
|
|
ScopeType string `json:"scope_type"`
|
|
ScopeID *string `json:"scope_id"`
|
|
ClusterID *string `json:"cluster_id"`
|
|
Enabled bool `json:"enabled"`
|
|
TelemetryEnabled bool `json:"telemetry_enabled"`
|
|
SyntheticLinksEnabled bool `json:"synthetic_links_enabled"`
|
|
HistoryRetentionHours int `json:"history_retention_hours"`
|
|
Metadata json.RawMessage `json:"metadata"`
|
|
}
|
|
if err := json.NewDecoder(r.Body).Decode(&payload); err != nil {
|
|
httpx.WriteError(w, http.StatusBadRequest, "invalid testing flag payload")
|
|
return
|
|
}
|
|
item, err := m.service.UpsertFabricTestingFlag(r.Context(), UpsertFabricTestingFlagInput{
|
|
ActorUserID: payload.ActorUserID,
|
|
ScopeType: payload.ScopeType,
|
|
ScopeID: payload.ScopeID,
|
|
ClusterID: payload.ClusterID,
|
|
Enabled: payload.Enabled,
|
|
TelemetryEnabled: payload.TelemetryEnabled,
|
|
SyntheticLinksEnabled: payload.SyntheticLinksEnabled,
|
|
HistoryRetentionHours: payload.HistoryRetentionHours,
|
|
Metadata: payload.Metadata,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{"testing_flag": item})
|
|
}
|
|
|
|
func (m *Module) listAuditEvents(w http.ResponseWriter, r *http.Request) {
|
|
limit, _ := strconv.Atoi(r.URL.Query().Get("limit"))
|
|
items, err := m.service.ListAuditEvents(r.Context(), r.URL.Query().Get("actor_user_id"), ListAuditEventsInput{
|
|
ClusterID: chi.URLParam(r, "clusterID"),
|
|
EventTypes: queryStringList(r, "event_type"),
|
|
TargetTypes: queryStringList(r, "target_type"),
|
|
Correlation: r.URL.Query().Get("correlation"),
|
|
Limit: limit,
|
|
})
|
|
if writeServiceError(w, err) {
|
|
return
|
|
}
|
|
httpx.WriteJSON(w, http.StatusOK, map[string]any{
|
|
"audit_events": items,
|
|
"audit_summary": summarizeClusterAuditEvents(items),
|
|
})
|
|
}
|
|
|
|
func queryStringList(r *http.Request, key string) []string {
|
|
values := []string{}
|
|
for _, raw := range r.URL.Query()[key] {
|
|
for _, part := range strings.Split(raw, ",") {
|
|
if value := strings.TrimSpace(part); value != "" {
|
|
values = append(values, value)
|
|
}
|
|
}
|
|
}
|
|
return values
|
|
}
|
|
|
|
func (m *Module) streamClusterEvents(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
httpx.WriteError(w, http.StatusInternalServerError, "streaming is not supported")
|
|
return
|
|
}
|
|
actorUserID := r.URL.Query().Get("actor_user_id")
|
|
clusterID := chi.URLParam(r, "clusterID")
|
|
w.Header().Set("Content-Type", "text/event-stream")
|
|
w.Header().Set("Cache-Control", "no-cache, no-transform")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
w.Header().Set("X-Accel-Buffering", "no")
|
|
_, _ = fmt.Fprint(w, "retry: 5000\n\n")
|
|
flusher.Flush()
|
|
|
|
ticker := time.NewTicker(5 * time.Second)
|
|
defer ticker.Stop()
|
|
var lastRevision string
|
|
for {
|
|
revision, payload, err := m.clusterEventSnapshot(r, actorUserID, clusterID)
|
|
if err != nil {
|
|
_ = writeSSE(w, "cluster.error", map[string]any{
|
|
"cluster_id": clusterID,
|
|
"error": err.Error(),
|
|
"observed_at": time.Now().UTC(),
|
|
})
|
|
flusher.Flush()
|
|
return
|
|
}
|
|
if revision != lastRevision {
|
|
lastRevision = revision
|
|
_ = writeSSE(w, "cluster.changed", payload)
|
|
flusher.Flush()
|
|
} else {
|
|
_, _ = fmt.Fprintf(w, ": keepalive %s\n\n", time.Now().UTC().Format(time.RFC3339Nano))
|
|
flusher.Flush()
|
|
}
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-ticker.C:
|
|
}
|
|
}
|
|
}
|
|
|
|
func (m *Module) clusterEventSnapshot(r *http.Request, actorUserID, clusterID string) (string, map[string]any, error) {
|
|
ctx := r.Context()
|
|
summaries, err := m.service.ListClusterAdminSummaries(ctx, actorUserID)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
var selected *ClusterAdminSummary
|
|
for i := range summaries {
|
|
if summaries[i].ClusterID == clusterID {
|
|
selected = &summaries[i]
|
|
break
|
|
}
|
|
}
|
|
if selected == nil {
|
|
return "", nil, pgx.ErrNoRows
|
|
}
|
|
nodes, err := m.service.ListClusterNodes(ctx, actorUserID, clusterID)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
joinRequests, err := m.service.ListJoinRequests(ctx, actorUserID, clusterID)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
meshLinks, err := m.service.ListMeshLinks(ctx, actorUserID, clusterID)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
payload := map[string]any{
|
|
"cluster_id": clusterID,
|
|
"observed_at": time.Now().UTC(),
|
|
"node_count": len(nodes),
|
|
"join_request_count": len(joinRequests),
|
|
"mesh_link_count": len(meshLinks),
|
|
"summary": selected,
|
|
"latest_node_seen_at": latestNodeSeenAt(nodes),
|
|
"latest_mesh_seen_at": latestMeshLinkSeenAt(meshLinks),
|
|
}
|
|
revisionPayload := map[string]any{
|
|
"cluster_id": clusterID,
|
|
"node_count": len(nodes),
|
|
"join_request_count": len(joinRequests),
|
|
"mesh_link_count": len(meshLinks),
|
|
"summary": selected,
|
|
"latest_node_seen_at": latestNodeSeenAt(nodes),
|
|
"latest_mesh_seen_at": latestMeshLinkSeenAt(meshLinks),
|
|
}
|
|
revisionBytes, err := json.Marshal(revisionPayload)
|
|
if err != nil {
|
|
return "", nil, err
|
|
}
|
|
sum := sha256.Sum256(revisionBytes)
|
|
revision := hex.EncodeToString(sum[:])
|
|
payload["revision"] = revision
|
|
return revision, payload, nil
|
|
}
|
|
|
|
func writeSSE(w http.ResponseWriter, event string, payload any) error {
|
|
encoded, err := json.Marshal(payload)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(w, "event: %s\n", event); err != nil {
|
|
return err
|
|
}
|
|
if _, err := fmt.Fprintf(w, "data: %s\n\n", encoded); err != nil {
|
|
return err
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func latestNodeSeenAt(nodes []ClusterNode) *time.Time {
|
|
var latest *time.Time
|
|
for i := range nodes {
|
|
if nodes[i].LastSeenAt == nil {
|
|
continue
|
|
}
|
|
if latest == nil || nodes[i].LastSeenAt.After(*latest) {
|
|
value := nodes[i].LastSeenAt.UTC()
|
|
latest = &value
|
|
}
|
|
}
|
|
return latest
|
|
}
|
|
|
|
func latestMeshLinkSeenAt(links []MeshLinkObservation) *time.Time {
|
|
var latest *time.Time
|
|
for i := range links {
|
|
if latest == nil || links[i].ObservedAt.After(*latest) {
|
|
value := links[i].ObservedAt.UTC()
|
|
latest = &value
|
|
}
|
|
}
|
|
return latest
|
|
}
|
|
|
|
func writeServiceError(w http.ResponseWriter, err error) bool {
|
|
if err == nil {
|
|
return false
|
|
}
|
|
var legacyRemovalBlocked *LegacyRemovalBlockedError
|
|
switch {
|
|
case errors.Is(err, ErrAccessDenied):
|
|
httpx.WriteError(w, http.StatusForbidden, err.Error())
|
|
case errors.Is(err, ErrVPNLeaseOwnerNotAllowed), errors.Is(err, ErrVPNLeaseOwnerRoleRequired):
|
|
httpx.WriteError(w, http.StatusForbidden, err.Error())
|
|
case errors.Is(err, ErrClusterReadOnly):
|
|
httpx.WriteError(w, http.StatusConflict, err.Error())
|
|
case errors.As(err, &legacyRemovalBlocked):
|
|
httpx.WriteErrorMessage(w, http.StatusConflict, httpx.ErrorResponse{
|
|
Error: httpx.NewErrorMessage(http.StatusConflict, err.Error(), legacyRemovalBlockedErrorDetails(*legacyRemovalBlocked), ""),
|
|
})
|
|
case errors.Is(err, ErrLegacyRemovalBlocked):
|
|
httpx.WriteError(w, http.StatusConflict, err.Error())
|
|
case errors.Is(err, ErrVPNLeaseAlreadyActive):
|
|
httpx.WriteError(w, http.StatusConflict, err.Error())
|
|
case errors.Is(err, ErrInvalidPayload), errors.Is(err, ErrInvalidJoinToken), errors.Is(err, ErrInvalidNodeRole):
|
|
httpx.WriteError(w, http.StatusBadRequest, err.Error())
|
|
case errors.Is(err, ErrInvalidCluster), errors.Is(err, ErrInvalidJoinRequest), errors.Is(err, ErrInvalidVPNConnection), errors.Is(err, ErrInvalidVPNLease), errors.Is(err, pgx.ErrNoRows):
|
|
httpx.WriteError(w, http.StatusNotFound, err.Error())
|
|
default:
|
|
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
|
|
}
|
|
return true
|
|
}
|
|
|
|
func legacyRemovalBlockedErrorDetails(err LegacyRemovalBlockedError) map[string]any {
|
|
details := map[string]any{
|
|
"blocked_operation": err.BlockedOperation,
|
|
"legacy_removal_allowed": err.Report.LegacyRemovalAllowed,
|
|
"bridge_hold_required": err.Report.BridgeHoldRequired,
|
|
"bridge_hold_reasons": err.Report.BridgeHoldReasons,
|
|
"blocked_operations": err.Report.BlockedOperations,
|
|
"heartbeat_stale_after_seconds": err.Report.HeartbeatStaleAfterSeconds,
|
|
"stale_nodes": err.Report.Summary.StaleNodes,
|
|
"blocked_nodes": err.Report.Summary.BlockedNodes,
|
|
"artifact_gap_nodes": err.Report.Summary.ArtifactGapNodes,
|
|
"unknown_profile_nodes": err.Report.Summary.UnknownProfileNodes,
|
|
"waiting_update_status_nodes": err.Report.Summary.WaitingUpdateStatusNodes,
|
|
"unknown_version_nodes": err.Report.Summary.UnknownVersionNodes,
|
|
"legacy_recovery_contract_nodes": err.Report.Summary.LegacyRecoveryContractNodes,
|
|
"recovery_bridge_required_nodes": err.Report.Summary.RecoveryBridgeRequiredNodes,
|
|
"recovery_bridge_replay_ready_nodes": err.Report.Summary.RecoveryBridgeReplayReadyNodes,
|
|
"waiting_recovery_heartbeat_nodes": err.Report.Summary.WaitingRecoveryHeartbeatNodes,
|
|
}
|
|
blockedNodeIDs := make([]string, 0, len(err.Report.Nodes))
|
|
for _, node := range err.Report.Nodes {
|
|
if node.Blocked {
|
|
blockedNodeIDs = append(blockedNodeIDs, node.NodeID)
|
|
}
|
|
}
|
|
if len(blockedNodeIDs) > 0 {
|
|
details["blocked_node_ids"] = blockedNodeIDs
|
|
}
|
|
if len(err.Report.BridgeHoldNodeIDs) > 0 {
|
|
details["bridge_hold_node_ids"] = err.Report.BridgeHoldNodeIDs
|
|
}
|
|
return details
|
|
}
|