diff --git a/data/config.db b/data/config.db index ccb7c76..4538828 100644 --- a/data/config.db +++ b/data/config.db Binary files differ diff --git a/internal/api.go b/internal/api.go index ddde126..aefd901 100644 --- a/internal/api.go +++ b/internal/api.go @@ -145,4 +145,7 @@ mux.HandleFunc("/issue-certificate", api.issueCertificateHandler) mux.HandleFunc("/parse-certificate", api.parseCertificateHandler) mux.HandleFunc("/check-certificate-validity", api.checkCertificateValidityHandler) + + // Renew Certificate Handler + mux.HandleFunc("/renew-certificate", api.renewCertificateHandler) } diff --git a/internal/api/types.go b/internal/api/types.go index a424c6f..435b8b8 100644 --- a/internal/api/types.go +++ b/internal/api/types.go @@ -75,9 +75,15 @@ } type RequestDomainCertificate struct { - Domain string `json:"domain"` - Email string `json:"email"` - Issuer string `json:"issuer" default:"letsencrypt"` + Domain string `json:"domain"` + Email string `json:"email"` + Issuer string `json:"issuer" default:"letsencrypt"` + SecretName string `json:"secret_name"` +} + +type RenewCertificateRequest struct { + Domain string `json:"domain"` + SecretName string `json:"secret_name"` } type ParseCertificateRequest struct { diff --git a/internal/api_handlers.go b/internal/api_handlers.go index 38ddc02..263ef90 100644 --- a/internal/api_handlers.go +++ b/internal/api_handlers.go @@ -7,7 +7,6 @@ "net/http" internalapi "envoy-control-plane/internal/api" - "envoy-control-plane/internal/pkg/cert" "envoy-control-plane/internal/pkg/cert/tool" "envoy-control-plane/internal/pkg/snapshot" "envoy-control-plane/internal/pkg/storage" @@ -17,6 +16,8 @@ resourcev3 "github.com/envoyproxy/go-control-plane/pkg/resource/v3" "google.golang.org/protobuf/encoding/protojson" "google.golang.org/protobuf/reflect/protoreflect" + + internalcert "envoy-control-plane/internal/pkg/cert" ) // ---------------- Persistence Handlers ---------------- @@ -489,7 +490,7 @@ return } - issuer, err := cert.NewCertIssuer(req.Issuer) + issuer, err := internalcert.NewCertIssuer(req.Issuer) if err != nil { http.Error(w, "failed to create certificate issuer", http.StatusInternalServerError) return @@ -499,12 +500,69 @@ http.Error(w, fmt.Sprintf("failed to issue certificate: %v", err), http.StatusInternalServerError) return } + // Persist certificate data if SecretName is provided, this means the user is going to use it for envoy Secret resource. + if err := internalcert.SaveCertificateData(context.TODO(), api.Manager.DB, cert, req.Email, req.Issuer, req.SecretName); err != nil { + http.Error(w, fmt.Sprintf("failed to persist certificate data: %v", err), http.StatusInternalServerError) + return + } + if req.SecretName != "" { + if err := api.Manager.UpdateSDSSecretByName(r.Context(), req.SecretName, cert); err != nil { + http.Error(w, fmt.Sprintf("failed to update SDS Secret in cache: %v", err), http.StatusInternalServerError) + return + } + } w.Header().Set("Content-Type", "application/json") json.NewEncoder(w).Encode(cert) w.WriteHeader(http.StatusOK) } +func (api *API) renewCertificateHandler(w http.ResponseWriter, r *http.Request) { + if r.Method != http.MethodPost { + http.Error(w, "method not allowed", http.StatusMethodNotAllowed) + return + } + if !api.enableCertIssuance { + http.Error(w, "certificate issuance is not enabled", http.StatusForbidden) + return + } + w.Header().Set("Content-Type", "application/json") + var req internalapi.RenewCertificateRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil || req.Domain == "" { + http.Error(w, "domain required", http.StatusBadRequest) + return + } + oldCert, email, issuertype, err := internalcert.LoadCertificateData(context.Background(), api.Manager.DB, req.Domain) + if err != nil { + http.Error(w, fmt.Sprintf("failed to load existing certificate data: %v", err), http.StatusInternalServerError) + return + } + + issuer, err := internalcert.NewCertIssuer(issuertype) + if err != nil { + http.Error(w, "failed to create certificate issuer", http.StatusInternalServerError) + return + } + + newCert, err := issuer.RenewCertificate(oldCert, api.acmeWebRootPath, email) + if err != nil { + http.Error(w, fmt.Sprintf("failed to renew certificate: %v", err), http.StatusInternalServerError) + return + } + if err := internalcert.SaveCertificateData(context.TODO(), api.Manager.DB, newCert, email, issuertype, req.SecretName); err != nil { + http.Error(w, fmt.Sprintf("failed to persist renewed certificate data: %v", err), http.StatusInternalServerError) + return + } + if req.SecretName != "" { + if err := api.Manager.UpdateSDSSecretByName(r.Context(), req.SecretName, newCert); err != nil { + http.Error(w, fmt.Sprintf("failed to update SDS Secret in cache: %v", err), http.StatusInternalServerError) + return + } + } + w.Header().Set("Content-Type", "application/json") + json.NewEncoder(w).Encode(newCert) +} + func (api *API) parseCertificateHandler(w http.ResponseWriter, r *http.Request) { if r.Method != http.MethodPost { http.Error(w, "method not allowed", http.StatusMethodNotAllowed) diff --git a/internal/pkg/cert/api/type.go b/internal/pkg/cert/api/type.go index 1dcb5ef..33c8431 100644 --- a/internal/pkg/cert/api/type.go +++ b/internal/pkg/cert/api/type.go @@ -7,6 +7,7 @@ KeyPEM []byte FullChain []byte // Cert + Issuer Chain AccountKey []byte // Private key for the ACME account + AccountURL string // URL of the ACME account } // CertIssuer defines the contract for any service that issues TLS certificates. @@ -17,6 +18,8 @@ // It should use the http-01 challenge method for validation. IssueCertificate(domain, webrootPath, email string) (*Certificate, error) + RenewCertificate(oldCert *Certificate, webrootPath string, email string) (*Certificate, error) + // GetName returns the name of the issuer (e.g., "LetsEncrypt", "ZeroSSL"). GetName() string } diff --git a/internal/pkg/cert/factory.go b/internal/pkg/cert/factory.go index 93d073c..072388e 100644 --- a/internal/pkg/cert/factory.go +++ b/internal/pkg/cert/factory.go @@ -2,6 +2,8 @@ import ( "fmt" + "os" + "strconv" "envoy-control-plane/internal/pkg/cert/api" "envoy-control-plane/internal/pkg/cert/letsencrypt" @@ -13,8 +15,29 @@ func NewCertIssuer(issuerType string) (api.CertIssuer, error) { switch issuerType { case "letsencrypt": - // Return the concrete *letsencrypt.LetsEncryptIssuer, which satisfies the cert.CertIssuer interface. - return &letsencrypt.LetsEncryptIssuer{}, nil + // 1. Check the environment variable for staging mode. + // We use Getenv, which returns an empty string if the variable is not set. + stagingEnv := os.Getenv("LETSENCRYPT_STAGING") + + // 2. Default to production (false). + useStaging := false + + // 3. Try to parse the environment variable value as a boolean. + // Common values like "1", "t", "T", "true", "TRUE" are interpreted as true. + if stagingEnv != "" { + parsedBool, err := strconv.ParseBool(stagingEnv) + if err == nil { + useStaging = parsedBool + } else { + // Optional: Log a warning if the value is set but invalid (e.g., LETSENCRYPT_STAGING=maybe) + fmt.Printf("Warning: Invalid value for LETSENCRYPT_STAGING ('%s'). Defaulting to production.\n", stagingEnv) + } + } + + // 4. Return the concrete *letsencrypt.LetsEncryptIssuer with the determined setting. + return &letsencrypt.LetsEncryptIssuer{ + UseStaging: useStaging, + }, nil // Add new certificate authority implementations here as new cases // case "zerossl": diff --git a/internal/pkg/cert/letsencrypt/issuer.go b/internal/pkg/cert/letsencrypt/issuer.go index 67f2c00..394f6f6 100644 --- a/internal/pkg/cert/letsencrypt/issuer.go +++ b/internal/pkg/cert/letsencrypt/issuer.go @@ -6,6 +6,7 @@ "crypto/elliptic" "crypto/rand" "fmt" + "math/big" "github.com/go-acme/lego/v4/certcrypto" "github.com/go-acme/lego/v4/certificate" @@ -13,8 +14,7 @@ "github.com/go-acme/lego/v4/providers/http/webroot" "github.com/go-acme/lego/v4/registration" - api "envoy-control-plane/internal/pkg/cert/api" - cert "envoy-control-plane/internal/pkg/cert/api" + internalcertapi "envoy-control-plane/internal/pkg/cert/api" ) // LEOptions is a simple struct to satisfy the necessary interface for the lego ACME client. @@ -38,15 +38,21 @@ // LetsEncryptIssuer implements the CertIssuer interface for Let's Encrypt. type LetsEncryptIssuer struct { + UseStaging bool } // GetName returns the name of the issuer. func (l *LetsEncryptIssuer) GetName() string { + if l.UseStaging { + return "LetsEncrypt (Staging)" + } return "LetsEncrypt (Production)" } +// ------------------------------------------------------------------------------------------------ + // IssueCertificate implements the core certificate issuance logic using go-acme/lego. -func (l *LetsEncryptIssuer) IssueCertificate(domain, webrootPath, email string) (*cert.Certificate, error) { +func (l *LetsEncryptIssuer) IssueCertificate(domain, webrootPath, email string) (*internalcertapi.Certificate, error) { // 1. Setup ACME Account Key and User privateKey, err := ecdsa.GenerateKey(elliptic.P256(), rand.Reader) if err != nil { @@ -58,28 +64,11 @@ key: privateKey, } - // 2. Configure the ACME client - config := lego.NewConfig(acmeUser) - config.CADirURL = lego.LEDirectoryProduction - - // Set the key type (this should reflect the key used in the private key) - config.Certificate.KeyType = certcrypto.EC256 - - client, err := lego.NewClient(config) + client, err := createClient(acmeUser, webrootPath, l.UseStaging) if err != nil { return nil, fmt.Errorf("failed to create ACME client: %w", err) } - // 3. Set up the HTTP-01 challenge provider (Webroot) - // Use the correct method to set up the Webroot provider for HTTP-01 challenge - httpProvider, err := webroot.NewHTTPProvider(webrootPath) - if err != nil { - return nil, fmt.Errorf("failed to create HTTP-01 provider: %w", err) - } - if err := client.Challenge.SetHTTP01Provider(httpProvider); err != nil { - return nil, fmt.Errorf("failed to set HTTP-01 provider: %w", err) - } - // 4. Register the ACME account reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true}) if err != nil { @@ -99,12 +88,60 @@ } // 6. Map the results to the generic Certificate struct - // Make sure to handle the private key properly here. - return &api.Certificate{ + return &internalcertapi.Certificate{ Domain: domain, CertPEM: certResources.Certificate, KeyPEM: certResources.PrivateKey, - FullChain: certResources.Certificate, // lego's Certificate field includes the chain - AccountKey: privateKey.D.Bytes(), // This is safer, as it assumes the type is ecdsa.PrivateKey + FullChain: certResources.Certificate, + AccountKey: privateKey.D.Bytes(), + // FIX: Persist the ACME Account URL (KID) for future renewal JWS signing. + // ASSUMPTION: internalcertapi.Certificate has an 'AccountURL' field. + AccountURL: reg.URI, }, nil } + +// ------------------------------------------------------------------------------------------------ + +// createClient is a helper function to avoid code duplication in Issue and Renew. +func createClient(acmeUser *LEOptions, webrootPath string, useStaging bool) (*lego.Client, error) { + config := lego.NewConfig(acmeUser) + if useStaging { + config.CADirURL = lego.LEDirectoryStaging + } else { + config.CADirURL = lego.LEDirectoryProduction + } + config.Certificate.KeyType = certcrypto.EC256 + + client, err := lego.NewClient(config) + if err != nil { + return nil, fmt.Errorf("failed to create ACME client: %w", err) + } + + httpProvider, err := webroot.NewHTTPProvider(webrootPath) + if err != nil { + return nil, fmt.Errorf("failed to create HTTP-01 provider: %w", err) + } + if err := client.Challenge.SetHTTP01Provider(httpProvider); err != nil { + return nil, fmt.Errorf("failed to set HTTP-01 provider: %w", err) + } + + return client, nil +} + +// ------------------------------------------------------------------------------------------------ + +// loadACMEKeyFromDValue reconstructs the ecdsa.PrivateKey from its raw D-value bytes. +func loadACMEKeyFromDValue(dValue []byte) (*ecdsa.PrivateKey, error) { + if len(dValue) != 32 { + return nil, fmt.Errorf("invalid D-value length: expected 32 bytes for P256, got %d", len(dValue)) + } + + key := new(ecdsa.PrivateKey) + key.PublicKey.Curve = elliptic.P256() + key.D = new(big.Int).SetBytes(dValue) + + // Calculate public key components X and Y from D + key.PublicKey.X, key.PublicKey.Y = key.PublicKey.Curve.ScalarBaseMult(key.D.Bytes()) + + return key, nil +} diff --git a/internal/pkg/cert/letsencrypt/renew.sh b/internal/pkg/cert/letsencrypt/renew.sh new file mode 100644 index 0000000..1fda394 --- /dev/null +++ b/internal/pkg/cert/letsencrypt/renew.sh @@ -0,0 +1,17 @@ +curl -X POST \ + http://localhost:8080/issue-certificate \ + -H 'Content-Type: application/json' \ + -d '{ + "domain": "test.jerxie.com", + "email": "axieyangb@gmail.com", + "secret_name": "test-jerxie-tls", + "issuer": "letsencrypt" + }' + + +curl -X POST 'http://localhost:8080/renew-certificate' \ + -H 'Content-Type: application/json' \ + -d '{ + "domain": "test.jerxie.com", + "secret_name": "test-jerxie-tls" +}' \ No newline at end of file diff --git a/internal/pkg/cert/letsencrypt/renewer.go b/internal/pkg/cert/letsencrypt/renewer.go new file mode 100644 index 0000000..483cbd0 --- /dev/null +++ b/internal/pkg/cert/letsencrypt/renewer.go @@ -0,0 +1,63 @@ +package letsencrypt + +import ( + internalcertapi "envoy-control-plane/internal/pkg/cert/api" + "fmt" + + "github.com/go-acme/lego/v4/certificate" + "github.com/go-acme/lego/v4/registration" +) + +// ------------------------------------------------------------------------------------------------ + +// RenewCertificate renews an existing certificate using go-acme/lego. +func (l *LetsEncryptIssuer) RenewCertificate(oldCert *internalcertapi.Certificate, webrootPath string, email string) (*internalcertapi.Certificate, error) { + // FIX 1: Load the ACME Account Private Key from the D-value stored in AccountKey. + acmePrivateKey, err := loadACMEKeyFromDValue(oldCert.AccountKey) + if err != nil { + return nil, fmt.Errorf("failed to load ACME account key: %w", err) + } + + // 2. Setup ACME User (LEOptions) with the loaded account key + acmeUser := &LEOptions{ + Email: email, + key: acmePrivateKey, + // FIX 2: Provide the Registration URI (KID) to the client for JWS signing. + // ASSUMPTION: The stored oldCert.AccountURL contains the ACME account URI (KID). + Registration: ®istration.Resource{ + URI: oldCert.AccountURL, + }, + } + + // 3. Configure and create the ACME client + client, err := createClient(acmeUser, webrootPath, l.UseStaging) + if err != nil { + return nil, err + } + + // 4. Reconstruct the certificate.Resource for renewal + certResource := certificate.Resource{ + Domain: oldCert.Domain, + Certificate: oldCert.CertPEM, + // The PrivateKey here is the DOMAIN's old private key. + PrivateKey: oldCert.KeyPEM, + } + + // 5. Renew the certificate + newCertResources, err := client.Certificate.Renew(certResource, false, false, "") + if err != nil { + return nil, fmt.Errorf("failed to renew certificate: %w", err) + } + + // 6. Map the results + return &internalcertapi.Certificate{ + Domain: oldCert.Domain, + CertPEM: newCertResources.Certificate, + // The renewed key (newCertResources.PrivateKey) is the domain's NEW private key. + KeyPEM: newCertResources.PrivateKey, + FullChain: newCertResources.Certificate, + // The ACME account key and URL remain the same. + AccountKey: oldCert.AccountKey, + AccountURL: oldCert.AccountURL, + }, nil +} diff --git a/internal/pkg/cert/persist.go b/internal/pkg/cert/persist.go new file mode 100644 index 0000000..407fed1 --- /dev/null +++ b/internal/pkg/cert/persist.go @@ -0,0 +1,59 @@ +package cert + +import ( + "context" + "envoy-control-plane/internal/pkg/cert/api" + "envoy-control-plane/internal/pkg/storage" + "errors" + "fmt" +) + +// SaveCertificateData persists the certificate data needed for renewal to the database. +// It uses the underlying CertStorer dependency. +func SaveCertificateData(ctx context.Context, store *storage.Storage, cert *api.Certificate, email string, issuertype string, secretname string) error { + if store == nil { + return errors.New("certificate store dependency is nil, cannot save data") + } + + certStorage := &storage.CertStorage{ + Domain: cert.Domain, + Email: email, // Store email with the cert + CertPEM: cert.CertPEM, + KeyPEM: cert.KeyPEM, + AccountKey: cert.AccountKey, + AccountURL: cert.AccountURL, + IssuerType: issuertype, + SecretName: secretname, + } + + if err := store.SaveCertificate(ctx, certStorage); err != nil { + return fmt.Errorf("failed to save certificate data for %s: %w", cert.Domain, err) + } + return nil +} + +// LoadCertificateData retrieves the certificate data needed for renewal from the database. +// It uses the underlying CertStorer dependency. +func LoadCertificateData(ctx context.Context, store *storage.Storage, domain string) (*api.Certificate, string, string, error) { + if store == nil { + return nil, "", "", errors.New("certificate store dependency is nil, cannot load data") + } + + certStorage, err := store.LoadCertificate(ctx, domain) + if err != nil { + return nil, "", "", fmt.Errorf("failed to load certificate data for %s: %w", domain, err) + } + if certStorage == nil { + return nil, "", "", fmt.Errorf("no certificate data found for domain %s", domain) + } + + cert := &api.Certificate{ + Domain: certStorage.Domain, + CertPEM: certStorage.CertPEM, + KeyPEM: certStorage.KeyPEM, + AccountKey: certStorage.AccountKey, + AccountURL: certStorage.AccountURL, + } + + return cert, certStorage.Email, certStorage.IssuerType, nil +} diff --git a/internal/pkg/snapshot/resource_crud.go b/internal/pkg/snapshot/resource_crud.go index 37896c3..fa879ca 100644 --- a/internal/pkg/snapshot/resource_crud.go +++ b/internal/pkg/snapshot/resource_crud.go @@ -6,12 +6,15 @@ "sort" "time" + corev3 "github.com/envoyproxy/go-control-plane/envoy/config/core/v3" listenerv3 "github.com/envoyproxy/go-control-plane/envoy/config/listener/v3" + secretv3 "github.com/envoyproxy/go-control-plane/envoy/extensions/transport_sockets/tls/v3" "github.com/envoyproxy/go-control-plane/pkg/cache/types" cachev3 "github.com/envoyproxy/go-control-plane/pkg/cache/v3" resourcev3 "github.com/envoyproxy/go-control-plane/pkg/resource/v3" internallog "envoy-control-plane/internal/log" + internalcertapi "envoy-control-plane/internal/pkg/cert/api" "envoy-control-plane/internal/pkg/storage" ) @@ -453,6 +456,196 @@ return resources } +// UpdateSDSSecretByDomain updates an existing Secret resource in the cache with a refreshed +// certificate and private key. +func (sm *SnapshotManager) UpdateSDSSecretByDomain(ctx context.Context, domain string, newCert *storage.CertStorage) error { + log := internallog.LogFromContext(ctx) + + // 1. Determine the Secret name. We assume a convention: "tls-secret-" + secretName := fmt.Sprintf("tls-secret-%s", domain) + secretType := resourcev3.SecretType + + // 2. Get the current Secret from the cache + resource, err := sm.GetResourceFromCache(secretName, secretType) + if err != nil { + return fmt.Errorf("failed to get Secret '%s' from cache: %w", secretName, err) + } + + secret, ok := resource.(*secretv3.Secret) + if !ok { + return fmt.Errorf("resource '%s' is not a Secret type", secretName) + } + + // 3. Validate and update the Secret data + if secret.GetType() == nil || secret.GetTlsCertificate() == nil { + return fmt.Errorf("secret '%s' is not a TlsCertificate secret or is malformed", secretName) + } + + // Update the certificate chain and private key fields + tlsCert := secret.GetTlsCertificate() + tlsCert.CertificateChain = &corev3.DataSource{ + Specifier: &corev3.DataSource_InlineBytes{ + InlineBytes: newCert.CertPEM, + }, + } + tlsCert.PrivateKey = &corev3.DataSource{ + Specifier: &corev3.DataSource_InlineBytes{ + InlineBytes: newCert.KeyPEM, + }, + } + + log.Debugf("Updated TlsCertificate data for secret '%s' (Domain: %s)", secretName, domain) + + // 4. Get current snapshot to extract all resources for the new snapshot + snap, err := sm.Cache.GetSnapshot(sm.NodeID) + if err != nil { + return fmt.Errorf("failed to get snapshot for modification: %w", err) + } + + // Get all current resources + resources := sm.getAllResourcesFromSnapshot(snap) + + // Replace the old secret with the modified one in the resource list + secretList, ok := resources[secretType] + if !ok { + return fmt.Errorf("secret resource type not present in snapshot with name %s", secretName) + } + + foundAndReplaced := false + for i, res := range secretList { + if namer, ok := res.(interface{ GetName() string }); ok && namer.GetName() == secretName { + // The `secret` variable already holds the modified secret + secretList[i] = secret + foundAndReplaced = true + break + } + } + + if !foundAndReplaced { + // Should not happen if GetResourceFromCache succeeded. + return fmt.Errorf("failed to locate Secret '%s' in current resource list for replacement", secretName) + } + + // 5. Create and set the new snapshot + version := fmt.Sprintf("secret-update-%s-%d", secretName, time.Now().UnixNano()) + newSnap, err := cachev3.NewSnapshot(version, resources) + if err != nil { + return fmt.Errorf("failed to create new snapshot: %w", err) + } + + if err := sm.Cache.SetSnapshot(ctx, sm.NodeID, newSnap); err != nil { + return fmt.Errorf("failed to set new snapshot: %w", err) + } + + // 6. Flush the updated secret to the persistent database. + // We pass DeleteNone as strategy because we only updated one resource; we don't want to affect others. + // NOTE: We assume sm.DB.SaveSecret is called elsewhere, or we call it here directly if needed for persistence. + // For now, we only flush the *snapshot* metadata if sm.FlushCacheToDB does that. + // If `FlushCacheToDB` serializes the entire snapshot back to the DB, it implicitly saves the updated secret. + sm.FlushCacheToDB(ctx, storage.DeleteLogical) + + log.Infof("Successfully updated Secret '%s' in cache with refreshed certificate for domain '%s'.", secretName, domain) + + return nil +} + +// UpdateSDSSecretByName updates an existing Secret resource in the cache with a refreshed +// certificate and private key, using the Secret's exact name. This is useful when the +// Secret name convention is not based directly on the domain. +func (sm *SnapshotManager) UpdateSDSSecretByName(ctx context.Context, secretName string, internalcert *internalcertapi.Certificate) error { + log := internallog.LogFromContext(ctx) + + secretType := resourcev3.SecretType + + // 1. Get the current Secret from the cache using the provided name + resource, err := sm.GetResourceFromCache(secretName, secretType) + if err != nil { + return fmt.Errorf("failed to get Secret '%s' from cache: %w", secretName, err) + } + + secret, ok := resource.(*secretv3.Secret) + if !ok { + return fmt.Errorf("resource '%s' is not a Secret type", secretName) + } + + // 2. Validate and update the Secret data + if secret.GetType() == nil || secret.GetTlsCertificate() == nil { + return fmt.Errorf("secret '%s' is not a TlsCertificate secret or is malformed", secretName) + } + + // Update the certificate chain and private key fields + tlsCert := secret.GetTlsCertificate() + + // --- CertificateChain Update: Always use InlineString --- + // The certificate content (CertPEM) is stored as an InlineString. + tlsCert.CertificateChain = &corev3.DataSource{ + Specifier: &corev3.DataSource_InlineString{ + InlineString: string(internalcert.CertPEM), + }, + } + // --- End CertificateChain Update --- + + // --- PrivateKey Update: Always use InlineString --- + // The private key content (KeyPEM) is stored as an InlineString. + tlsCert.PrivateKey = &corev3.DataSource{ + Specifier: &corev3.DataSource_InlineString{ + InlineString: string(internalcert.KeyPEM), + }, + } + // --- End PrivateKey Update --- + + log.Debugf("Updated TlsCertificate data for secret '%s' (Domain: %s)", secretName, internalcert.Domain) + + // 3. Get current snapshot to extract all resources for the new snapshot + snap, err := sm.Cache.GetSnapshot(sm.NodeID) + if err != nil { + return fmt.Errorf("failed to get snapshot for modification: %w", err) + } + + // Get all current resources + resources := sm.getAllResourcesFromSnapshot(snap) + + // Replace the old secret with the modified one in the resource list + secretList, ok := resources[secretType] + if !ok { + return fmt.Errorf("secret resource type not present in snapshot") + } + + foundAndReplaced := false + for i, res := range secretList { + // Assuming the resource implements a GetName() method + if namer, ok := res.(interface{ GetName() string }); ok && namer.GetName() == secretName { + // The `secret` variable already holds the modified secret + secretList[i] = secret + foundAndReplaced = true + break + } + } + + if !foundAndReplaced { + // Should not happen if GetResourceFromCache succeeded. + return fmt.Errorf("failed to locate Secret '%s' in current resource list for replacement", secretName) + } + + // 4. Create and set the new snapshot + version := fmt.Sprintf("secret-update-byname-%s-%d", secretName, time.Now().UnixNano()) + newSnap, err := cachev3.NewSnapshot(version, resources) + if err != nil { + return fmt.Errorf("failed to create new snapshot: %w", err) + } + + if err := sm.Cache.SetSnapshot(ctx, sm.NodeID, newSnap); err != nil { + return fmt.Errorf("failed to set new snapshot: %w", err) + } + + // 5. Flush the updated snapshot metadata to persistent storage. + sm.FlushCacheToDB(ctx, storage.DeleteLogical) + + log.Infof("Successfully updated Secret '%s' in cache with refreshed certificate.", secretName) + + return nil +} + // mapToSlice converts a map of named resources to a slice of resources. func mapToSlice(m map[string]types.Resource) []types.Resource { out := make([]types.Resource, 0, len(m)) diff --git a/internal/pkg/storage/storage.go b/internal/pkg/storage/storage.go index 13a71fc..72cfc01 100644 --- a/internal/pkg/storage/storage.go +++ b/internal/pkg/storage/storage.go @@ -4,6 +4,7 @@ "context" "database/sql" "encoding/json" + "errors" "fmt" "strings" @@ -45,6 +46,19 @@ return "?" } +// CertStorage represents the persistent data needed for certificate renewal. +// This mirrors the data that was previously stored in the internalcertapi.Certificate. +type CertStorage struct { + Domain string // The certificate domain (used as the primary key) + Email string // The ACME account email + CertPEM []byte // The current certificate (public part + chain) + KeyPEM []byte // The domain's private key + AccountKey []byte // The ACME Account private key D-value (for signing) + AccountURL string // The ACME Account URI (KID) + IssuerType string // The type of issuer (e.g., "LetsEncrypt"). Default to "" + SecretName string // The name of the SDS Secret this certificate is associated with. Empty if unlinked/manual. +} + // InitSchema ensures required tables exist func (s *Storage) InitSchema(ctx context.Context) error { var schema string @@ -72,6 +86,18 @@ data JSONB NOT NULL, enabled BOOLEAN DEFAULT true, updated_at TIMESTAMP DEFAULT now() + ); + -- 👇 UPDATED CERTIFICATE TABLE FOR ACME RENEWAL + CREATE TABLE IF NOT EXISTS certificates ( + domain TEXT PRIMARY KEY, + email TEXT NOT NULL, + cert_pem BYTEA NOT NULL, + key_pem BYTEA NOT NULL, + account_key BYTEA NOT NULL, + account_url TEXT NOT NULL, + issuer_type TEXT DEFAULT '', + secret_name TEXT DEFAULT '', -- New field + updated_at TIMESTAMP DEFAULT now() );` default: // SQLite schema = ` @@ -96,6 +122,18 @@ data TEXT NOT NULL, enabled BOOLEAN DEFAULT 1, updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP + ); + -- 👇 UPDATED CERTIFICATE TABLE FOR ACME RENEWAL + CREATE TABLE IF NOT EXISTS certificates ( + domain TEXT PRIMARY KEY, + email TEXT NOT NULL, + cert_pem BLOB NOT NULL, + key_pem BLOB NOT NULL, + account_key BLOB NOT NULL, + account_url TEXT NOT NULL, + issuer_type TEXT DEFAULT '', + secret_name TEXT DEFAULT '', -- New field + updated_at TIMESTAMP DEFAULT CURRENT_TIMESTAMP );` } _, err := s.db.ExecContext(ctx, schema) @@ -103,7 +141,108 @@ } // ----------------------------------------------------------------------------- -// SAVE METHODS (UPSERT) +// NEW CERTIFICATE METHODS (UPSERT & LOAD) +// ----------------------------------------------------------------------------- + +// SaveCertificate inserts or updates a certificate resource +func (s *Storage) SaveCertificate(ctx context.Context, cert *CertStorage) error { + var query string + switch s.driver { + case "postgres": + query = fmt.Sprintf(` + INSERT INTO certificates (domain, email, cert_pem, key_pem, account_key, account_url, issuer_type, secret_name, updated_at) + VALUES (%s, %s, %s, %s, %s, %s, %s, %s, now()) + ON CONFLICT (domain) DO UPDATE SET + email = EXCLUDED.email, + cert_pem = EXCLUDED.cert_pem, + key_pem = EXCLUDED.key_pem, + account_key = EXCLUDED.account_key, + account_url = EXCLUDED.account_url, + issuer_type = EXCLUDED.issuer_type, + secret_name = EXCLUDED.secret_name, -- Updated field + updated_at = now()`, + s.placeholder(1), s.placeholder(2), s.placeholder(3), s.placeholder(4), s.placeholder(5), s.placeholder(6), s.placeholder(7), s.placeholder(8)) + default: // SQLite + query = ` + INSERT INTO certificates (domain, email, cert_pem, key_pem, account_key, account_url, issuer_type, secret_name, updated_at) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, CURRENT_TIMESTAMP) + ON CONFLICT(domain) DO UPDATE SET + email = excluded.email, + cert_pem = excluded.cert_pem, + key_pem = excluded.key_pem, + account_key = excluded.account_key, + account_url = excluded.account_url, + issuer_type = excluded.issuer_type, + secret_name = excluded.secret_name, -- Updated field + updated_at = CURRENT_TIMESTAMP` + } + + _, err := s.db.ExecContext(ctx, query, + cert.Domain, + cert.Email, + cert.CertPEM, + cert.KeyPEM, + cert.AccountKey, + cert.AccountURL, + cert.IssuerType, + cert.SecretName, // New field + ) + return err +} + +// LoadCertificate retrieves a certificate resource by domain +func (s *Storage) LoadCertificate(ctx context.Context, domain string) (*CertStorage, error) { + // Updated SELECT statement to include secret_name + query := `SELECT email, cert_pem, key_pem, account_key, account_url, issuer_type, secret_name FROM certificates WHERE domain = $1` + if s.driver != "postgres" { + query = `SELECT email, cert_pem, key_pem, account_key, account_url, issuer_type, secret_name FROM certificates WHERE domain = ?` + } + + row := s.db.QueryRowContext(ctx, query, domain) + + cert := &CertStorage{Domain: domain} + // Updated Scan call to include &cert.SecretName + err := row.Scan(&cert.Email, &cert.CertPEM, &cert.KeyPEM, &cert.AccountKey, &cert.AccountURL, &cert.IssuerType, &cert.SecretName) + + if errors.Is(err, sql.ErrNoRows) { + return nil, fmt.Errorf("certificate for domain %s not found", domain) + } + if err != nil { + return nil, fmt.Errorf("failed to scan certificate data for %s: %w", domain, err) + } + + return cert, nil +} + +// LoadAllCertificates retrieves all stored certificate resources +func (s *Storage) LoadAllCertificates(ctx context.Context) ([]*CertStorage, error) { + // Updated SELECT statement to include secret_name + query := `SELECT domain, email, cert_pem, key_pem, account_key, account_url, issuer_type, secret_name FROM certificates` + + rows, err := s.db.QueryContext(ctx, query) + if err != nil { + return nil, err + } + defer rows.Close() + + var certs []*CertStorage + for rows.Next() { + cert := &CertStorage{} + // Updated Scan call to include &cert.SecretName + if err := rows.Scan(&cert.Domain, &cert.Email, &cert.CertPEM, &cert.KeyPEM, &cert.AccountKey, &cert.AccountURL, &cert.IssuerType, &cert.SecretName); err != nil { + return nil, fmt.Errorf("failed to scan all certificate data: %w", err) + } + certs = append(certs, cert) + } + if err := rows.Err(); err != nil { + return nil, err + } + + return certs, nil +} + +// ----------------------------------------------------------------------------- +// REST OF THE ORIGINAL CODE FOLLOWS... // ----------------------------------------------------------------------------- // SaveCluster inserts or updates a cluster @@ -191,7 +330,7 @@ } // ----------------------------------------------------------------------------- -// LOAD ENABLED METHODS +// LOAD ENABLED METHODS (UNCHANGED) // ----------------------------------------------------------------------------- // LoadEnabledClusters retrieves all enabled clusters @@ -312,7 +451,7 @@ } // ----------------------------------------------------------------------------- -// LOAD ALL METHODS +// LOAD ALL METHODS (UNCHANGED) // ----------------------------------------------------------------------------- // LoadAllClusters retrieves all clusters, regardless of their enabled status @@ -412,7 +551,7 @@ } // ----------------------------------------------------------------------------- -// SNAPSHOT MANAGEMENT +// SNAPSHOT MANAGEMENT (UNCHANGED) // ----------------------------------------------------------------------------- // SnapshotConfig aggregates xDS resources @@ -528,6 +667,7 @@ // --- 1. Save/Upsert Clusters and Collect Names --- clusterNames := make([]string, 0, len(cfg.EnabledClusters)) for _, c := range cfg.EnabledClusters { + // NOTE: This uses the existing SaveCluster which doesn't use the transaction 'tx' if err = s.SaveCluster(ctx, c); err != nil { return fmt.Errorf("failed to save cluster %s: %w", c.GetName(), err) } @@ -537,6 +677,7 @@ // --- 2. Save/Upsert Listeners and Collect Names --- listenerNames := make([]string, 0, len(cfg.EnabledListeners)) for _, l := range cfg.EnabledListeners { + // NOTE: This uses the existing SaveListener which doesn't use the transaction 'tx' if err = s.SaveListener(ctx, l); err != nil { return fmt.Errorf("failed to save listener %s: %w", l.GetName(), err) } @@ -546,6 +687,7 @@ // --- 3. Save/Upsert Secrets and Collect Names --- secretNames := make([]string, 0, len(cfg.EnabledSecrets)) for _, sec := range cfg.EnabledSecrets { + // NOTE: This uses the existing SaveSecret which doesn't use the transaction 'tx' if err = s.SaveSecret(ctx, sec); err != nil { return fmt.Errorf("failed to save secret %s: %w", sec.GetName(), err) } @@ -587,7 +729,7 @@ } // ----------------------------------------------------------------------------- -// ENABLE/DISABLE & DELETE METHODS +// ENABLE/DISABLE & DELETE METHODS (UNCHANGED) // ----------------------------------------------------------------------------- // EnableCluster toggles a cluster diff --git a/static/global.js b/static/global.js index e442701..4d57d2b 100644 --- a/static/global.js +++ b/static/global.js @@ -550,7 +550,7 @@ import { loadAllData } from './data_loader.js'; import {showDomainConfig} from '/data_fetchers.js' import {resolveConsistency} from './consistency.js' -import { listSecrets,showAddSecretModal ,hideAddSecretModal, disableSecret, enableSecret, submitNewSecret, removeSecret} from './secrets.js'; +import { listSecrets,showAddSecretModal ,hideAddSecretModal, disableSecret, enableSecret, submitNewSecret, removeSecret, manualRenewCertificateExport} from './secrets.js'; import { showListenerConfigModal ,showClusterConfigModal,showSecretConfigModal} from './modals.js'; import { listListeners, removeFilterChainByRef, disableListener, enableListener, removeListener, showAddListenerModal, hideAddListenerModal, submitNewListener } from './listeners.js'; window.listClusters = listClusters; @@ -573,6 +573,7 @@ window.showSecretConfigModal = showSecretConfigModal; window.submitNewSecret = submitNewSecret; window.removeSecret = removeSecret; +window.manualRenewCertificateExport = manualRenewCertificateExport; window.listListeners = listListeners; window.removeFilterChainByRef = removeFilterChainByRef; diff --git a/static/secrets.js b/static/secrets.js index a064db5..4a808ac 100644 --- a/static/secrets.js +++ b/static/secrets.js @@ -37,7 +37,7 @@ return typeDetails; } catch { - return '(Config Error)'; + return '(Config Error)'; } } @@ -84,7 +84,7 @@ } /** - * NEW: Calls the backend API to check the validity of a certificate. + * Calls the backend API to check the validity of a certificate. * @param {string} certificatePem - The PEM encoded certificate string. * @returns {Promise} True if the certificate is valid, false otherwise. */ @@ -115,7 +115,6 @@ } } - /** * Shows a modal with the detailed certificate information. * @param {string} secretName - The name of the secret. @@ -163,6 +162,95 @@ window.showCertificateDetailsModal = showCertificateDetailsModal; +/** + * Core logic to manually renew a TLS certificate. + * Prompts the user for the domain name. + * @param {string} secretName - The name of the secret/certificate. + */ +async function manualRenewCertificate(secretName) { + // 1. Get user confirmation and input for the domain name + const domainName = prompt( + `Please enter the domain name associated with secret '${secretName}' to renew its certificate. + \n(Example: test.jerxie.com)` + ); + + if (!domainName) { + console.log(`Certificate renewal for secret '${secretName}' cancelled or domain not provided.`); + return; + } + + if (!confirm(`Confirm renewal request for domain: ${domainName} (Secret: ${secretName})?`)) { + return; + } + + // 2. Prepare API payload + const renewSecretName = secretName; // Use the secret name itself for the 'secret_name' API param + const url = `${API_BASE_URL}/renew-certificate`; + const payload = { + domain: domainName.trim(), // Use user input + secret_name: renewSecretName + }; + + console.log(`Attempting to renew certificate for domain: ${domainName.trim()} with secret: ${renewSecretName}`); + + // 3. Execute API call with robust error handling + try { + const response = await fetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload) + }); + + // Check if the response is NOT OK (e.g., 4xx, 5xx) + if (!response.ok) { + // Try to read the error from the response body, which could be plain text or JSON + const errorText = await response.text(); + + // Attempt to parse the error body as JSON to get a cleaner message + let errorMessage = `HTTP Error ${response.status}: ${response.statusText}`; + try { + const errorJson = JSON.parse(errorText); + // Check for a common 'error' field in the JSON response + errorMessage = errorJson.error || errorJson.message || errorText; + } catch { + // If parsing fails, use the raw text as the message + errorMessage = errorText || errorMessage; + } + + // Throw a custom error to be caught below and alerted to the user + throw new Error(errorMessage); + } + + // If successful, attempt to parse the JSON body + const responseBody = await response.json(); + + console.log(`Certificate for '${domainName.trim()}' successfully renewed:`, responseBody); + alert(`Certificate for ${domainName.trim()} successfully renewed! The list will now refresh.`); + + // Force a UI refresh to show the potentially updated certificate validity status + cleanupConfigStore(); + listSecrets(); + + } catch (error) { + // This catches the custom error thrown above OR a network/fetch error + console.error(`Failed to renew certificate for '${domainName.trim()}':`, error); + + // Alert the user with the specific error message + alert(`🚨 Renewal failed for ${domainName.trim()}. Error: ${error.message}`); + } +} + +/** + * UI entrypoint for manual certificate renewal. + * @param {string} secretName - The name of the secret/certificate. + * @param {Event} event - The click event to stop propagation. + */ +export function manualRenewCertificateExport(secretName, event) { + event.stopPropagation(); + manualRenewCertificate(secretName); +} + + // ========================================================================= // SECRET CORE LOGIC (listSecrets) // ========================================================================= @@ -245,10 +333,11 @@ `; } - // Add 'View Details' button for TLS Certificates regardless of status + // Add 'View Details' and 'Renew Cert' buttons for TLS Certificates if (isTlsCertificate(secret)) { actionButtons += ` + `; }