From e6088c79a331d7c1183e6ec7dafb685d6fef1a2e Mon Sep 17 00:00:00 2001 From: shankar0123 Date: Sat, 4 Apr 2026 01:09:53 -0400 Subject: [PATCH] feat(M35): dynamic target configuration with encrypted config, test connection, and GUI updates Mirror M34's dynamic issuer config pattern for deployment targets: AES-256-GCM encrypted config storage, sensitive field redaction in API responses, agent heartbeat-based test connection endpoint, and full frontend updates including test status indicators, source badges, and removal of stale hostname/status fields from the Target interface. Co-Authored-By: Claude Opus 4.6 --- cmd/server/main.go | 2 +- internal/api/handler/target_handler_test.go | 84 ++++++- internal/api/handler/targets.go | 34 +++ internal/api/router/router.go | 1 + internal/domain/connector.go | 20 +- internal/integration/lifecycle_test.go | 12 + internal/repository/interfaces.go | 3 + internal/repository/postgres/target.go | 95 ++++++-- internal/service/concurrent_test.go | 5 +- internal/service/config_helpers.go | 42 ++++ internal/service/context_test.go | 3 +- internal/service/issuer.go | 37 --- internal/service/target.go | 257 +++++++++++++++++++- internal/service/target_test.go | 209 ++++++++++++++-- internal/service/testutil_test.go | 13 + migrations/000010_target_config.down.sql | 5 + migrations/000010_target_config.up.sql | 16 ++ web/src/api/client.test.ts | 9 + web/src/api/client.ts | 3 + web/src/api/types.ts | 6 +- web/src/pages/CertificateDetailPage.tsx | 2 +- web/src/pages/TargetDetailPage.tsx | 99 ++++++-- web/src/pages/TargetsPage.tsx | 43 ++-- 23 files changed, 849 insertions(+), 151 deletions(-) create mode 100644 internal/service/config_helpers.go create mode 100644 migrations/000010_target_config.down.sql create mode 100644 migrations/000010_target_config.up.sql diff --git a/cmd/server/main.go b/cmd/server/main.go index 521f3b0..a50d1f1 100644 --- a/cmd/server/main.go +++ b/cmd/server/main.go @@ -185,7 +185,7 @@ func main() { logger.Error("failed to build issuer registry from database", "error", err) } logger.Info("issuer registry loaded", "issuers", issuerRegistry.Len()) - targetService := service.NewTargetService(targetRepo, auditService) + targetService := service.NewTargetService(targetRepo, auditService, agentRepo, encryptionKey, logger) profileService := service.NewProfileService(profileRepo, auditService) teamService := service.NewTeamService(teamRepo, auditService) ownerService := service.NewOwnerService(ownerRepo, auditService) diff --git a/internal/api/handler/target_handler_test.go b/internal/api/handler/target_handler_test.go index 3ea3565..8a34e36 100644 --- a/internal/api/handler/target_handler_test.go +++ b/internal/api/handler/target_handler_test.go @@ -13,11 +13,12 @@ import ( // MockTargetService is a mock implementation of TargetService interface. type MockTargetService struct { - ListTargetsFn func(page, perPage int) ([]domain.DeploymentTarget, int64, error) - GetTargetFn func(id string) (*domain.DeploymentTarget, error) - CreateTargetFn func(target domain.DeploymentTarget) (*domain.DeploymentTarget, error) - UpdateTargetFn func(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) - DeleteTargetFn func(id string) error + ListTargetsFn func(page, perPage int) ([]domain.DeploymentTarget, int64, error) + GetTargetFn func(id string) (*domain.DeploymentTarget, error) + CreateTargetFn func(target domain.DeploymentTarget) (*domain.DeploymentTarget, error) + UpdateTargetFn func(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) + DeleteTargetFn func(id string) error + TestTargetConnectionFn func(id string) error } func (m *MockTargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) { @@ -55,6 +56,13 @@ func (m *MockTargetService) DeleteTarget(id string) error { return nil } +func (m *MockTargetService) TestTargetConnection(id string) error { + if m.TestTargetConnectionFn != nil { + return m.TestTargetConnectionFn(id) + } + return nil +} + func TestListTargets_Success(t *testing.T) { now := time.Now() t1 := domain.DeploymentTarget{ @@ -419,3 +427,69 @@ func TestDeleteTarget_EmptyID(t *testing.T) { t.Fatalf("expected status 400, got %d", w.Code) } } + +func TestTestTargetConnection_Success(t *testing.T) { + mock := &MockTargetService{ + TestTargetConnectionFn: func(id string) error { + return nil + }, + } + + handler := NewTargetHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/targets/t-nginx-01/test", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.TestTargetConnection(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["status"] != "success" { + t.Errorf("expected status 'success', got %v", resp["status"]) + } +} + +func TestTestTargetConnection_Failed(t *testing.T) { + mock := &MockTargetService{ + TestTargetConnectionFn: func(id string) error { + return ErrMockServiceFailed + }, + } + + handler := NewTargetHandler(mock) + req := httptest.NewRequest(http.MethodPost, "/api/v1/targets/t-nginx-01/test", nil) + req = req.WithContext(contextWithRequestID()) + w := httptest.NewRecorder() + + handler.TestTargetConnection(w, req) + + if w.Code != http.StatusOK { + t.Fatalf("expected status 200, got %d", w.Code) + } + + var resp map[string]interface{} + if err := json.NewDecoder(w.Body).Decode(&resp); err != nil { + t.Fatalf("failed to decode response: %v", err) + } + if resp["status"] != "failed" { + t.Errorf("expected status 'failed', got %v", resp["status"]) + } +} + +func TestTestTargetConnection_MethodNotAllowed(t *testing.T) { + handler := NewTargetHandler(&MockTargetService{}) + req := httptest.NewRequest(http.MethodGet, "/api/v1/targets/t-nginx-01/test", nil) + w := httptest.NewRecorder() + + handler.TestTargetConnection(w, req) + + if w.Code != http.StatusMethodNotAllowed { + t.Fatalf("expected status 405, got %d", w.Code) + } +} diff --git a/internal/api/handler/targets.go b/internal/api/handler/targets.go index 1eda98e..84578a8 100644 --- a/internal/api/handler/targets.go +++ b/internal/api/handler/targets.go @@ -17,6 +17,7 @@ type TargetService interface { CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error) UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) DeleteTarget(id string) error + TestTargetConnection(id string) error } // TargetHandler handles HTTP requests for deployment target operations. @@ -189,3 +190,36 @@ func (h TargetHandler) DeleteTarget(w http.ResponseWriter, r *http.Request) { w.WriteHeader(http.StatusNoContent) } + +// TestTargetConnection tests target connectivity by checking the assigned agent's heartbeat. +// POST /api/v1/targets/{id}/test +func (h TargetHandler) TestTargetConnection(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + Error(w, http.StatusMethodNotAllowed, "Method not allowed") + return + } + + requestID := middleware.GetRequestID(r.Context()) + + // Extract target ID from path: /api/v1/targets/{id}/test + path := strings.TrimPrefix(r.URL.Path, "/api/v1/targets/") + parts := strings.Split(path, "/") + if len(parts) < 2 || parts[0] == "" { + ErrorWithRequestID(w, http.StatusBadRequest, "Target ID is required", requestID) + return + } + id := parts[0] + + if err := h.svc.TestTargetConnection(id); err != nil { + JSON(w, http.StatusOK, map[string]interface{}{ + "status": "failed", + "message": err.Error(), + }) + return + } + + JSON(w, http.StatusOK, map[string]interface{}{ + "status": "success", + "message": "Agent is online and reachable", + }) +} diff --git a/internal/api/router/router.go b/internal/api/router/router.go index 462794a..9074998 100644 --- a/internal/api/router/router.go +++ b/internal/api/router/router.go @@ -126,6 +126,7 @@ func (r *Router) RegisterHandlers(reg HandlerRegistry) { r.Register("GET /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.GetTarget)) r.Register("PUT /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.UpdateTarget)) r.Register("DELETE /api/v1/targets/{id}", http.HandlerFunc(reg.Targets.DeleteTarget)) + r.Register("POST /api/v1/targets/{id}/test", http.HandlerFunc(reg.Targets.TestTargetConnection)) // Agents routes: /api/v1/agents r.Register("GET /api/v1/agents", http.HandlerFunc(reg.Agents.ListAgents)) diff --git a/internal/domain/connector.go b/internal/domain/connector.go index e3e23c5..6764816 100644 --- a/internal/domain/connector.go +++ b/internal/domain/connector.go @@ -22,14 +22,18 @@ type Issuer struct { // DeploymentTarget represents a target system where certificates are deployed. type DeploymentTarget struct { - ID string `json:"id"` - Name string `json:"name"` - Type TargetType `json:"type"` - AgentID string `json:"agent_id"` - Config json.RawMessage `json:"config"` - Enabled bool `json:"enabled"` - CreatedAt time.Time `json:"created_at"` - UpdatedAt time.Time `json:"updated_at"` + ID string `json:"id"` + Name string `json:"name"` + Type TargetType `json:"type"` + AgentID string `json:"agent_id"` + Config json.RawMessage `json:"config"` + EncryptedConfig []byte `json:"-"` // AES-GCM encrypted full config (never exposed via API) + Enabled bool `json:"enabled"` + LastTestedAt *time.Time `json:"last_tested_at,omitempty"` + TestStatus string `json:"test_status,omitempty"` + Source string `json:"source,omitempty"` + CreatedAt time.Time `json:"created_at"` + UpdatedAt time.Time `json:"updated_at"` } // Agent represents an agent running on a target system. diff --git a/internal/integration/lifecycle_test.go b/internal/integration/lifecycle_test.go index ea94331..38660c2 100644 --- a/internal/integration/lifecycle_test.go +++ b/internal/integration/lifecycle_test.go @@ -786,6 +786,14 @@ func (m *mockTargetRepository) Create(ctx context.Context, target *domain.Deploy return nil } +func (m *mockTargetRepository) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) { + if _, exists := m.targets[target.ID]; exists { + return false, nil + } + m.targets[target.ID] = target + return true, nil +} + func (m *mockTargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error { m.targets[target.ID] = target return nil @@ -1009,6 +1017,10 @@ func (m *mockTargetService) DeleteTarget(id string) error { return m.targetRepo.Delete(context.Background(), id) } +func (m *mockTargetService) TestTargetConnection(id string) error { + return nil // No-op for integration tests +} + type mockTeamService struct{} func (m *mockTeamService) ListTeams(page, perPage int) ([]domain.Team, int64, error) { diff --git a/internal/repository/interfaces.go b/internal/repository/interfaces.go index e0a6ad4..6cd0d89 100644 --- a/internal/repository/interfaces.go +++ b/internal/repository/interfaces.go @@ -68,6 +68,9 @@ type TargetRepository interface { Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) // Create stores a new target. Create(ctx context.Context, target *domain.DeploymentTarget) error + // CreateIfNotExists creates a target only if the ID doesn't already exist (ON CONFLICT DO NOTHING). + // Returns true if created, false if already existed. + CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) // Update modifies an existing target. Update(ctx context.Context, target *domain.DeploymentTarget) error // Delete removes a target. diff --git a/internal/repository/postgres/target.go b/internal/repository/postgres/target.go index 00ea1f5..6c74a6f 100644 --- a/internal/repository/postgres/target.go +++ b/internal/repository/postgres/target.go @@ -19,10 +19,40 @@ func NewTargetRepository(db *sql.DB) *TargetRepository { return &TargetRepository{db: db} } +// scanTarget scans a target row including optional M35 columns (encrypted_config, last_tested_at, test_status, source). +func scanTarget(scanner interface { + Scan(dest ...interface{}) error +}, target *domain.DeploymentTarget) error { + var lastTestedAt sql.NullTime + var testStatus sql.NullString + var source sql.NullString + if err := scanner.Scan( + &target.ID, &target.Name, &target.Type, &target.AgentID, + &target.Config, &target.EncryptedConfig, &target.Enabled, + &lastTestedAt, &testStatus, &source, + &target.CreatedAt, &target.UpdatedAt, + ); err != nil { + return err + } + if lastTestedAt.Valid { + target.LastTestedAt = &lastTestedAt.Time + } + if testStatus.Valid { + target.TestStatus = testStatus.String + } + if source.Valid { + target.Source = source.String + } + return nil +} + +// targetSelectColumns is the standard column list for target queries. +const targetSelectColumns = `id, name, type, agent_id, config, COALESCE(encrypted_config, ''::bytea), enabled, last_tested_at, COALESCE(test_status, 'untested'), COALESCE(source, 'database'), created_at, updated_at` + // List returns all targets func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT id, name, type, agent_id, config, enabled, created_at, updated_at + SELECT `+targetSelectColumns+` FROM deployment_targets ORDER BY created_at DESC `) @@ -35,8 +65,7 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget var targets []*domain.DeploymentTarget for rows.Next() { var target domain.DeploymentTarget - if err := rows.Scan(&target.ID, &target.Name, &target.Type, &target.AgentID, - &target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt); err != nil { + if err := scanTarget(rows, &target); err != nil { return nil, fmt.Errorf("failed to scan target: %w", err) } targets = append(targets, &target) @@ -52,12 +81,11 @@ func (r *TargetRepository) List(ctx context.Context) ([]*domain.DeploymentTarget // Get retrieves a target by ID func (r *TargetRepository) Get(ctx context.Context, id string) (*domain.DeploymentTarget, error) { var target domain.DeploymentTarget - err := r.db.QueryRowContext(ctx, ` - SELECT id, name, type, agent_id, config, enabled, created_at, updated_at + err := scanTarget(r.db.QueryRowContext(ctx, ` + SELECT `+targetSelectColumns+` FROM deployment_targets WHERE id = $1 - `, id).Scan(&target.ID, &target.Name, &target.Type, &target.AgentID, - &target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt) + `, id), &target) if err != nil { if err == sql.ErrNoRows { @@ -76,10 +104,11 @@ func (r *TargetRepository) Create(ctx context.Context, target *domain.Deployment } err := r.db.QueryRowContext(ctx, ` - INSERT INTO deployment_targets (id, name, type, agent_id, config, enabled, created_at, updated_at) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO deployment_targets (id, name, type, agent_id, config, encrypted_config, enabled, last_tested_at, test_status, source, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) RETURNING id - `, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.Enabled, + `, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig, + target.Enabled, target.LastTestedAt, target.TestStatus, target.Source, target.CreatedAt, target.UpdatedAt).Scan(&target.ID) if err != nil { @@ -89,6 +118,33 @@ func (r *TargetRepository) Create(ctx context.Context, target *domain.Deployment return nil } +// CreateIfNotExists creates a target only if the ID doesn't already exist (ON CONFLICT DO NOTHING). +// Returns true if created, false if already existed. +func (r *TargetRepository) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) { + if target.ID == "" { + target.ID = uuid.New().String() + } + + result, err := r.db.ExecContext(ctx, ` + INSERT INTO deployment_targets (id, name, type, agent_id, config, encrypted_config, enabled, last_tested_at, test_status, source, created_at, updated_at) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12) + ON CONFLICT (id) DO NOTHING + `, target.ID, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig, + target.Enabled, target.LastTestedAt, target.TestStatus, target.Source, + target.CreatedAt, target.UpdatedAt) + + if err != nil { + return false, fmt.Errorf("failed to create target: %w", err) + } + + rows, err := result.RowsAffected() + if err != nil { + return false, fmt.Errorf("failed to get rows affected: %w", err) + } + + return rows > 0, nil +} + // Update modifies an existing target func (r *TargetRepository) Update(ctx context.Context, target *domain.DeploymentTarget) error { result, err := r.db.ExecContext(ctx, ` @@ -97,10 +153,16 @@ func (r *TargetRepository) Update(ctx context.Context, target *domain.Deployment type = $2, agent_id = $3, config = $4, - enabled = $5, - updated_at = $6 - WHERE id = $7 - `, target.Name, target.Type, target.AgentID, target.Config, target.Enabled, target.UpdatedAt, target.ID) + encrypted_config = $5, + enabled = $6, + last_tested_at = $7, + test_status = $8, + source = $9, + updated_at = $10 + WHERE id = $11 + `, target.Name, target.Type, target.AgentID, target.Config, target.EncryptedConfig, + target.Enabled, target.LastTestedAt, target.TestStatus, target.Source, + target.UpdatedAt, target.ID) if err != nil { return fmt.Errorf("failed to update target: %w", err) @@ -141,7 +203,7 @@ func (r *TargetRepository) Delete(ctx context.Context, id string) error { // ListByCertificate returns all targets for a given certificate func (r *TargetRepository) ListByCertificate(ctx context.Context, certID string) ([]*domain.DeploymentTarget, error) { rows, err := r.db.QueryContext(ctx, ` - SELECT dt.id, dt.name, dt.type, dt.agent_id, dt.config, dt.enabled, dt.created_at, dt.updated_at + SELECT dt.id, dt.name, dt.type, dt.agent_id, dt.config, COALESCE(dt.encrypted_config, ''::bytea), dt.enabled, dt.last_tested_at, COALESCE(dt.test_status, 'untested'), COALESCE(dt.source, 'database'), dt.created_at, dt.updated_at FROM deployment_targets dt INNER JOIN certificate_target_mappings ctm ON dt.id = ctm.target_id WHERE ctm.certificate_id = $1 @@ -156,8 +218,7 @@ func (r *TargetRepository) ListByCertificate(ctx context.Context, certID string) var targets []*domain.DeploymentTarget for rows.Next() { var target domain.DeploymentTarget - if err := rows.Scan(&target.ID, &target.Name, &target.Type, &target.AgentID, - &target.Config, &target.Enabled, &target.CreatedAt, &target.UpdatedAt); err != nil { + if err := scanTarget(rows, &target); err != nil { return nil, fmt.Errorf("failed to scan target: %w", err) } targets = append(targets, &target) diff --git a/internal/service/concurrent_test.go b/internal/service/concurrent_test.go index 8046c9c..1d594dd 100644 --- a/internal/service/concurrent_test.go +++ b/internal/service/concurrent_test.go @@ -4,6 +4,7 @@ import ( "context" "fmt" "log/slog" + "os" "sync" "testing" @@ -193,7 +194,7 @@ func TestConcurrentTargetCRUD(t *testing.T) { Targets: make(map[string]*domain.DeploymentTarget), } - targetSvc := NewTargetService(mockTargetRepo, nil) + targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil))) var mu sync.Mutex createdTargets := make([]string, 0) @@ -402,7 +403,7 @@ func TestConcurrentMixedOperations(t *testing.T) { // Setup services auditSvc := &AuditService{auditRepo: mockAuditRepo} certSvc := NewCertificateService(mockCertRepo, nil, auditSvc) - targetSvc := NewTargetService(mockTargetRepo, auditSvc) + targetSvc := NewTargetService(mockTargetRepo, auditSvc, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil))) var wg sync.WaitGroup errChan := make(chan error, 30) diff --git a/internal/service/config_helpers.go b/internal/service/config_helpers.go new file mode 100644 index 0000000..56565fa --- /dev/null +++ b/internal/service/config_helpers.go @@ -0,0 +1,42 @@ +package service + +import ( + "encoding/json" + "strings" +) + +// sensitiveKeys are config key substrings that should be redacted in API responses. +var sensitiveKeys = []string{"password", "secret", "token", "key", "hmac", "private", "credentials"} + +// isSensitiveConfigKey checks if a config key contains sensitive substrings. +func isSensitiveConfigKey(key string) bool { + lower := strings.ToLower(key) + for _, s := range sensitiveKeys { + if strings.Contains(lower, s) { + return true + } + } + return false +} + +// redactConfigJSON replaces sensitive values in a JSON config with "********". +func redactConfigJSON(configJSON json.RawMessage) json.RawMessage { + var m map[string]interface{} + if err := json.Unmarshal(configJSON, &m); err != nil { + return configJSON // Not a JSON object, return as-is + } + + for k, v := range m { + if isSensitiveConfigKey(k) { + if str, ok := v.(string); ok && str != "" { + m[k] = "********" + } + } + } + + redacted, err := json.Marshal(m) + if err != nil { + return configJSON + } + return json.RawMessage(redacted) +} diff --git a/internal/service/context_test.go b/internal/service/context_test.go index 3070dc7..79be98c 100644 --- a/internal/service/context_test.go +++ b/internal/service/context_test.go @@ -3,6 +3,7 @@ package service import ( "context" "log/slog" + "os" "testing" "time" @@ -141,7 +142,7 @@ func TestTargetService_ListWithCancelledContext(t *testing.T) { mockTargetRepo := &mockTargetRepo{ Targets: make(map[string]*domain.DeploymentTarget), } - targetSvc := NewTargetService(mockTargetRepo, nil) + targetSvc := NewTargetService(mockTargetRepo, nil, nil, nil, slog.New(slog.NewTextHandler(os.Stderr, nil))) _, _, err := targetSvc.List(ctx, 1, 50) diff --git a/internal/service/issuer.go b/internal/service/issuer.go index 91e368b..5b79f25 100644 --- a/internal/service/issuer.go +++ b/internal/service/issuer.go @@ -6,7 +6,6 @@ import ( "fmt" "log/slog" "os" - "strings" "time" "github.com/shankar0123/certctl/internal/config" @@ -16,9 +15,6 @@ import ( "github.com/shankar0123/certctl/internal/repository" ) -// sensitiveKeys are config key substrings that should be redacted in API responses. -var sensitiveKeys = []string{"password", "secret", "token", "key", "hmac", "private", "credentials"} - // IssuerService provides business logic for certificate issuer management. type IssuerService struct { issuerRepo repository.IssuerRepository @@ -703,39 +699,6 @@ func (s *IssuerService) updateTestStatus(ctx context.Context, iss *domain.Issuer } } -// redactConfigJSON replaces sensitive values in a JSON config with "********". -func redactConfigJSON(configJSON json.RawMessage) json.RawMessage { - var m map[string]interface{} - if err := json.Unmarshal(configJSON, &m); err != nil { - return configJSON // Not a JSON object, return as-is - } - - for k, v := range m { - if isSensitiveConfigKey(k) { - if str, ok := v.(string); ok && str != "" { - m[k] = "********" - } - } - } - - redacted, err := json.Marshal(m) - if err != nil { - return configJSON - } - return json.RawMessage(redacted) -} - -// isSensitiveConfigKey checks if a config key contains sensitive substrings. -func isSensitiveConfigKey(key string) bool { - lower := strings.ToLower(key) - for _, s := range sensitiveKeys { - if strings.Contains(lower, s) { - return true - } - } - return false -} - // getEnvForSeed reads an environment variable for seed data construction. func getEnvForSeed(key string) string { return os.Getenv(key) diff --git a/internal/service/target.go b/internal/service/target.go index 9785599..33518d1 100644 --- a/internal/service/target.go +++ b/internal/service/target.go @@ -2,28 +2,58 @@ package service import ( "context" + "encoding/json" "fmt" "log/slog" "time" + "github.com/shankar0123/certctl/internal/crypto" "github.com/shankar0123/certctl/internal/domain" "github.com/shankar0123/certctl/internal/repository" ) +// validTargetTypes is the set of allowed target types for validation. +var validTargetTypes = map[domain.TargetType]bool{ + domain.TargetTypeNGINX: true, + domain.TargetTypeApache: true, + domain.TargetTypeHAProxy: true, + domain.TargetTypeF5: true, + domain.TargetTypeIIS: true, + domain.TargetTypeTraefik: true, + domain.TargetTypeCaddy: true, + domain.TargetTypeEnvoy: true, + domain.TargetTypePostfix: true, + domain.TargetTypeDovecot: true, +} + +// isValidTargetType checks if a type string is a known target type. +func isValidTargetType(t domain.TargetType) bool { + return validTargetTypes[t] +} + // TargetService provides business logic for deployment target management. type TargetService struct { - targetRepo repository.TargetRepository - auditService *AuditService + targetRepo repository.TargetRepository + agentRepo repository.AgentRepository + auditService *AuditService + encryptionKey []byte + logger *slog.Logger } // NewTargetService creates a new target service. func NewTargetService( targetRepo repository.TargetRepository, auditService *AuditService, + agentRepo repository.AgentRepository, + encryptionKey []byte, + logger *slog.Logger, ) *TargetService { return &TargetService{ - targetRepo: targetRepo, - auditService: auditService, + targetRepo: targetRepo, + agentRepo: agentRepo, + auditService: auditService, + encryptionKey: encryptionKey, + logger: logger, } } @@ -61,11 +91,14 @@ func (s *TargetService) Get(ctx context.Context, id string) (*domain.DeploymentT return target, nil } -// Create validates and stores a new deployment target. +// Create validates and stores a new deployment target, encrypting sensitive config. func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTarget, actor string) error { if target.Name == "" { return fmt.Errorf("target name is required") } + if !isValidTargetType(target.Type) { + return fmt.Errorf("unsupported target type: %s", target.Type) + } if target.ID == "" { target.ID = generateID("target") @@ -77,33 +110,68 @@ func (s *TargetService) Create(ctx context.Context, target *domain.DeploymentTar if target.UpdatedAt.IsZero() { target.UpdatedAt = now } + if target.TestStatus == "" { + target.TestStatus = "untested" + } + if target.Source == "" { + target.Source = "database" + } + + // Encrypt the full config and store redacted version in config column + if len(target.Config) > 0 { + encrypted, _, err := crypto.EncryptIfKeySet([]byte(target.Config), s.encryptionKey) + if err != nil { + return fmt.Errorf("failed to encrypt config: %w", err) + } + target.EncryptedConfig = encrypted + target.Config = redactConfigJSON(target.Config) + } + if err := s.targetRepo.Create(ctx, target); err != nil { return fmt.Errorf("failed to create target: %w", err) } if s.auditService != nil { if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "create_target", "target", target.ID, nil); auditErr != nil { - slog.Error("failed to record audit event", "error", auditErr) + s.logger.Error("failed to record audit event", "error", auditErr) } } return nil } -// Update modifies an existing deployment target. +// Update modifies an existing deployment target. Handles "********" preservation for sensitive fields. func (s *TargetService) Update(ctx context.Context, id string, target *domain.DeploymentTarget, actor string) error { if target.Name == "" { return fmt.Errorf("target name is required") } target.ID = id + target.UpdatedAt = time.Now() + + // If config contains "********" values, merge with existing decrypted config + if len(target.Config) > 0 { + mergedConfig, err := s.mergeRedactedConfig(ctx, id, target.Config) + if err != nil { + return fmt.Errorf("failed to merge config: %w", err) + } + + // Encrypt the merged config + encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey) + if encErr != nil { + return fmt.Errorf("failed to encrypt config: %w", encErr) + } + target.EncryptedConfig = encrypted + target.Config = redactConfigJSON(json.RawMessage(mergedConfig)) + } + if err := s.targetRepo.Update(ctx, target); err != nil { return fmt.Errorf("failed to update target %s: %w", id, err) } if s.auditService != nil { if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "update_target", "target", id, nil); auditErr != nil { - slog.Error("failed to record audit event", "error", auditErr) + s.logger.Error("failed to record audit event", "error", auditErr) } } @@ -118,13 +186,50 @@ func (s *TargetService) Delete(ctx context.Context, id string, actor string) err if s.auditService != nil { if auditErr := s.auditService.RecordEvent(ctx, actor, domain.ActorTypeUser, "delete_target", "target", id, nil); auditErr != nil { - slog.Error("failed to record audit event", "error", auditErr) + s.logger.Error("failed to record audit event", "error", auditErr) } } return nil } +// TestConnection tests a target's connectivity by checking the assigned agent's heartbeat status. +// Target connectors run on agents, not on the server, so we can't instantiate a connector here. +// Instead, we verify the agent is online and reachable. +func (s *TargetService) TestConnection(ctx context.Context, id string) error { + target, err := s.targetRepo.Get(ctx, id) + if err != nil { + return fmt.Errorf("target not found: %w", err) + } + + if target.AgentID == "" { + s.updateTestStatus(ctx, target, "failed") + return fmt.Errorf("target has no assigned agent") + } + + agent, err := s.agentRepo.Get(ctx, target.AgentID) + if err != nil { + s.updateTestStatus(ctx, target, "failed") + return fmt.Errorf("assigned agent not found: %w", err) + } + + if agent.Status != domain.AgentStatusOnline { + s.updateTestStatus(ctx, target, "failed") + return fmt.Errorf("assigned agent %s is %s (expected Online)", agent.ID, agent.Status) + } + + // Check heartbeat freshness (agent must have heartbeated within the last 5 minutes) + if agent.LastHeartbeatAt != nil { + if time.Since(*agent.LastHeartbeatAt) > 5*time.Minute { + s.updateTestStatus(ctx, target, "failed") + return fmt.Errorf("assigned agent %s last heartbeat was %s ago (stale)", agent.ID, time.Since(*agent.LastHeartbeatAt).Round(time.Second)) + } + } + + s.updateTestStatus(ctx, target, "success") + return nil +} + // ListTargets returns paginated targets (handler interface method). func (s *TargetService) ListTargets(page, perPage int) ([]domain.DeploymentTarget, int64, error) { if page < 1 { @@ -157,6 +262,9 @@ func (s *TargetService) GetTarget(id string) (*domain.DeploymentTarget, error) { // CreateTarget creates a new target (handler interface method). func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.DeploymentTarget, error) { + if !isValidTargetType(target.Type) { + return nil, fmt.Errorf("unsupported target type: %s", target.Type) + } if target.ID == "" { target.ID = generateID("target") } @@ -167,6 +275,23 @@ func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.De if target.UpdatedAt.IsZero() { target.UpdatedAt = now } + if target.TestStatus == "" { + target.TestStatus = "untested" + } + if target.Source == "" { + target.Source = "database" + } + + // Encrypt config + if len(target.Config) > 0 { + encrypted, _, err := crypto.EncryptIfKeySet([]byte(target.Config), s.encryptionKey) + if err != nil { + return nil, fmt.Errorf("failed to encrypt config: %w", err) + } + target.EncryptedConfig = encrypted + target.Config = redactConfigJSON(target.Config) + } + if err := s.targetRepo.Create(context.Background(), &target); err != nil { return nil, fmt.Errorf("failed to create target: %w", err) } @@ -176,6 +301,23 @@ func (s *TargetService) CreateTarget(target domain.DeploymentTarget) (*domain.De // UpdateTarget modifies a target (handler interface method). func (s *TargetService) UpdateTarget(id string, target domain.DeploymentTarget) (*domain.DeploymentTarget, error) { target.ID = id + target.UpdatedAt = time.Now() + + // Merge redacted fields with existing config + if len(target.Config) > 0 { + mergedConfig, err := s.mergeRedactedConfig(context.Background(), id, target.Config) + if err != nil { + return nil, fmt.Errorf("failed to merge config: %w", err) + } + + encrypted, _, encErr := crypto.EncryptIfKeySet(mergedConfig, s.encryptionKey) + if encErr != nil { + return nil, fmt.Errorf("failed to encrypt config: %w", encErr) + } + target.EncryptedConfig = encrypted + target.Config = redactConfigJSON(json.RawMessage(mergedConfig)) + } + if err := s.targetRepo.Update(context.Background(), &target); err != nil { return nil, fmt.Errorf("failed to update target: %w", err) } @@ -186,3 +328,100 @@ func (s *TargetService) UpdateTarget(id string, target domain.DeploymentTarget) func (s *TargetService) DeleteTarget(id string) error { return s.targetRepo.Delete(context.Background(), id) } + +// TestTargetConnection tests target connectivity (handler interface method). +func (s *TargetService) TestTargetConnection(id string) error { + return s.TestConnection(context.Background(), id) +} + +// --- Internal helpers --- + +// getDecryptedConfig returns the decrypted config JSON for a target. +func (s *TargetService) getDecryptedConfig(target *domain.DeploymentTarget) (json.RawMessage, error) { + if len(target.EncryptedConfig) > 0 { + decrypted, err := crypto.DecryptIfKeySet(target.EncryptedConfig, s.encryptionKey) + if err != nil { + return nil, err + } + return json.RawMessage(decrypted), nil + } + if len(target.Config) > 0 { + return target.Config, nil + } + return json.RawMessage("{}"), nil +} + +// mergeRedactedConfig merges incoming config (which may have "********" values) +// with the existing decrypted config so sensitive fields are preserved. +func (s *TargetService) mergeRedactedConfig(ctx context.Context, id string, incoming json.RawMessage) ([]byte, error) { + // Parse incoming config + var incomingMap map[string]interface{} + if err := json.Unmarshal(incoming, &incomingMap); err != nil { + s.logger.Warn("mergeRedactedConfig: incoming config is not a JSON object, using as-is", "target", id, "error", err) + return incoming, nil + } + + // Check if any values are "********" + hasRedacted := false + for _, v := range incomingMap { + if str, ok := v.(string); ok && str == "********" { + hasRedacted = true + break + } + } + + if !hasRedacted { + return incoming, nil // No redacted values, use incoming as-is + } + + // Load existing target to get real values + existing, err := s.targetRepo.Get(ctx, id) + if err != nil { + s.logger.Warn("mergeRedactedConfig: could not load existing target, redacted values will be lost", "target", id, "error", err) + return incoming, nil + } + + existingConfig, err := s.getDecryptedConfig(existing) + if err != nil { + s.logger.Warn("mergeRedactedConfig: could not decrypt existing config, redacted values will be lost", "target", id, "error", err) + return incoming, nil + } + + var existingMap map[string]interface{} + if err := json.Unmarshal(existingConfig, &existingMap); err != nil { + s.logger.Warn("mergeRedactedConfig: existing config is not a JSON object, redacted values will be lost", "target", id, "error", err) + return incoming, nil + } + + // Merge: for each "********" value in incoming, use existing value + for k, v := range incomingMap { + if str, ok := v.(string); ok && str == "********" { + if existingVal, exists := existingMap[k]; exists { + incomingMap[k] = existingVal + } + } + } + + return json.Marshal(incomingMap) +} + +// updateTestStatus updates the test_status and last_tested_at fields in the database +// and records an audit event. +func (s *TargetService) updateTestStatus(ctx context.Context, target *domain.DeploymentTarget, status string) { + now := time.Now() + target.TestStatus = status + target.LastTestedAt = &now + target.UpdatedAt = now + if err := s.targetRepo.Update(ctx, target); err != nil { + s.logger.Error("failed to update test status", "target", target.ID, "status", status, "error", err) + } + + // Record audit event for connection test + if s.auditService != nil { + action := "target_test_connection_" + status + details := map[string]interface{}{"target_type": string(target.Type), "result": status, "agent_id": target.AgentID} + if auditErr := s.auditService.RecordEvent(ctx, "system", domain.ActorTypeSystem, action, "target", target.ID, details); auditErr != nil { + s.logger.Error("failed to record test connection audit event", "error", auditErr) + } + } +} diff --git a/internal/service/target_test.go b/internal/service/target_test.go index 4e0a583..833f17c 100644 --- a/internal/service/target_test.go +++ b/internal/service/target_test.go @@ -3,21 +3,26 @@ package service import ( "context" "encoding/json" + "log/slog" + "os" "testing" + "time" "github.com/shankar0123/certctl/internal/domain" ) // newTestTargetService creates a TargetService with mock repositories for testing. -func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo) { +func newTestTargetService() (*TargetService, *mockTargetRepo, *mockAuditRepo, *mockAgentRepo) { targetRepo := &mockTargetRepo{Targets: make(map[string]*domain.DeploymentTarget)} auditRepo := newMockAuditRepository() auditSvc := NewAuditService(auditRepo) - return NewTargetService(targetRepo, auditSvc), targetRepo, auditRepo + agentRepo := &mockAgentRepo{Agents: make(map[string]*domain.Agent), HeartbeatUpdates: make(map[string]time.Time)} + logger := slog.New(slog.NewTextHandler(os.Stderr, &slog.HandlerOptions{Level: slog.LevelError})) + return NewTargetService(targetRepo, auditSvc, agentRepo, nil, logger), targetRepo, auditRepo, agentRepo } func TestTargetService_List_Success(t *testing.T) { - svc, targetRepo, _ := newTestTargetService() + svc, targetRepo, _, _ := newTestTargetService() ctx := context.Background() // Add 3 targets @@ -44,7 +49,7 @@ func TestTargetService_List_Success(t *testing.T) { } func TestTargetService_List_DefaultPagination(t *testing.T) { - svc, _, _ := newTestTargetService() + svc, _, _, _ := newTestTargetService() ctx := context.Background() // Call with invalid pagination (page=0, perPage=0) @@ -60,7 +65,7 @@ func TestTargetService_List_DefaultPagination(t *testing.T) { } func TestTargetService_List_EmptyPage(t *testing.T) { - svc, targetRepo, _ := newTestTargetService() + svc, targetRepo, _, _ := newTestTargetService() ctx := context.Background() // Add 3 targets @@ -87,7 +92,7 @@ func TestTargetService_List_EmptyPage(t *testing.T) { } func TestTargetService_List_RepoError(t *testing.T) { - svc, targetRepo, _ := newTestTargetService() + svc, targetRepo, _, _ := newTestTargetService() ctx := context.Background() // Set repo to return error @@ -104,7 +109,7 @@ func TestTargetService_List_RepoError(t *testing.T) { } func TestTargetService_Get_Success(t *testing.T) { - svc, targetRepo, _ := newTestTargetService() + svc, targetRepo, _, _ := newTestTargetService() ctx := context.Background() target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX} @@ -121,7 +126,7 @@ func TestTargetService_Get_Success(t *testing.T) { } func TestTargetService_Get_NotFound(t *testing.T) { - svc, _, _ := newTestTargetService() + svc, _, _, _ := newTestTargetService() ctx := context.Background() result, err := svc.Get(ctx, "nonexistent") @@ -135,7 +140,7 @@ func TestTargetService_Get_NotFound(t *testing.T) { } func TestTargetService_Create_Success(t *testing.T) { - svc, targetRepo, auditRepo := newTestTargetService() + svc, targetRepo, auditRepo, _ := newTestTargetService() ctx := context.Background() target := &domain.DeploymentTarget{ @@ -168,6 +173,14 @@ func TestTargetService_Create_Success(t *testing.T) { t.Errorf("expected timestamps to be set, CreatedAt=%v, UpdatedAt=%v", target.CreatedAt, target.UpdatedAt) } + // Verify test status and source defaults + if target.TestStatus != "untested" { + t.Errorf("expected test_status 'untested', got %s", target.TestStatus) + } + if target.Source != "database" { + t.Errorf("expected source 'database', got %s", target.Source) + } + // Verify audit event if len(auditRepo.Events) == 0 { t.Fatalf("expected audit event, got none") @@ -184,7 +197,7 @@ func TestTargetService_Create_Success(t *testing.T) { } func TestTargetService_Create_MissingName(t *testing.T) { - svc, _, _ := newTestTargetService() + svc, _, _, _ := newTestTargetService() ctx := context.Background() target := &domain.DeploymentTarget{ @@ -197,8 +210,23 @@ func TestTargetService_Create_MissingName(t *testing.T) { } } +func TestTargetService_Create_InvalidType(t *testing.T) { + svc, _, _, _ := newTestTargetService() + ctx := context.Background() + + target := &domain.DeploymentTarget{ + Name: "Bad Target", + Type: domain.TargetType("InvalidType"), + } + + err := svc.Create(ctx, target, "test-actor") + if err == nil { + t.Fatalf("expected error for invalid type, got nil") + } +} + func TestTargetService_Create_RepoError(t *testing.T) { - svc, targetRepo, _ := newTestTargetService() + svc, targetRepo, _, _ := newTestTargetService() ctx := context.Background() targetRepo.CreateErr = errNotFound @@ -215,7 +243,7 @@ func TestTargetService_Create_RepoError(t *testing.T) { } func TestTargetService_Update_Success(t *testing.T) { - svc, targetRepo, auditRepo := newTestTargetService() + svc, targetRepo, auditRepo, _ := newTestTargetService() ctx := context.Background() // Create initial target @@ -251,7 +279,7 @@ func TestTargetService_Update_Success(t *testing.T) { } func TestTargetService_Update_MissingName(t *testing.T) { - svc, _, _ := newTestTargetService() + svc, _, _, _ := newTestTargetService() ctx := context.Background() target := &domain.DeploymentTarget{ @@ -265,7 +293,7 @@ func TestTargetService_Update_MissingName(t *testing.T) { } func TestTargetService_Delete_Success(t *testing.T) { - svc, targetRepo, auditRepo := newTestTargetService() + svc, targetRepo, auditRepo, _ := newTestTargetService() ctx := context.Background() // Create initial target @@ -295,7 +323,7 @@ func TestTargetService_Delete_Success(t *testing.T) { } func TestTargetService_Delete_RepoError(t *testing.T) { - svc, targetRepo, _ := newTestTargetService() + svc, targetRepo, _, _ := newTestTargetService() ctx := context.Background() targetRepo.DeleteErr = errNotFound @@ -307,7 +335,7 @@ func TestTargetService_Delete_RepoError(t *testing.T) { } func TestTargetService_ListTargets_Success(t *testing.T) { - svc, targetRepo, _ := newTestTargetService() + svc, targetRepo, _, _ := newTestTargetService() // Add targets target1 := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX} @@ -331,7 +359,7 @@ func TestTargetService_ListTargets_Success(t *testing.T) { } func TestTargetService_GetTarget_Success(t *testing.T) { - svc, targetRepo, _ := newTestTargetService() + svc, targetRepo, _, _ := newTestTargetService() target := &domain.DeploymentTarget{ID: "t-1", Name: "Target 1", Type: domain.TargetTypeNGINX} targetRepo.AddTarget(target) @@ -347,7 +375,7 @@ func TestTargetService_GetTarget_Success(t *testing.T) { } func TestTargetService_CreateTarget_Success(t *testing.T) { - svc, targetRepo, _ := newTestTargetService() + svc, targetRepo, _, _ := newTestTargetService() target := domain.DeploymentTarget{ Name: "New Target", @@ -369,8 +397,22 @@ func TestTargetService_CreateTarget_Success(t *testing.T) { } } +func TestTargetService_CreateTarget_InvalidType(t *testing.T) { + svc, _, _, _ := newTestTargetService() + + target := domain.DeploymentTarget{ + Name: "Bad Target", + Type: domain.TargetType("Unknown"), + } + + _, err := svc.CreateTarget(target) + if err == nil { + t.Fatalf("expected error for invalid type, got nil") + } +} + func TestTargetService_UpdateTarget_Success(t *testing.T) { - svc, targetRepo, _ := newTestTargetService() + svc, targetRepo, _, _ := newTestTargetService() // Create initial target target := &domain.DeploymentTarget{ID: "t-1", Name: "Old Name", Type: domain.TargetTypeNGINX} @@ -393,7 +435,7 @@ func TestTargetService_UpdateTarget_Success(t *testing.T) { } func TestTargetService_DeleteTarget_Success(t *testing.T) { - svc, targetRepo, _ := newTestTargetService() + svc, targetRepo, _, _ := newTestTargetService() // Create initial target target := &domain.DeploymentTarget{ID: "t-1", Name: "Target To Delete", Type: domain.TargetTypeNGINX} @@ -410,3 +452,130 @@ func TestTargetService_DeleteTarget_Success(t *testing.T) { t.Errorf("target should be deleted from repo") } } + +func TestTargetService_TestConnection_AgentOnline(t *testing.T) { + svc, targetRepo, _, agentRepo := newTestTargetService() + ctx := context.Background() + + // Set up agent + heartbeat := time.Now() + agent := &domain.Agent{ + ID: "agent-1", + Name: "Test Agent", + Status: domain.AgentStatusOnline, + LastHeartbeatAt: &heartbeat, + } + agentRepo.Create(ctx, agent) + + // Set up target assigned to agent + target := &domain.DeploymentTarget{ + ID: "t-1", + Name: "Test Target", + Type: domain.TargetTypeNGINX, + AgentID: "agent-1", + } + targetRepo.AddTarget(target) + + // Test connection should succeed + err := svc.TestConnection(ctx, "t-1") + if err != nil { + t.Fatalf("expected success, got error: %v", err) + } + + // Verify test status was updated + stored := targetRepo.Targets["t-1"] + if stored.TestStatus != "success" { + t.Errorf("expected test_status 'success', got %s", stored.TestStatus) + } + if stored.LastTestedAt == nil { + t.Error("expected last_tested_at to be set") + } +} + +func TestTargetService_TestConnection_AgentOffline(t *testing.T) { + svc, targetRepo, _, agentRepo := newTestTargetService() + ctx := context.Background() + + // Set up offline agent + agent := &domain.Agent{ + ID: "agent-1", + Name: "Offline Agent", + Status: domain.AgentStatusOffline, + } + agentRepo.Create(ctx, agent) + + // Set up target + target := &domain.DeploymentTarget{ + ID: "t-1", + Name: "Test Target", + Type: domain.TargetTypeNGINX, + AgentID: "agent-1", + } + targetRepo.AddTarget(target) + + err := svc.TestConnection(ctx, "t-1") + if err == nil { + t.Fatal("expected error for offline agent, got nil") + } + + stored := targetRepo.Targets["t-1"] + if stored.TestStatus != "failed" { + t.Errorf("expected test_status 'failed', got %s", stored.TestStatus) + } +} + +func TestTargetService_TestConnection_NoAgent(t *testing.T) { + svc, targetRepo, _, _ := newTestTargetService() + ctx := context.Background() + + target := &domain.DeploymentTarget{ + ID: "t-1", + Name: "Test Target", + Type: domain.TargetTypeNGINX, + AgentID: "", + } + targetRepo.AddTarget(target) + + err := svc.TestConnection(ctx, "t-1") + if err == nil { + t.Fatal("expected error for missing agent, got nil") + } +} + +func TestTargetService_TestConnection_TargetNotFound(t *testing.T) { + svc, _, _, _ := newTestTargetService() + ctx := context.Background() + + err := svc.TestConnection(ctx, "nonexistent") + if err == nil { + t.Fatal("expected error for nonexistent target, got nil") + } +} + +func TestTargetService_TestConnection_StaleHeartbeat(t *testing.T) { + svc, targetRepo, _, agentRepo := newTestTargetService() + ctx := context.Background() + + // Set up agent with stale heartbeat (10 minutes ago) + staleTime := time.Now().Add(-10 * time.Minute) + agent := &domain.Agent{ + ID: "agent-1", + Name: "Stale Agent", + Status: domain.AgentStatusOnline, + LastHeartbeatAt: &staleTime, + } + agentRepo.Create(ctx, agent) + + target := &domain.DeploymentTarget{ + ID: "t-1", + Name: "Test Target", + Type: domain.TargetTypeNGINX, + AgentID: "agent-1", + } + targetRepo.AddTarget(target) + + err := svc.TestConnection(ctx, "t-1") + if err == nil { + t.Fatal("expected error for stale heartbeat, got nil") + } +} diff --git a/internal/service/testutil_test.go b/internal/service/testutil_test.go index f6254fb..f171f1b 100644 --- a/internal/service/testutil_test.go +++ b/internal/service/testutil_test.go @@ -637,6 +637,19 @@ func (m *mockTargetRepo) Create(ctx context.Context, target *domain.DeploymentTa return nil } +func (m *mockTargetRepo) CreateIfNotExists(ctx context.Context, target *domain.DeploymentTarget) (bool, error) { + m.mu.Lock() + defer m.mu.Unlock() + if m.CreateErr != nil { + return false, m.CreateErr + } + if _, exists := m.Targets[target.ID]; exists { + return false, nil + } + m.Targets[target.ID] = target + return true, nil +} + func (m *mockTargetRepo) Update(ctx context.Context, target *domain.DeploymentTarget) error { m.mu.Lock() defer m.mu.Unlock() diff --git a/migrations/000010_target_config.down.sql b/migrations/000010_target_config.down.sql new file mode 100644 index 0000000..ed20a5e --- /dev/null +++ b/migrations/000010_target_config.down.sql @@ -0,0 +1,5 @@ +-- Rollback migration 000010: Remove dynamic target configuration columns +ALTER TABLE deployment_targets DROP COLUMN IF EXISTS encrypted_config; +ALTER TABLE deployment_targets DROP COLUMN IF EXISTS last_tested_at; +ALTER TABLE deployment_targets DROP COLUMN IF EXISTS test_status; +ALTER TABLE deployment_targets DROP COLUMN IF EXISTS source; diff --git a/migrations/000010_target_config.up.sql b/migrations/000010_target_config.up.sql new file mode 100644 index 0000000..7c242e0 --- /dev/null +++ b/migrations/000010_target_config.up.sql @@ -0,0 +1,16 @@ +-- Migration 000010: Add dynamic target configuration columns +-- Supports M35: Dynamic Target Configuration (GUI) + +-- encrypted_config stores AES-GCM encrypted config blob containing all fields including secrets. +-- The existing `config` JSONB column is retained for backward compatibility and holds a redacted copy. +ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS encrypted_config BYTEA; + +-- last_tested_at tracks when the target connection was last tested (agent heartbeat check). +ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS last_tested_at TIMESTAMPTZ; + +-- test_status tracks the latest connection test result. +ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS test_status TEXT NOT NULL DEFAULT 'untested'; + +-- source tracks where the target configuration originated from. +-- 'database' = created via GUI, 'env' = seeded from environment variables. +ALTER TABLE deployment_targets ADD COLUMN IF NOT EXISTS source TEXT NOT NULL DEFAULT 'database'; diff --git a/web/src/api/client.test.ts b/web/src/api/client.test.ts index e0b05e9..2f4cd6e 100644 --- a/web/src/api/client.test.ts +++ b/web/src/api/client.test.ts @@ -36,6 +36,7 @@ import { getTargets, createTarget, deleteTarget, + testTargetConnection, getProfiles, getProfile, createProfile, @@ -425,6 +426,14 @@ describe('API Client', () => { expect(url).toBe('/api/v1/targets/t-nginx'); expect(init.method).toBe('DELETE'); }); + + it('testTargetConnection sends POST', async () => { + mockFetch.mockReturnValueOnce(mockJsonResponse({ status: 'success', message: 'Agent is online' })); + await testTargetConnection('t-nginx'); + const [url, init] = mockFetch.mock.calls[0]; + expect(url).toBe('/api/v1/targets/t-nginx/test'); + expect(init.method).toBe('POST'); + }); }); // ─── Approval ────────────────────────────────────── diff --git a/web/src/api/client.ts b/web/src/api/client.ts index 80dab73..aa049d9 100644 --- a/web/src/api/client.ts +++ b/web/src/api/client.ts @@ -232,6 +232,9 @@ export const updateTarget = (id: string, data: Partial) => export const deleteTarget = (id: string) => fetchJSON<{ message: string }>(`${BASE}/targets/${id}`, { method: 'DELETE' }); +export const testTargetConnection = (id: string) => + fetchJSON<{ status: string; message: string }>(`${BASE}/targets/${id}/test`, { method: 'POST' }); + // Profiles export const getProfiles = (params: Record = {}) => { const qs = new URLSearchParams({ page: '1', per_page: '50', ...params }).toString(); diff --git a/web/src/api/types.ts b/web/src/api/types.ts index 9e318db..5e19d04 100644 --- a/web/src/api/types.ts +++ b/web/src/api/types.ts @@ -156,10 +156,12 @@ export interface Target { id: string; name: string; type: string; - hostname: string; agent_id: string; config: Record; - status: string; + enabled: boolean; + last_tested_at?: string; + test_status?: string; + source?: string; created_at: string; updated_at?: string; } diff --git a/web/src/pages/CertificateDetailPage.tsx b/web/src/pages/CertificateDetailPage.tsx index d22933a..b6c3a46 100644 --- a/web/src/pages/CertificateDetailPage.tsx +++ b/web/src/pages/CertificateDetailPage.tsx @@ -660,7 +660,7 @@ export default function CertificateDetailPage() { > {targets?.data?.map(t => ( - + ))}
diff --git a/web/src/pages/TargetDetailPage.tsx b/web/src/pages/TargetDetailPage.tsx index 5799146..9e5b189 100644 --- a/web/src/pages/TargetDetailPage.tsx +++ b/web/src/pages/TargetDetailPage.tsx @@ -1,7 +1,7 @@ import { useState } from 'react'; import { useParams, Link } from 'react-router-dom'; import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'; -import { getTarget, getJobs, updateTarget } from '../api/client'; +import { getTarget, getJobs, updateTarget, testTargetConnection } from '../api/client'; import PageHeader from '../components/PageHeader'; import StatusBadge from '../components/StatusBadge'; import DataTable from '../components/DataTable'; @@ -18,6 +18,9 @@ const typeLabels: Record = { caddy: 'Caddy', f5_bigip: 'F5 BIG-IP', iis: 'IIS', + envoy: 'Envoy', + postfix: 'Postfix', + dovecot: 'Dovecot', }; function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { @@ -29,21 +32,59 @@ function InfoRow({ label, value }: { label: string; value: React.ReactNode }) { ); } +function TestStatusIndicator({ status, testedAt }: { status?: string; testedAt?: string }) { + if (!status || status === 'untested') { + return Not tested; + } + const styles: Record = { + success: 'bg-emerald-100 text-emerald-700', + failed: 'bg-red-100 text-red-700', + }; + const labels: Record = { + success: 'Connected', + failed: 'Failed', + }; + return ( + + + {labels[status] || status} + + {testedAt && {formatDateTime(testedAt)}} + + ); +} + +function SourceBadge({ source }: { source?: string }) { + if (!source || source === 'database') { + return GUI; + } + if (source === 'env') { + return Env Var; + } + return {source}; +} + export default function TargetDetailPage() { const { id } = useParams<{ id: string }>(); const queryClient = useQueryClient(); const [isEditing, setIsEditing] = useState(false); const [editName, setEditName] = useState(''); - const [editHostname, setEditHostname] = useState(''); const updateMutation = useMutation({ - mutationFn: (data: Partial<{ name: string; hostname: string }>) => updateTarget(id!, data), + mutationFn: (data: Partial<{ name: string }>) => updateTarget(id!, data), onSuccess: () => { queryClient.invalidateQueries({ queryKey: ['target', id] }); setIsEditing(false); }, }); + const testMutation = useMutation({ + mutationFn: () => testTargetConnection(id!), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ['target', id] }); + }, + }); + const { data: target, isLoading, error, refetch } = useQuery({ queryKey: ['target', id], queryFn: () => getTarget(id!), @@ -126,19 +167,39 @@ export default function TargetDetailPage() { title={target.name} subtitle={typeLabels[target.type] || target.type} action={ - +
+ + +
} /> + {/* Test connection result banner */} + {testMutation.isSuccess && ( +
+ Agent connection test passed — agent is online and responsive. +
+ )} + {testMutation.isError && ( +
+ Connection test failed: {(testMutation.error as Error).message} +
+ )} +
{/* Target info */} @@ -147,8 +208,9 @@ export default function TargetDetailPage() { {target.id}} /> - - } /> + } /> + } /> + } /> {target.agent_id && ( @@ -157,6 +219,7 @@ export default function TargetDetailPage() { } /> )} + {target.updated_at && }
{/* Config */} @@ -205,15 +268,11 @@ export default function TargetDetailPage() { {(updateMutation.error as Error).message}
)} -
{ e.preventDefault(); updateMutation.mutate({ name: editName, hostname: editHostname }); }} className="space-y-4"> + { e.preventDefault(); updateMutation.mutate({ name: editName }); }} className="space-y-4">
setEditName(e.target.value)} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" />
-
- - setEditHostname(e.target.value)} className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" /> -
-
-
- - setHostname(e.target.value)} - className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" - placeholder="web1.example.com" /> -
-
- - setAgentId(e.target.value)} - className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" - placeholder="agent-web1" /> -
+
+ + setAgentId(e.target.value)} + className="w-full bg-white border border-surface-border rounded px-3 py-2 text-sm text-ink focus:outline-none focus:border-brand-400" + placeholder="agent-web1" />
{fields.map(f => (
@@ -252,12 +242,6 @@ function CreateTargetWizard({ onClose, onSuccess }: { onClose: () => void; onSuc Type {typeLabels[targetType] || targetType}
- {hostname && ( -
- Hostname - {hostname} -
- )} {agentId && (
Agent @@ -322,20 +306,23 @@ export default function TargetsPage() { {typeLabels[t.type] || t.type} ), }, - { - key: 'hostname', - label: 'Hostname', - render: (t) => {t.hostname || '\u2014'}, - }, { key: 'agent', label: 'Agent', render: (t) => {t.agent_id || '\u2014'}, }, { - key: 'status', + key: 'enabled', label: 'Status', - render: (t) => , + render: (t) => , + }, + { + key: 'test_status', + label: 'Connection', + render: (t) => { + if (!t.test_status || t.test_status === 'untested') return ; + return ; + }, }, { key: 'created',