Files
rdp-proxy/backend/internal/modules/nodeagent/module.go
T
2026-05-18 21:33:39 +03:00

416 lines
16 KiB
Go

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 {
ClusterID string `json:"cluster_id"`
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
}
if payload.ClusterID != "" {
if _, err := m.db.Exec(r.Context(), `
INSERT INTO cluster_memberships (cluster_id, node_id, membership_status, joined_at, last_seen_at, metadata)
VALUES ($1::uuid, $2::uuid, 'active', $3, $3, $4::jsonb)
ON CONFLICT (cluster_id, node_id) DO UPDATE SET
membership_status = 'active',
last_seen_at = EXCLUDED.last_seen_at,
metadata = cluster_memberships.metadata || EXCLUDED.metadata
`, payload.ClusterID, nodeID, now, []byte(`{"source":"fabric_control_candidate_registration"}`)); err != nil {
httpx.WriteError(w, http.StatusInternalServerError, err.Error())
return
}
}
httpx.WriteJSON(w, http.StatusOK, map[string]any{
"node_id": nodeID,
"status": "registered",
"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
}