diff --git a/.gitignore b/.gitignore index 9fec30a..54be248 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ bin/ .vscode/ -__debug_* \ No newline at end of file +__debug_* +data/config.db \ No newline at end of file diff --git a/data/config.db b/data/config.db deleted file mode 100755 index 2112031..0000000 --- a/data/config.db +++ /dev/null Binary files differ diff --git a/internal/api.go b/internal/api.go index 30e0494..4849cae 100644 --- a/internal/api.go +++ b/internal/api.go @@ -149,5 +149,10 @@ // Renew Certificate Handler mux.HandleFunc("/renew-certificate", api.renewCertificateHandler) + // Certificate Rotation Handler + mux.HandleFunc("/enable-certificate-rotation", api.enableCertificateRotationHandler) + mux.HandleFunc("/disable-certificate-rotation", api.disableCertificateRotationHandler) + mux.HandleFunc("/list-rotating-certificates", api.listRotatingCertificatesHandler) + mux.HandleFunc("/storage-dump", api.storageDumpHandler) } diff --git a/internal/api/types.go b/internal/api/types.go index d3ac75c..daa893d 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -99,3 +99,31 @@ type CheckCertificateValidityRequest struct { CertificatePEM string `json:"certificate_pem"` } + +type EnableCertificateRotationRequest struct { + Domain string `json:"domain"` + SecretName string `json:"secret_name"` + // Optional: Duration before expiration to trigger rotation (e.g., "168h" for 7 days) + RenewBefore string `json:"renew_before,omitempty"` +} + +type DisableCertificateRotationRequest struct { + Domain string `json:"domain"` + SecretName string `json:"secret_name"` +} + +type ListRotatingCertificatesRequest struct { + // No fields needed for this request, but can be extended in the future if needed. +} + +type ListRotatingCertificatesResponse struct { + Certificates []RotatingCertificateInfo `json:"certificates"` +} + +type RotatingCertificateInfo struct { + Domain string `json:"domain"` + SecretName string `json:"secret_name"` + ExpiresAt string `json:"expires_at"` + RenewBefore string `json:"renew_before"` + RotationEnabled bool `json:"rotation_enabled"` +} diff --git a/internal/api_handlers.go b/internal/api_handlers.go index 7425331..58449ff 100644 --- a/internal/api_handlers.go +++ b/internal/api_handlers.go @@ -6,6 +6,7 @@ "fmt" "io" "net/http" + "time" internalapi "envoy-control-plane/internal/api" "envoy-control-plane/internal/pkg/cert/tool" @@ -694,3 +695,136 @@ return } } + +// rotateCertificatesHandler triggers the certificate rotation process for all certificates that are due for renewal. +func (api *API) enableCertificateRotationHandler(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + var req internalapi.EnableCertificateRotationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("invalid request with erro %v", err), http.StatusBadRequest) + return + } + if req.Domain == "" { + http.Error(w, "domain required", http.StatusBadRequest) + return + } + + if req.SecretName == "" { + http.Error(w, "secret_name required", http.StatusBadRequest) + return + } + + // *If the req.RenewBefore string is not parsable, return an error response indicating the invalid format.* + renewBefore := time.Duration(0) // Default to 0 if not provided. + if req.RenewBefore != "" { + var err error + if renewBefore, err = time.ParseDuration(req.RenewBefore); err != nil { + http.Error(w, fmt.Sprintf("invalid renew_before format: %v", err), http.StatusBadRequest) + return + } + } + certStorage, err := api.Manager.DB.LoadCertificate(ctx, req.Domain) + if err != nil { + http.Error(w, fmt.Sprintf("failed to load certificate for domain %s: %v", req.Domain, err), http.StatusInternalServerError) + return + } + + if certStorage == nil { + http.Error(w, fmt.Sprintf("no certificate found for domain %s", req.Domain), http.StatusNotFound) + return + } + if certStorage.SecretName != req.SecretName { + http.Error(w, fmt.Sprintf("secret name mismatch for domain %s: expected %s, got %s", req.Domain, certStorage.SecretName, req.SecretName), http.StatusBadRequest) + return + } + certStorage.RenewBefore = renewBefore + certStorage.EnableRotation = true + + if err := api.Manager.DB.UpdateCertRotationSettings(ctx, certStorage); err != nil { + http.Error(w, fmt.Sprintf("failed to update certificate rotation settings for domain %s: %v", req.Domain, err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "message": fmt.Sprintf("Certificate rotation enabled for domain %s with renew_before %s", req.Domain, req.RenewBefore), + }) +} + +func (api *API) disableCertificateRotationHandler(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + var req internalapi.DisableCertificateRotationRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + http.Error(w, fmt.Sprintf("invalid request with error %v", err), http.StatusBadRequest) + return + } + if req.Domain == "" { + http.Error(w, "domain required", http.StatusBadRequest) + return + } + + certStorage, err := api.Manager.DB.LoadCertificate(ctx, req.Domain) + if err != nil { + http.Error(w, fmt.Sprintf("failed to load certificate for domain %s: %v", req.Domain, err), http.StatusInternalServerError) + return + } + + if certStorage == nil { + http.Error(w, fmt.Sprintf("no certificate found for domain %s", req.Domain), http.StatusNotFound) + return + } + + certStorage.EnableRotation = false + certStorage.RenewBefore = 0 + + if err := api.Manager.DB.UpdateCertRotationSettings(ctx, certStorage); err != nil { + http.Error(w, fmt.Sprintf("failed to update certificate rotation settings for domain %s: %v", req.Domain, err), http.StatusInternalServerError) + return + } + + w.Header().Set("Content-Type", "application/json") + w.WriteHeader(http.StatusOK) + json.NewEncoder(w).Encode(map[string]string{ + "status": "ok", + "message": fmt.Sprintf("Certificate rotation disabled for domain %s", req.Domain), + }) +} + +func (api *API) listRotatingCertificatesHandler(w http.ResponseWriter, r *http.Request) { + ctx := context.Background() + if r.Method != http.MethodGet { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + w.Header().Set("Content-Type", "application/json") + + certs, err := api.Manager.DB.LoadAllCertificates(ctx) + if err != nil { + http.Error(w, fmt.Sprintf("failed to list rotating certificates: %v", err), http.StatusInternalServerError) + return + } + rotatingCerts := make([]*internalapi.RotatingCertificateInfo, 0) + for _, cert := range certs { + if cert.EnableRotation { + rotatingCerts = append(rotatingCerts, &internalapi.RotatingCertificateInfo{ + Domain: cert.Domain, + SecretName: cert.SecretName, + RenewBefore: cert.RenewBefore.String(), + }) + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(rotatingCerts) +} diff --git a/internal/app/app.go b/internal/app/app.go index dfab493..6cf9ab4 100644 --- a/internal/app/app.go +++ b/internal/app/app.go @@ -220,7 +220,6 @@ go func() { defer wg.Done() - log.Infof("Starting REST API server on port %d...", cfg.RESTPort) restAPIServer.RunRESTServer(ctx, mux, cfg.RESTPort, cfg.WebrootPath, cfg.EnableCertIssuance) log.Infof("REST API server shut down.") }() @@ -228,13 +227,14 @@ if cfg.EnableCertIssuance { // 7. Start Certificate Rotator wg.Add(1) - certRotator := rotation.NewCertRotator(cfg.CertCheckInterval, storage) + certRotator := rotation.NewCertRotator(cfg.CertCheckInterval, manager) go func() { defer wg.Done() log.Infof("Starting certificate rotator with check interval: %v", cfg.CertCheckInterval) if err := certRotator.RotateCertificates(ctx); err != nil { log.Errorf("Certificate rotator failed: %v", err) } + log.Infof("Certificate rotator shut down.") }() } diff --git a/internal/pkg/cert/rotation/rotator.go b/internal/pkg/cert/rotation/rotator.go index 100c530..84f1269 100644 --- a/internal/pkg/cert/rotation/rotator.go +++ b/internal/pkg/cert/rotation/rotator.go @@ -7,6 +7,7 @@ "envoy-control-plane/internal/pkg/cert" certapi "envoy-control-plane/internal/pkg/cert/api" "envoy-control-plane/internal/pkg/cert/tool" + "envoy-control-plane/internal/pkg/snapshot" "envoy-control-plane/internal/pkg/storage" "fmt" "time" @@ -19,17 +20,17 @@ // CertRotator manages the background rotation process for certificates stored in the system. type CertRotator struct { - checkInterval time.Duration - storage *storage.Storage - certParser tool.CertificateParser + checkInterval time.Duration + snapshotManager *snapshot.SnapshotManager + certParser tool.CertificateParser } // NewCertRotator creates a new CertRotator instance. // Note: The redundant NewCertRotor function was removed. -func NewCertRotator(interval time.Duration, s *storage.Storage) CertRotator { +func NewCertRotator(interval time.Duration, sm *snapshot.SnapshotManager) CertRotator { return CertRotator{ - checkInterval: interval, - storage: s, + checkInterval: interval, + snapshotManager: sm, // Assuming tool.CertificateParser implements an interface or is used directly as a concrete type certParser: tool.CertificateParser{}, } @@ -39,7 +40,7 @@ func (cr *CertRotator) loadCertificatesWithAutoRotationEnrolled(ctx context.Context) ([]*storage.CertStorage, error) { log := internallog.LogFromContext(ctx) - certs, err := cr.storage.LoadAllCertificates(ctx) + certs, err := cr.snapshotManager.DB.LoadAllCertificates(ctx) if err != nil { log.Errorf("Failed to load all certificates from storage: %v", err) return nil, fmt.Errorf("failed to load certificates: %w", err) @@ -112,12 +113,19 @@ } // 5. Save the new certificate (corrected to pass cr.storage directly) - if err := cert.SaveCertificateData(ctx, cr.storage, newCert, c.Email, c.IssuerType, c.SecretName); err != nil { + if err := cert.SaveCertificateData(ctx, cr.snapshotManager.DB, newCert, c.Email, c.IssuerType, c.SecretName); err != nil { // This is a critical failure, we have a new cert but failed to save it. log.Errorf("Failed to save the new certificate for domain %s into the database: %v", c.Domain, err) return } log.Infof("Successfully renewed and saved certificate for domain %s.", c.Domain) + + // 6. Update the SDS secret with the new certificate + if err := cr.snapshotManager.UpdateSDSSecretByName(ctx, c.SecretName, newCert); err != nil { + log.Errorf("Failed to update SDS secret for domain %s with the new certificate: %v", c.Domain, err) + return + } + log.Infof("SDS secret updated successfully for domain %s.", c.Domain) } else { log.Debugf("Certificate for domain %s is valid until %v. No renewal needed.", c.Domain, certExpiration.Format(time.RFC3339)) } @@ -155,7 +163,8 @@ } }() - // The background process has started successfully. + <-ctx.Done() // Wait for the context to be canceled before returning, allowing the goroutine to exit gracefully. + log.Infof("[Rotator] Rotation process stopped due to context cancellation.") return nil } diff --git a/internal/pkg/server/server.go b/internal/pkg/server/server.go index 8f52898..4ce97c4 100644 --- a/internal/pkg/server/server.go +++ b/internal/pkg/server/server.go @@ -5,12 +5,13 @@ "context" "fmt" "net/http" + "time" internallog "envoy-control-plane/internal/log" "envoy-control-plane/internal/pkg/api" ) -// RunRESTServer starts the REST API server with appropriate middleware. +// RunRESTServer starts the REST API server with graceful shutdown support. func RunRESTServer(ctx context.Context, mux *http.ServeMux, restPort uint, webrootPath string, enableCertIssuance bool) { log := internallog.LogFromContext(ctx) @@ -23,9 +24,25 @@ log.Infof("ACME challenge path configured: %s/ -> %s", api.ACME_CALLENGE_WEB_PATH, webrootPath) } - if err := http.ListenAndServe(restAddr, corsHandler); err != nil { - log.Fatalf("REST server error: %w", err) + srv := &http.Server{ + Addr: restAddr, + Handler: corsHandler, + } + + // Shutdown goroutine + go func() { + <-ctx.Done() + log.Infof("REST API server shutting down gracefully...") + shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second) + defer cancel() + if err := srv.Shutdown(shutdownCtx); err != nil { + log.Errorf("REST API server forced to shutdown: %v", err) + } + }() + + // Start server (blocking) + err := srv.ListenAndServe() + if err != nil && err != http.ErrServerClosed { + log.Errorf("REST server error: %v", err) } } - -// NOTE: The function to start the gRPC xDS server (internal.RunServer) remains in your existing 'internal' package. diff --git a/internal/pkg/storage/storage.go b/internal/pkg/storage/storage.go index a9db162..d54bc94 100644 --- a/internal/pkg/storage/storage.go +++ b/internal/pkg/storage/storage.go @@ -174,6 +174,51 @@ return cert, nil } +// UpdateCertRotationSettings updates the enable_rotation and renew_before fields +// for a specific certificate domain. +func (s *Storage) UpdateCertRotationSettings(ctx context.Context, cert *CertStorage) error { + renewBeforeNanos := cert.RenewBefore.Nanoseconds() + + // 1. Define the UPDATE query to only target the rotation-related fields and updated_at. + query := fmt.Sprintf(` + UPDATE certificates + SET + enable_rotation = %s, + renew_before = %s, + updated_at = %s + WHERE domain = %s`, + s.placeholder(1), // enable_rotation + s.placeholder(2), // renew_before + s.strategy.GetTimeNow(), // updated_at (value/function) + s.placeholder(3), // domain (for WHERE) + ) + + // 2. Prepare the arguments in the correct order for the placeholders. + args := []interface{}{ + cert.EnableRotation, // $1: true to enable, false to disable + renewBeforeNanos, // $2: The new renew_before duration + cert.Domain, // $3: The domain for the WHERE clause + } + + // 3. Execute the update. + res, err := s.db.ExecContext(ctx, query, args...) + if err != nil { + return fmt.Errorf("failed to update certificate rotation settings for %s: %w", cert.Domain, err) + } + + // 4. Check if a row was updated. + rowsAffected, err := res.RowsAffected() + if err != nil { + return fmt.Errorf("failed to check rows affected after updating cert rotation for %s: %w", cert.Domain, err) + } + + if rowsAffected == 0 { + return fmt.Errorf("certificate for domain %s not found to update rotation settings", cert.Domain) + } + + return nil +} + // LoadAllCertificates is unchanged from the original, as it didn't have driver logic. func (s *Storage) LoadAllCertificates(ctx context.Context) ([]*CertStorage, error) { query := `SELECT domain, email, cert_pem, key_pem, account_key, account_url, issuer_type, secret_name, enable_rotation, renew_before FROM certificates` diff --git a/main.go b/main.go index b6e2ff8..938ecaf 100644 --- a/main.go +++ b/main.go @@ -5,6 +5,8 @@ "context" "flag" "os" + "os/signal" + "syscall" "envoy-control-plane/internal/app" "envoy-control-plane/internal/config" @@ -15,18 +17,38 @@ func main() { // 1. Initialize and Parse Flags - config.InitFlags() // Initialize all flags from a central location + config.InitFlags() flag.Parse() defer klog.Flush() - // 2. Setup Logger and Context + // 2. Setup Logger and Root Context logger := internallog.NewDefaultLogger() ctx := internallog.WithLogger(context.Background(), logger) log := internallog.LogFromContext(ctx) - // 3. Run the Application + // 3. Create a cancellable context for graceful shutdown + ctx, cancel := context.WithCancel(ctx) + + // 4. Setup signal listener (SIGINT, SIGTERM) + sigChan := make(chan os.Signal, 2) + signal.Notify(sigChan, syscall.SIGINT, syscall.SIGTERM) + + go func() { + sig := <-sigChan + log.Infof("Received signal: %v. Initiating graceful shutdown...", sig) + cancel() // propagate shutdown to app.Run + + // If user presses Ctrl+C again → force exit immediately. + sig = <-sigChan + log.Errorf("Received second signal: %v. Forcing exit.", sig) + os.Exit(1) + }() + + // 5. Run main application if err := app.Run(ctx); err != nil { log.Errorf("Application failed: %v", err) os.Exit(1) } + + log.Infof("Process exited cleanly.") }