package nodeagent import ( "encoding/json" "errors" "net/http" "time" "github.com/go-chi/chi/v5" "github.com/google/uuid" "github.com/jackc/pgx/v5/pgxpool" clustermodule "github.com/example/remote-access-platform/backend/internal/modules/cluster" "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 { db *pgxpool.Pool cluster *clustermodule.Service } func NewModule(deps module.Dependencies) *Module { clusterStore := clustermodule.NewPostgresStore(deps.Infra.DB) if deps.Config.Secret.EncryptionKeyBase64 != "" { if encryptor, err := secrets.NewEncryptor(deps.Config.Secret.EncryptionKeyBase64, deps.Config.Secret.EncryptionKeyID); err == nil { clusterStore.WithClusterKeyEncryptor(encryptor) } } return &Module{ db: deps.Infra.DB, cluster: clustermodule.NewService(clusterStore), } } func (m *Module) Name() string { return "nodeagent" } func (m *Module) RegisterRoutes(router chi.Router) { router.Route("/node-agents", func(r chi.Router) { r.Post("/docker-install-profile", m.dockerInstallProfile) r.Post("/windows-install-profile", m.windowsInstallProfile) r.Post("/linux-install-profile", m.linuxInstallProfile) r.Post("/enroll", m.enrollAgent) r.Post("/enrollments/{requestID}/bootstrap", m.bootstrapEnrollment) r.Post("/register", m.registerAgent) r.Post("/{nodeID}/health", m.reportHealth) r.Post("/{nodeID}/services/status", m.reportServiceStatus) r.Post("/{nodeID}/update-manifest/request", m.requestUpdateManifest) r.Post("/{nodeID}/update-result", m.acknowledgeUpdateResult) r.Post("/{nodeID}/rollback-result", m.reportRollbackResult) r.Get("/{nodeID}/clusters/{clusterID}/vpn-assignments/desired", m.listVPNAssignments) r.Post("/{nodeID}/clusters/{clusterID}/vpn-assignments/{vpnConnectionID}/status", m.reportVPNAssignmentStatus) }) } func (m *Module) linuxInstallProfile(w http.ResponseWriter, r *http.Request) { var payload clustermodule.DockerInstallProfileRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { httpx.WriteError(w, http.StatusBadRequest, "invalid linux install profile payload") return } profile, err := m.cluster.GetLinuxInstallProfile(r.Context(), payload) if err != nil { httpx.WriteError(w, http.StatusBadRequest, err.Error()) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"linux_install_profile": profile}) } func (m *Module) windowsInstallProfile(w http.ResponseWriter, r *http.Request) { var payload clustermodule.DockerInstallProfileRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { httpx.WriteError(w, http.StatusBadRequest, "invalid windows install profile payload") return } profile, err := m.cluster.GetWindowsInstallProfile(r.Context(), payload) if err != nil { httpx.WriteError(w, http.StatusBadRequest, err.Error()) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"windows_install_profile": profile}) } func (m *Module) dockerInstallProfile(w http.ResponseWriter, r *http.Request) { var payload clustermodule.DockerInstallProfileRequest if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { httpx.WriteError(w, http.StatusBadRequest, "invalid docker install profile payload") return } profile, err := m.cluster.GetDockerInstallProfile(r.Context(), payload) if err != nil { httpx.WriteError(w, http.StatusBadRequest, err.Error()) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{"docker_install_profile": profile}) } func (m *Module) enrollAgent(w http.ResponseWriter, r *http.Request) { var payload struct { ClusterID string `json:"cluster_id"` 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 agent enrollment payload") return } joinRequest, err := m.cluster.CreateJoinRequest(r.Context(), clustermodule.CreateJoinRequestInput{ ClusterID: payload.ClusterID, JoinToken: payload.JoinToken, NodeName: payload.NodeName, NodeFingerprint: payload.NodeFingerprint, PublicKey: payload.PublicKey, ReportedCapabilities: payload.ReportedCapabilities, ReportedFacts: payload.ReportedFacts, RequestedRoles: payload.RequestedRoles, }) if err != nil { httpx.WriteError(w, http.StatusBadRequest, err.Error()) return } httpx.WriteJSON(w, http.StatusAccepted, map[string]any{ "status": "pending_approval", "join_request": joinRequest, }) } func (m *Module) bootstrapEnrollment(w http.ResponseWriter, r *http.Request) { var payload struct { ClusterID string `json:"cluster_id"` NodeFingerprint string `json:"node_fingerprint"` PublicKey string `json:"public_key"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { httpx.WriteError(w, http.StatusBadRequest, "invalid enrollment bootstrap payload") return } result, err := m.cluster.GetJoinRequestBootstrap(r.Context(), clustermodule.GetJoinRequestBootstrapInput{ ClusterID: payload.ClusterID, JoinRequestID: chi.URLParam(r, "requestID"), NodeFingerprint: payload.NodeFingerprint, PublicKey: payload.PublicKey, }) if err != nil { httpx.WriteError(w, http.StatusBadRequest, err.Error()) return } httpx.WriteJSON(w, http.StatusOK, result) } func (m *Module) registerAgent(w http.ResponseWriter, r *http.Request) { var payload struct { NodeKey string `json:"node_key"` Name string `json:"name"` OwnershipType string `json:"ownership_type"` OwnerOrganizationID *string `json:"owner_organization_id"` ReportedVersion *string `json:"reported_version"` Metadata json.RawMessage `json:"metadata"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { httpx.WriteError(w, http.StatusBadRequest, "invalid agent registration payload") return } if payload.NodeKey == "" || payload.Name == "" || payload.OwnershipType == "" { httpx.WriteError(w, http.StatusBadRequest, "node_key, name, and ownership_type are required") return } if len(payload.Metadata) == 0 { payload.Metadata = json.RawMessage(`{}`) } now := time.Now().UTC() nodeID := uuid.NewString() if err := m.db.QueryRow(r.Context(), ` INSERT INTO nodes ( id, owner_organization_id, node_key, name, ownership_type, registration_status, health_status, version_state, partition_state, desired_version, reported_version, last_seen_at, metadata, created_at, updated_at ) VALUES ($1, $2, $3, $4, $5, 'active', 'unknown', 'unknown', 'healthy', NULL, $6, $7, $8::jsonb, $9, $10) ON CONFLICT (node_key) DO UPDATE SET name = EXCLUDED.name, ownership_type = EXCLUDED.ownership_type, owner_organization_id = EXCLUDED.owner_organization_id, registration_status = 'active', reported_version = EXCLUDED.reported_version, last_seen_at = EXCLUDED.last_seen_at, metadata = EXCLUDED.metadata, updated_at = EXCLUDED.updated_at RETURNING id `, nodeID, payload.OwnerOrganizationID, payload.NodeKey, payload.Name, payload.OwnershipType, payload.ReportedVersion, now, []byte(payload.Metadata), now, now).Scan(&nodeID); err != nil { httpx.WriteError(w, http.StatusInternalServerError, err.Error()) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{ "node_id": nodeID, "status": "registered", "legacy": true, "warning": "direct node-agent registration is retained for compatibility; production enrollment must use /node-agents/enroll", }) } func (m *Module) reportHealth(w http.ResponseWriter, r *http.Request) { var payload struct { HealthStatus string `json:"health_status"` ReportedVersion *string `json:"reported_version"` Metadata json.RawMessage `json:"metadata"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { httpx.WriteError(w, http.StatusBadRequest, "invalid node health payload") return } if payload.HealthStatus == "" { payload.HealthStatus = "unknown" } if len(payload.Metadata) == 0 { payload.Metadata = json.RawMessage(`{}`) } if _, err := m.db.Exec(r.Context(), ` UPDATE nodes SET health_status = $2, reported_version = COALESCE($3, reported_version), last_seen_at = $4, metadata = $5::jsonb, updated_at = $4 WHERE id = $1 `, chi.URLParam(r, "nodeID"), payload.HealthStatus, payload.ReportedVersion, time.Now().UTC(), []byte(payload.Metadata)); err != nil { httpx.WriteError(w, http.StatusInternalServerError, err.Error()) return } httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"status": "accepted"}) } func (m *Module) reportServiceStatus(w http.ResponseWriter, r *http.Request) { var payload struct { Services []struct { ServiceType string `json:"service_type"` ReportedState string `json:"reported_state"` Metadata json.RawMessage `json:"metadata"` } `json:"services"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { httpx.WriteError(w, http.StatusBadRequest, "invalid node service status payload") return } now := time.Now().UTC() for _, service := range payload.Services { if len(service.Metadata) == 0 { service.Metadata = json.RawMessage(`{}`) } if _, err := m.db.Exec(r.Context(), ` INSERT INTO node_services ( node_id, service_type, enabled, desired_state, reported_state, last_reported_at, metadata, updated_at ) VALUES ($1, $2, FALSE, 'disabled', $3, $4, $5::jsonb, $4) ON CONFLICT (node_id, service_type) DO UPDATE SET reported_state = EXCLUDED.reported_state, last_reported_at = EXCLUDED.last_reported_at, metadata = EXCLUDED.metadata, updated_at = EXCLUDED.updated_at `, chi.URLParam(r, "nodeID"), service.ServiceType, service.ReportedState, now, []byte(service.Metadata)); err != nil { httpx.WriteError(w, http.StatusInternalServerError, err.Error()) return } } httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"status": "accepted"}) } func (m *Module) listVPNAssignments(w http.ResponseWriter, r *http.Request) { items, err := m.cluster.ListNodeVPNAssignments(r.Context(), chi.URLParam(r, "clusterID"), chi.URLParam(r, "nodeID")) if writeClusterServiceError(w, err) { return } httpx.WriteJSON(w, http.StatusOK, map[string]any{ "vpn_assignments": items, "runtime_execution_enabled": false, }) } func (m *Module) reportVPNAssignmentStatus(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 } observedAt := time.Time{} if payload.ObservedAt != nil { observedAt = *payload.ObservedAt } item, err := m.cluster.ReportNodeVPNAssignmentStatus(r.Context(), clustermodule.ReportNodeVPNAssignmentStatusInput{ ClusterID: chi.URLParam(r, "clusterID"), NodeID: chi.URLParam(r, "nodeID"), VPNConnectionID: chi.URLParam(r, "vpnConnectionID"), ObservedStatus: payload.ObservedStatus, StatusPayload: payload.StatusPayload, ObservedAt: observedAt, }) if writeClusterServiceError(w, err) { return } httpx.WriteJSON(w, http.StatusAccepted, map[string]any{ "vpn_assignment_status": item, "runtime_execution_enabled": false, }) } func (m *Module) requestUpdateManifest(w http.ResponseWriter, r *http.Request) { nodeID := chi.URLParam(r, "nodeID") var mode, channel string var canary, automatic bool var desiredVersion *string if err := m.db.QueryRow(r.Context(), ` SELECT n.desired_version, p.mode, p.channel, p.canary, p.automatic_rollout FROM nodes n LEFT JOIN node_update_policies p ON p.node_id = n.id WHERE n.id = $1 `, nodeID).Scan(&desiredVersion, &mode, &channel, &canary, &automatic); err != nil { httpx.WriteError(w, http.StatusInternalServerError, err.Error()) return } httpx.WriteJSON(w, http.StatusOK, map[string]any{ "manifest": map[string]any{ "node_id": nodeID, "desired_version": desiredVersion, "mode": mode, "channel": channel, "canary": canary, "automatic_rollout": automatic, }, }) } func (m *Module) acknowledgeUpdateResult(w http.ResponseWriter, r *http.Request) { m.recordUpdateRun(w, r, "update") } func (m *Module) reportRollbackResult(w http.ResponseWriter, r *http.Request) { m.recordUpdateRun(w, r, "rollback") } func (m *Module) recordUpdateRun(w http.ResponseWriter, r *http.Request, action string) { var payload struct { TargetVersion string `json:"target_version"` Status string `json:"status"` Payload json.RawMessage `json:"payload"` } if err := json.NewDecoder(r.Body).Decode(&payload); err != nil { httpx.WriteError(w, http.StatusBadRequest, "invalid update result payload") return } if payload.Status == "" { payload.Status = "acknowledged" } if len(payload.Payload) == 0 { payload.Payload = json.RawMessage(`{}`) } now := time.Now().UTC() runID := uuid.NewString() if _, err := m.db.Exec(r.Context(), ` INSERT INTO node_agent_update_runs ( id, node_id, action, target_version, status, requested_at, acknowledged_at, completed_at, payload ) VALUES ($1, $2, $3, $4, $5, $6, $6, CASE WHEN $5 IN ('succeeded', 'failed') THEN $6 ELSE NULL END, $7::jsonb) `, runID, chi.URLParam(r, "nodeID"), action, payload.TargetVersion, payload.Status, now, []byte(payload.Payload)); err != nil { httpx.WriteError(w, http.StatusInternalServerError, err.Error()) return } if action == "update" && payload.Status == "succeeded" { _, _ = m.db.Exec(r.Context(), ` UPDATE nodes SET reported_version = $2, version_state = 'current', updated_at = $3 WHERE id = $1 `, chi.URLParam(r, "nodeID"), payload.TargetVersion, now) } if action == "rollback" && payload.Status == "succeeded" { _, _ = m.db.Exec(r.Context(), ` UPDATE nodes SET reported_version = $2, version_state = 'rollback', updated_at = $3 WHERE id = $1 `, chi.URLParam(r, "nodeID"), payload.TargetVersion, now) } httpx.WriteJSON(w, http.StatusAccepted, map[string]any{"status": "accepted", "run_id": runID}) } func writeClusterServiceError(w http.ResponseWriter, err error) bool { if err == nil { return false } switch { case errors.Is(err, clustermodule.ErrVPNLeaseOwnerNotAllowed), errors.Is(err, clustermodule.ErrVPNLeaseOwnerRoleRequired): httpx.WriteError(w, http.StatusForbidden, err.Error()) case errors.Is(err, clustermodule.ErrInvalidPayload): httpx.WriteError(w, http.StatusBadRequest, err.Error()) default: httpx.WriteError(w, http.StatusInternalServerError, err.Error()) } return true }