diff --git a/internal/api.go b/internal/api.go
index 4849cae..54697f8 100644
--- a/internal/api.go
+++ b/internal/api.go
@@ -145,6 +145,7 @@
mux.HandleFunc("/issue-certificate", api.issueCertificateHandler)
mux.HandleFunc("/parse-certificate", api.parseCertificateHandler)
mux.HandleFunc("/check-certificate-validity", api.checkCertificateValidityHandler)
+ mux.HandleFunc("/get-certificate", api.getCertificateHandler)
// Renew Certificate Handler
mux.HandleFunc("/renew-certificate", api.renewCertificateHandler)
diff --git a/internal/api_handlers.go b/internal/api_handlers.go
index 58449ff..d11dfcd 100644
--- a/internal/api_handlers.go
+++ b/internal/api_handlers.go
@@ -626,6 +626,54 @@
w.WriteHeader(http.StatusOK)
}
+func (api *API) getCertificateHandler(w http.ResponseWriter, r *http.Request) {
+ if r.Method != http.MethodGet {
+ http.Error(w, "method not allowed", http.StatusMethodNotAllowed)
+ return
+ }
+
+ domain := r.URL.Query().Get("domain")
+ secretName := r.URL.Query().Get("secret_name")
+
+ if domain == "" && secretName == "" {
+ http.Error(w, "either domain or secret_name query parameter required", http.StatusBadRequest)
+ return
+ }
+ if domain != "" && secretName != "" {
+ http.Error(w, "only one of domain or secret_name query parameter should be provided", http.StatusBadRequest)
+ return
+ }
+
+ var cert *storage.CertStorage
+ var err error
+ if domain != "" {
+ cert, err = api.Manager.DB.LoadCertificate(context.Background(), domain)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to load certificate for domain %s: %v", domain, err), http.StatusInternalServerError)
+ return
+ }
+ if cert == nil {
+ http.Error(w, fmt.Sprintf("no certificate found for domain %s", domain), http.StatusNotFound)
+ return
+ }
+ }
+ if secretName != "" {
+ cert, err = api.Manager.DB.LoadCertificateBySecretName(context.Background(), secretName)
+ if err != nil {
+ http.Error(w, fmt.Sprintf("failed to load certificate for secret name %s: %v", secretName, err), http.StatusInternalServerError)
+ return
+ }
+ if cert == nil {
+ http.Error(w, fmt.Sprintf("no certificate found for secret name %s", secretName), http.StatusNotFound)
+ return
+ }
+ }
+
+ w.Header().Set("Content-Type", "application/json")
+ json.NewEncoder(w).Encode(cert)
+ w.WriteHeader(http.StatusOK)
+}
+
func (api *API) storageDumpHandler(w http.ResponseWriter, r *http.Request) {
ctx := context.Background()
@@ -819,9 +867,10 @@
for _, cert := range certs {
if cert.EnableRotation {
rotatingCerts = append(rotatingCerts, &internalapi.RotatingCertificateInfo{
- Domain: cert.Domain,
- SecretName: cert.SecretName,
- RenewBefore: cert.RenewBefore.String(),
+ Domain: cert.Domain,
+ SecretName: cert.SecretName,
+ RenewBefore: cert.RenewBefore.String(),
+ RotationEnabled: true,
})
}
}
diff --git a/internal/pkg/storage/storage.go b/internal/pkg/storage/storage.go
index d54bc94..3111a95 100644
--- a/internal/pkg/storage/storage.go
+++ b/internal/pkg/storage/storage.go
@@ -174,6 +174,42 @@
return cert, nil
}
+func (s *Storage) LoadCertificateBySecretName(ctx context.Context, secretName string) (*CertStorage, error) {
+ // We expect one result, similar to LoadCertificate, but querying by secret_name.
+ // Use placeholder(1) and let the strategy handle the SQL dialect
+ query := fmt.Sprintf(`SELECT domain, email, cert_pem, key_pem, account_key, account_url, issuer_type, secret_name, enable_rotation, renew_before FROM certificates WHERE secret_name = %s`, s.placeholder(1))
+
+ row := s.db.QueryRowContext(ctx, query, secretName)
+
+ cert := &CertStorage{}
+ var renewBeforeNanos int64
+
+ err := row.Scan(
+ &cert.Domain,
+ &cert.Email,
+ &cert.CertPEM,
+ &cert.KeyPEM,
+ &cert.AccountKey,
+ &cert.AccountURL,
+ &cert.IssuerType,
+ &cert.SecretName,
+ &cert.EnableRotation,
+ &renewBeforeNanos,
+ )
+
+ cert.RenewBefore = time.Duration(renewBeforeNanos)
+
+ if errors.Is(err, sql.ErrNoRows) {
+ // Return a specific error if no certificate is found
+ return nil, fmt.Errorf("certificate with secret name %s not found", secretName)
+ }
+ if err != nil {
+ return nil, fmt.Errorf("failed to scan certificate data for secret %s: %w", secretName, err)
+ }
+
+ 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 {
diff --git a/static/global.js b/static/global.js
index b1c955a..894808b 100644
--- a/static/global.js
+++ b/static/global.js
@@ -61,31 +61,49 @@
window.hideAddClusterModal?.();
window.hideAddSecretModal?.();
window.hideCertificateDetailsModal?.()
+ window.hideRotationSettingsModal?.();
}
});
-// Close modal when clicking outside of the content (on the backdrop)
window.addEventListener('click', (event) => {
- const modal = document.getElementById('configModal');
- const secretModal = document.getElementById('secretConfigModal');
- const addFCModal = document.getElementById('addFilterChainModal');
- const addListenerModal = document.getElementById('addListenerModal');
- const addClusterModal = document.getElementById('addClusterModal');
-
- if (event.target === modal) {
- hideModal();
- }
- if (event.target === secretModal) {
- document.getElementById('secretConfigModal').style.display = 'none';
- }
- if (event.target === addFCModal) {
- window.hideAddFilterChainModal?.();
- }
- if (event.target === addListenerModal) {
- window.hideAddListenerModal?.();
- }
- if (event.target === addClusterModal) {
- window.hideAddClusterModal?.();
+ // 1. Check if the clicked element has the 'modal' class (i.e., is a backdrop)
+ if (event.target.classList.contains('modal')) {
+ const modalId = event.target.id;
+
+ // 2. Map the modal ID to its corresponding close function
+ switch (modalId) {
+ case 'configModal':
+ // The general configuration/details modal
+ hideModal();
+ break;
+ case 'secretConfigModal':
+ // Note: The HTML provided doesn't show this ID, but it's in your original JS.
+ document.getElementById('secretConfigModal').style.display = 'none';
+ break;
+ case 'addFilterChainModal':
+ window.hideAddFilterChainModal?.();
+ break;
+ case 'addListenerModal':
+ window.hideAddListenerModal?.();
+ break;
+ case 'addClusterModal':
+ window.hideAddClusterModal?.();
+ break;
+ case 'addSecretModal':
+ window.hideAddSecretModal?.();
+ break;
+ case 'certificateDetailsModal':
+ window.hideCertificateDetailsModal?.();
+ break;
+ case 'rotationSettingsModal':
+ window.hideRotationSettingsModal?.();
+ break;
+ case 'consistencyModal':
+ // Note: The HTML has an inline onclick, but for consistency, we call the global function.
+ window.hideConsistencyModal?.();
+ break;
+ // Add any future modal IDs here
+ }
}
});
@@ -556,7 +574,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, manualRenewCertificateExport} from './secrets.js';
+import { listSecrets,showAddSecretModal ,hideAddSecretModal, disableSecret, enableSecret, submitNewSecret, removeSecret, manualRenewCertificate, hideRotationSettingsModal} 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;
@@ -579,7 +597,8 @@
window.showSecretConfigModal = showSecretConfigModal;
window.submitNewSecret = submitNewSecret;
window.removeSecret = removeSecret;
-window.manualRenewCertificateExport = manualRenewCertificateExport;
+window.manualRenewCertificate = manualRenewCertificate;
+window.hideRotationSettingsModal = hideRotationSettingsModal;
window.listListeners = listListeners;
window.removeFilterChainByRef = removeFilterChainByRef;
diff --git a/static/index.html b/static/index.html
index c482852..247b409 100644
--- a/static/index.html
+++ b/static/index.html
@@ -354,6 +354,56 @@
+
+
+
+
+
+
Configure automatic certificate rotation for secret:
+
+
+
+
+
+
diff --git a/static/secrets.js b/static/secrets.js
index c52c53d..08c5678 100644
--- a/static/secrets.js
+++ b/static/secrets.js
@@ -1,9 +1,5 @@
-// secrets.js
import { API_BASE_URL, configStore, cleanupConfigStore } from './global.js';
-// =========================================================================
-// SECRET UTILITIES (Unchanged)
-// =========================================================================
/**
* Extracts a concise description of the secret type (e.g., TlsCertificate).
@@ -34,6 +30,41 @@
typeDetails += ` ${statusText}`;
}
+// NEW: Append rotation status for TLS Certificates with HOVER DETAILS
+ if (typeName === 'TlsCertificate' && secret.rotationStatus === 'Enabled') {
+ const rotationInfo = secret.rotationDetails;
+ let tooltipContent = 'Auto Rotation Enabled.'; // Renamed from tooltipText to tooltipContent
+
+ if (rotationInfo) {
+ // 1. Convert renew_before to display format
+ let renewBeforeDisplay = rotationInfo.renew_before || 'N/A';
+ const hoursMatch = renewBeforeDisplay.match(/(\d+)h/);
+ if (hoursMatch) {
+ const hours = parseInt(hoursMatch[1], 10);
+ const days = Math.round(hours / 24);
+ renewBeforeDisplay = `${days}d (${hours}h)`;
+ }
+
+ const rotationStatusText = rotationInfo.rotation_enabled ? 'Active' : 'Configured (Disabled)';
+ const expiresAtText = rotationInfo.expires_at || 'N/A';
+
+ // 2. Assemble the full tooltip content with actual newlines
+ // This content will be used by our CSS tooltip.
+ tooltipContent = `
+Rotation Status: ${rotationStatusText}
+Domain: ${rotationInfo.domain}
+Secret Name: ${rotationInfo.secret_name}
+Renew Before: ${renewBeforeDisplay}
+Expires At: ${expiresAtText}
+ `.trim();
+ }
+
+ // --- IMPORTANT CHANGE HERE ---
+ // Remove the 'title' attribute and use 'data-tooltip' instead.
+ // The 'title' attribute can be kept as a fallback for accessibility,
+ // but the CSS tooltip will take precedence.
+ typeDetails += `Auto Rotation Enabled`;
+ }
return typeDetails;
} catch {
@@ -57,7 +88,7 @@
// =========================================================================
-// CERTIFICATE LOGIC (API & Modal) (Unchanged)
+// CERTIFICATE LOGIC (API & Modal) (Modified/New)
// =========================================================================
/**
@@ -163,7 +194,9 @@
if (modal) {
modal.style.display = 'none';
// Clear the input when closing
- document.getElementById('certificate-details-content').value = '';
+ // NOTE: Changed .value to .innerHTML as 'certificate-details-content' is a container, not an input.
+ const content = document.getElementById('certificate-details-content');
+ if (content) content.innerHTML = '';
}
}
@@ -172,42 +205,57 @@
window.hideCertificateDetailsModal = hideCertificateDetailsModal;
+// -------------------------------------------------------------------------
+// NEW ROTATION SETTINGS LOGIC
+// -------------------------------------------------------------------------
+
/**
- * Core logic to manually renew a TLS certificate. (Unchanged)
- * Prompts the user for the domain name.
+ * Calls the backend API to fetch the current certificate rotation status.
* @param {string} secretName - The name of the secret/certificate.
+ * @returns {Promise