diff --git a/src/ui/app/routes/customcert.py b/src/ui/app/routes/customcert.py new file mode 100644 index 0000000000..dbd93c9d3d --- /dev/null +++ b/src/ui/app/routes/customcert.py @@ -0,0 +1,629 @@ +from datetime import datetime, timezone +from hashlib import sha256 +from json import dumps, loads +from pathlib import Path + +from cryptography.hazmat.primitives.asymmetric import dsa, ec, rsa +from cryptography.x509 import load_pem_x509_certificate, load_pem_x509_certificates +from flask import Blueprint, render_template +from flask_login import login_required + +from app.dependencies import BW_CONFIG, DB +from app.utils import LOGGER + +customcert = Blueprint("customcert", __name__) + +CUSTOMCERT_CACHE_ROOT = Path("/var/cache/bunkerweb/customcert") +CUSTOMCERT_METADATA_ROOT = Path("/var/cache/bunkerweb/customcert-metadata") + +# Maximum SANs to display in table (show "and X more" if exceeded) +MAX_DISPLAY_SANS = 8 + + +def _path_under_root(resolved_path: Path, root: Path) -> bool: + """Return True if resolved_path is under root (prevents path traversal).""" + try: + return resolved_path.is_relative_to(root.resolve()) or resolved_path.resolve() == root.resolve() + except (ValueError, OSError): + return False + + +def sanitize_service_name(value: str, max_length: int = 255) -> str: + """Sanitize service name for path/DB/display: alphanumeric, dot, hyphen, underscore only.""" + if not isinstance(value, str): + return "" + value = value.strip()[:max_length] + safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_") + return "".join(c if c in safe_chars else "" for c in value) + + +def sanitize_cert_field(value: str, max_length: int = 1024) -> str: + """Sanitize certificate field to prevent injection attacks. + + Only allows alphanumeric, dots, hyphens, underscores, and spaces. + Truncates to max_length to prevent DOS. + """ + if not isinstance(value, str): + return "" + + # Truncate to max length + value = value[:max_length] + + # Only allow safe characters: alphanumeric, dots, hyphens, underscores, spaces, @, :, /, =, , () + # This covers domain names, IPs, and common certificate formats (RDN attributes use = and ,) + safe_chars = set("abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789.-_@ :/*=,()") + return "".join(c if c in safe_chars else "?" for c in value) + + +def matches_domain_pattern(domain: str, pattern: str) -> bool: + """Check if a domain matches a certificate pattern (supporting wildcards). + + Examples: + - "www.example.com" matches "www.example.com" -> True + - "www.example.com" matches "*.example.com" -> True + - "api.example.com" matches "*.example.com" -> True + - "example.com" matches "*.example.com" -> False (wildcard requires subdomain) + - "sub.www.example.com" matches "*.example.com" -> False (too many subdomains) + """ + pattern = pattern.lower() + domain = domain.lower() + + if pattern == domain: + return True + + if pattern.startswith("*."): + # Wildcard pattern - must match exactly one subdomain level + suffix = pattern[2:] # Remove "*." + if domain.endswith("." + suffix): + # Check that there's exactly one label before the suffix + prefix = domain[: -len(suffix) - 1] + return "." not in prefix + + return False + + +def validate_cert_names(cn: str | None, sans: list[str], service_domains: list[str]) -> dict: + """Validate that certificate CN/SANs match the service domain names. + + Returns dict with: + - "valid": bool - True if at least one service domain is covered by cert + - "covered_domains": list - service domains covered by the certificate + - "uncovered_domains": list - service domains NOT covered by the certificate + - "warnings": list - validation warning messages + """ + if not service_domains: + return { + "valid": True, + "covered_domains": [], + "uncovered_domains": [], + "warnings": [], + } + + # All possible certificate names (CN + SANs, deduplicated and lowercased) + cert_names = set() + if cn: + cert_names.add(cn.lower()) + for san in sans: + cert_names.add(san.lower()) + + covered = [] + uncovered = [] + + for service_domain in service_domains: + service_domain_lower = service_domain.lower() + # Check if this service domain matches any certificate name + if any(matches_domain_pattern(service_domain_lower, cert_name) for cert_name in cert_names): + covered.append(service_domain) + else: + uncovered.append(service_domain) + + warnings = [] + if uncovered: + warnings.append( + f"Certificate does not cover: {', '.join(sanitize_cert_field(d) for d in uncovered)}. " + f"Cert covers: {', '.join(sorted(sanitize_cert_field(n) for n in cert_names))}" + ) + + return { + "valid": len(uncovered) == 0, + "covered_domains": [sanitize_cert_field(d) for d in covered], + "uncovered_domains": [sanitize_cert_field(d) for d in uncovered], + "warnings": warnings, + } + + +def get_cert_key_type_and_size(cert_pem: bytes) -> tuple[str, int | None]: + """Extract key type and size from certificate.""" + try: + cert = load_pem_x509_certificate(cert_pem) + pub_key = cert.public_key() + + if isinstance(pub_key, rsa.RSAPublicKey): + return "RSA", pub_key.key_size + elif isinstance(pub_key, ec.EllipticCurvePublicKey): + return f"ECDSA ({pub_key.curve.name})", pub_key.key_size + elif isinstance(pub_key, dsa.DSAPublicKey): + return "DSA", pub_key.key_size + else: + return "Unknown", None + except Exception as e: + LOGGER.warning(f"Could not extract key type: {e}") + return "Unknown", None + + +def get_cert_info(cert_pem: bytes) -> dict: + """Extract certificate information (CN, SANs, TTL, Issuer, OCSP, EKU, not_before).""" + try: + LOGGER.debug(f"get_cert_info: Processing {len(cert_pem)} bytes of certificate data") + + # Normalize PEM: remove commented lines and extra whitespace + pem_lines = [] + original_len = len(cert_pem) + has_invalid = False + for line in cert_pem.decode('utf-8', errors='ignore').split('\n'): + stripped = line.strip() + # Keep only valid PEM lines: BEGIN/END markers, base64 data, but skip comments + if stripped and not stripped.startswith('#'): + pem_lines.append(line) + elif stripped.startswith('#'): + has_invalid = True + + cert_pem = '\n'.join(pem_lines).encode('utf-8') + if has_invalid: + LOGGER.debug(f"Found invalid content (comments/etc): removed during normalization ({original_len} -> {len(cert_pem)} bytes)") + else: + LOGGER.debug(f"PEM normalization complete: {len(cert_pem)} bytes") + + # Try to load as single certificate first, then fallback to chain + try: + cert = load_pem_x509_certificate(cert_pem) + LOGGER.debug("Single certificate load succeeded") + except Exception as e: + # If single cert fails, try loading as chain and use the first (leaf) certificate + LOGGER.debug(f"Single certificate load failed ({type(e).__name__}): {e}, trying chain load...") + try: + certs = load_pem_x509_certificates(cert_pem) + if not certs: + raise ValueError("No certificates found in PEM data") + cert = certs[0] # Use the leaf certificate (first in chain) + LOGGER.debug(f"Chain load succeeded, using first certificate from {len(certs)} cert(s)") + except Exception as e2: + LOGGER.error(f"Chain load also failed ({type(e2).__name__}): {e2}") + raise + + # Extract CN (Common Name) + cn = None + try: + from cryptography.x509.oid import NameOID + + cn_attrs = cert.subject.get_attributes_for_oid(NameOID.COMMON_NAME) + if cn_attrs: + cn = str(cn_attrs[0].value) + LOGGER.debug(f"Extracted CN: {cn}") + else: + LOGGER.debug("No CN attributes found in certificate subject") + except Exception as e: + LOGGER.error(f"Error extracting CN from certificate: {type(e).__name__}: {e}") + + # Extract SANs (Subject Alternative Names) + sans = [] + try: + from cryptography.x509 import DNSName, SubjectAlternativeName + from cryptography.x509.oid import ExtensionOID + + san_ext = cert.extensions.get_extension_for_oid(ExtensionOID.SUBJECT_ALTERNATIVE_NAME) + if isinstance(san_ext.value, SubjectAlternativeName): + for name in san_ext.value: + if isinstance(name, DNSName): + sans.append(str(name.value)) + LOGGER.debug(f"Extracted SANs: {sans}") + else: + LOGGER.debug("SAN extension not a SubjectAlternativeName") + except Exception as e: + LOGGER.debug(f"No SANs extension or error extracting SANs: {type(e).__name__}: {e}") + + # Get validity dates + now = datetime.now(timezone.utc) + not_before = cert.not_valid_before_utc + not_after = cert.not_valid_after_utc + + if getattr(not_before, "tzinfo", None) is None: + not_before = not_before.replace(tzinfo=timezone.utc) + if getattr(not_after, "tzinfo", None) is None: + not_after = not_after.replace(tzinfo=timezone.utc) + + # Calculate TTL + ttl_seconds = (not_after - now).total_seconds() + ttl_days = ttl_seconds / 86400.0 + + # Check if certificate is not yet valid + not_yet_valid = now < not_before + not_yet_valid_days = (not_before - now).total_seconds() / 86400.0 if not_yet_valid else 0 + + # Extract Issuer + issuer = None + try: + issuer = cert.issuer.rfc4514_string() + except Exception: + issuer = str(cert.issuer) + + # Get key type and size + key_type, key_size = get_cert_key_type_and_size(cert_pem) + key_info_raw = f"{key_type} ({key_size} bits)" if key_size else key_type + key_info = sanitize_cert_field(key_info_raw) or "Unknown" + + # Check Extended Key Usage (specifically for serverAuth) + server_auth_enabled = False + try: + from cryptography.x509 import ExtendedKeyUsage + from cryptography.x509.oid import ExtensionOID, ExtendedKeyUsageOID + + eku_ext = cert.extensions.get_extension_for_oid(ExtensionOID.EXTENDED_KEY_USAGE) + if isinstance(eku_ext.value, ExtendedKeyUsage): + server_auth_enabled = ExtendedKeyUsageOID.SERVER_AUTH in eku_ext.value + except Exception: + # If no EKU extension found, assume it's valid (older certs may not have it) + server_auth_enabled = True + + # Check for OCSP URLs + ocsp_urls = [] + try: + from cryptography.x509 import AuthorityInformationAccess + from cryptography.x509.oid import AuthorityInformationAccessOID, ExtensionOID + + aia_ext = cert.extensions.get_extension_for_oid(ExtensionOID.AUTHORITY_INFORMATION_ACCESS) + if isinstance(aia_ext.value, AuthorityInformationAccess): + for desc in aia_ext.value: + if desc.access_method == AuthorityInformationAccessOID.OCSP: + ocsp_urls.append(str(desc.access_location.value)) + except Exception: + pass + + return { + "cn": sanitize_cert_field(cn) if cn else None, + "sans": [sanitize_cert_field(san) for san in sans], + "ttl_seconds": ttl_seconds, + "ttl_days": ttl_days, + "expires_at": not_after.isoformat(), + "not_before": not_before.isoformat(), + "issuer": sanitize_cert_field(issuer) if issuer else None, + "key_info": key_info, + "valid": ttl_seconds > 0, + "not_yet_valid": not_yet_valid, + "not_yet_valid_days": not_yet_valid_days, + "server_auth_enabled": server_auth_enabled, + "ocsp_urls": [sanitize_cert_field(url) for url in ocsp_urls], + } + except Exception as e: + LOGGER.error(f"Exception in get_cert_info: {type(e).__name__}: {e}") + import traceback + LOGGER.debug(f"Traceback: {traceback.format_exc()}") + return {} + + +def compute_cert_hash(cert_pem: bytes) -> str: + """Compute SHA256 hash of certificate.""" + return sha256(cert_pem).hexdigest() + + +def cache_cert_metadata(service_name: str, cert_hash: str, metadata: dict) -> None: + """Cache certificate metadata with checksum in database and filesystem.""" + safe_name = sanitize_service_name(service_name) + if not safe_name: + LOGGER.warning("Refusing to cache metadata for empty or invalid service name") + return + cache_data = { + "service_name": safe_name, + "cert_hash": cert_hash, + "metadata": metadata, + "cached_at": datetime.now(timezone.utc).isoformat(), + "ttl": 3600, # 1 hour TTL + } + try: + # Cache in database + DB.insert( + "cache", + [ + "filename", + "content", + ], + [ + f"customcert-{safe_name}.json", + dumps(cache_data), + ], + ) + LOGGER.debug(f"Cached certificate metadata for {safe_name} in database") + except Exception as e: + LOGGER.debug(f"Could not cache metadata in database for {safe_name}: {e}") + + try: + cache_dir = CUSTOMCERT_METADATA_ROOT + cache_dir.mkdir(parents=True, exist_ok=True) + cache_file = (cache_dir / f"{safe_name}.json").resolve() + if not _path_under_root(cache_file, cache_dir): + LOGGER.warning(f"Refusing to write metadata outside cache root for service: {safe_name}") + return + cache_file.write_text(dumps(cache_data, indent=2)) + except Exception as e: + LOGGER.debug(f"Could not cache metadata in filesystem for {safe_name}: {e}") + + +def delete_cert_metadata(service_name: str) -> None: + """Delete cached certificate metadata from filesystem and database.""" + safe_name = sanitize_service_name(service_name) + if not safe_name: + return + + # Delete from filesystem + try: + cache_file = (CUSTOMCERT_METADATA_ROOT / f"{safe_name}.json").resolve() + if _path_under_root(cache_file, CUSTOMCERT_METADATA_ROOT) and cache_file.exists(): + cache_file.unlink() + LOGGER.debug(f"Deleted cached metadata for {safe_name} from filesystem") + except Exception as e: + LOGGER.debug(f"Could not delete filesystem metadata for {safe_name}: {e}") + + # Delete from database + try: + DB.delete( + "cache", + "filename", + f"customcert-{safe_name}.json", + ) + LOGGER.debug(f"Deleted cached metadata for {safe_name} from database") + except Exception as e: + LOGGER.debug(f"Could not delete database metadata for {safe_name}: {e}") + + +def get_cached_cert_metadata(service_name: str) -> dict | None: + """Retrieve cached certificate metadata from filesystem if available and not expired.""" + safe_name = sanitize_service_name(service_name) + if not safe_name: + return None + try: + cache_file = (CUSTOMCERT_METADATA_ROOT / f"{safe_name}.json").resolve() + if not _path_under_root(cache_file, CUSTOMCERT_METADATA_ROOT): + return None + if cache_file.exists(): + data = loads(cache_file.read_text()) + if isinstance(data, dict) and "metadata" in data and "cert_hash" in data: + # Check if cache has expired (TTL-based) + if "cached_at" in data and "ttl" in data: + cached_at = datetime.fromisoformat(data["cached_at"]) + ttl = data["ttl"] + age_seconds = (datetime.now(timezone.utc) - cached_at.replace(tzinfo=timezone.utc)).total_seconds() + if age_seconds > ttl: + LOGGER.debug(f"Cached metadata for {safe_name} expired (age: {age_seconds}s, ttl: {ttl}s)") + return None + return data + LOGGER.debug("Cached metadata invalid structure, ignoring") + except Exception as e: + LOGGER.debug(f"Could not read cached metadata from filesystem: {e}") + + return None + + +def find_cert_file(service_name: str) -> Path | None: + """Find certificate file for a service (checks -ecdsa, -rsa, plain variants and cert-ecdsa.pem).""" + safe_name = sanitize_service_name(service_name) + if not safe_name: + return None + # Try different algorithm variants (as directory suffixes) + # Include both regular and wildcard variants + variants = [ + f"{safe_name}-ecdsa", + f"{safe_name}-rsa", + f"{safe_name}-ML-DSA", + f"{safe_name}-SLH-DSA", + f"{safe_name}-pqc", + safe_name, + f"_wildcard_.{safe_name}-ecdsa", + f"_wildcard_.{safe_name}-rsa", + f"_wildcard_.{safe_name}-ML-DSA", + f"_wildcard_.{safe_name}-SLH-DSA", + f"_wildcard_.{safe_name}-pqc", + f"_wildcard_.{safe_name}", + ] + + cert_filenames = ["cert.pem", "cert-ecdsa.pem", "cert-rsa.pem"] + + for variant in variants: + for cert_filename in cert_filenames: + cert_path = (CUSTOMCERT_CACHE_ROOT / variant / cert_filename).resolve() + if not _path_under_root(cert_path, CUSTOMCERT_CACHE_ROOT): + continue + if cert_path.exists(): + LOGGER.debug(f"✓ Found certificate for {safe_name} at: {cert_path}") + return cert_path + + LOGGER.debug(f"No certificate found for {safe_name}") + return None + + +@customcert.route("/customcert", methods=["GET"]) +@login_required +def customcert_page(): + """Display overview of all custom certificates.""" + certs = [] + + LOGGER.debug("CUSTOMCERT page start") + + # Get multisite configuration + try: + multisite_config = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("MULTISITE",)) + multisite = multisite_config.get("MULTISITE") + multisite = multisite == "yes" if multisite else False + LOGGER.debug(f"Multisite mode: {multisite}") + except Exception as e: + LOGGER.error(f"Error getting MULTISITE config: {e}") + multisite = False + + if multisite: + # Get all services from SERVER_NAME and check which have custom SSL enabled + try: + server_name_config = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=("SERVER_NAME",)) + server_names = server_name_config.get("SERVER_NAME", "") + services = [] + + if not server_names: + LOGGER.warning("SERVER_NAME is empty in multisite mode") + else: + server_list = server_names.split() + LOGGER.debug(f"Parsed server list ({len(server_list)} entries)") + + saved_settings = DB.get_services_settings(methods=False, with_drafts=False) + draft_settings = DB.get_services_settings(methods=False, with_drafts=True) + + for idx, service_name in enumerate(server_list): + if idx < len(saved_settings) and idx < len(draft_settings): + saved_config = saved_settings[idx] + draft_config = draft_settings[idx] + + use_custom_ssl_saved = saved_config.get("USE_CUSTOM_SSL") + use_custom_ssl_draft = draft_config.get("USE_CUSTOM_SSL") + + is_draft = use_custom_ssl_draft != use_custom_ssl_saved + use_custom_ssl = use_custom_ssl_draft if is_draft else use_custom_ssl_saved + + LOGGER.debug( + f"Service {sanitize_service_name(service_name) or '?'}: " + f"saved={use_custom_ssl_saved}, draft={use_custom_ssl_draft}, is_draft={is_draft}" + ) + + if use_custom_ssl == "yes": + services.append((service_name, is_draft)) + else: + LOGGER.warning(f"Service at index {idx} not found in services_settings") + except Exception as e: + LOGGER.error(f"Error getting server configuration in multisite mode: {e}") + services = [] + else: + # Single-site mode + try: + # Get both saved and draft config + use_custom_ssl_saved_config = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=( + "USE_CUSTOM_SSL", + ), with_drafts=False) + use_custom_ssl_draft_config = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=( + "USE_CUSTOM_SSL", + ), with_drafts=True) + + use_custom_ssl_saved = use_custom_ssl_saved_config.get("USE_CUSTOM_SSL", "no") + use_custom_ssl_draft = use_custom_ssl_draft_config.get("USE_CUSTOM_SSL", "no") + + # Determine which value to use and if it's a draft + is_draft = use_custom_ssl_draft != use_custom_ssl_saved + use_custom_ssl = use_custom_ssl_draft if is_draft else use_custom_ssl_saved + + server_name_config = BW_CONFIG.get_config(global_only=True, methods=False, filtered_settings=( + "SERVER_NAME", + )) + server_name = server_name_config.get("SERVER_NAME", "") + + LOGGER.debug( + f"Single-site: USE_CUSTOM_SSL={use_custom_ssl}, is_draft={is_draft}, " + f"SERVER_NAME={sanitize_service_name(server_name) or '?'}" + ) + + if use_custom_ssl == "yes" and server_name: + service = server_name.split()[0] if isinstance(server_name, str) else server_name + services = [(service, is_draft)] + else: + LOGGER.warning( + f"Not collecting certs: USE_CUSTOM_SSL={use_custom_ssl}, " + f"SERVER_NAME={sanitize_service_name(server_name) or '?'}" + ) + services = [] + except Exception as e: + LOGGER.error(f"Error getting configuration in single-site mode: {e}") + services = [] + + LOGGER.debug(f"Collecting certs for {len(services)} service(s) with custom SSL") + for service_entry in services: + if isinstance(service_entry, tuple): + service_name, is_draft = service_entry + else: + service_name = service_entry + is_draft = False + + safe_name = sanitize_service_name(service_name) + if not safe_name or safe_name != service_name: + LOGGER.warning(f"Skipping service with invalid or unsafe name (sanitized: {safe_name!r})") + continue + + cert_file = find_cert_file(service_name) + LOGGER.debug(f"Service {safe_name}: cert_file={cert_file}") + if cert_file and cert_file.exists(): + try: + cert_pem = cert_file.read_bytes() + LOGGER.debug(f"Read certificate file for {safe_name}: {len(cert_pem)} bytes") + if len(cert_pem) == 0: + LOGGER.error(f"Certificate file for {safe_name} is empty!") + cert_hash = compute_cert_hash(cert_pem) + + # Check if cached metadata is still valid + cached_data = get_cached_cert_metadata(service_name) + LOGGER.debug(f"Cached data for {safe_name}: {cached_data is not None}") + if cached_data and cached_data.get("cert_hash") == cert_hash: + cert_info = cached_data.get("metadata", {}) + LOGGER.debug(f"Using cached metadata for {safe_name}") + else: + LOGGER.debug(f"Calling get_cert_info for {safe_name}...") + cert_info = get_cert_info(cert_pem) + LOGGER.debug(f"get_cert_info returned: {cert_info}") + + # Only cache metadata if parsing succeeded (non-empty result) + if cert_info: + cache_cert_metadata(service_name, cert_hash, cert_info) + LOGGER.debug(f"Parsed and cached certificate metadata for {safe_name}") + else: + # Parsing failed: delete any existing cached metadata to force re-parsing next time + delete_cert_metadata(service_name) + LOGGER.error(f"Certificate parsing failed for {safe_name}, deleted cached metadata") + + # Validate certificate CN/SANs match the service domain names + try: + service_name_config = BW_CONFIG.get_config( + global_only=False, methods=False, with_drafts=False + ) + # Get the service's SERVER_NAME (can be multiple space-separated domains) + service_server_name_key = f"{service_name}_SERVER_NAME" + service_domains_str = service_name_config.get(service_server_name_key) or service_name + service_domains = service_domains_str.split() if isinstance(service_domains_str, str) else [service_domains_str] + + # Validate certificate covers all service domains + cert_cn = cert_info.get("cn") + cert_sans = cert_info.get("sans", []) + validation = validate_cert_names(cert_cn, cert_sans, service_domains) + cert_info["cert_validation"] = validation + LOGGER.debug( + f"Certificate validation for {safe_name}: cn={cert_cn}, sans={cert_sans}, " + f"service_domains={service_domains}, valid={validation['valid']}" + ) + except Exception as e: + LOGGER.warning(f"Could not validate certificate names for {safe_name}: {e}") + err_msg = sanitize_cert_field(str(e), max_length=200) if str(e) else "Validation failed" + cert_info["cert_validation"] = { + "valid": None, + "covered_domains": [], + "uncovered_domains": [], + "warnings": [err_msg or "Validation failed"], + } + + cert_info["service_name"] = safe_name + cert_info["cert_file"] = str(cert_file) + cert_info["is_draft"] = is_draft + certs.append(cert_info) + LOGGER.debug(f"Added certificate for {safe_name}") + except Exception as e: + LOGGER.error(f"Error reading certificate for {safe_name}: {e}") + else: + LOGGER.warning(f"USE_CUSTOM_SSL is enabled for {safe_name} but no certificate data found") + certs.append({ + "service_name": safe_name, + "error": "USE_CUSTOM_SSL is enabled but no certificate data found", + "is_draft": is_draft, + }) + + LOGGER.debug(f"CUSTOMCERT page end: {len(certs)} cert(s)") + return render_template("customcert.html", certs=certs, multisite=multisite) diff --git a/src/ui/app/static/locales/ar.json b/src/ui/app/static/locales/ar.json index 582d57c150..e3c06ec09b 100644 --- a/src/ui/app/static/locales/ar.json +++ b/src/ui/app/static/locales/ar.json @@ -42,6 +42,7 @@ "bans": "الحظر", "cache": "الذاكرة المؤقتة", "configs": "التكوينات", + "customcert": "الشهادات المخصصة", "extra_pages": "صفحات إضافية", "global_settings": "الإعدادات العامة", "home": "الرئيسية", @@ -861,6 +862,7 @@ "bans": "الحظر", "cache": "الذاكرة المؤقتة", "configs": "التكوينات", + "customcert": "الشهادات المخصصة", "extra_pages": "صفحات إضافية", "global_settings": "الإعدادات العامة", "home": "الرئيسية", @@ -1219,6 +1221,7 @@ "async": "غير متزامن", "checksum": "Checksum", "configs": "التكوينات", + "customcert": "الشهادات المخصصة", "country": "البلد", "created": "تاريخ الإنشاء", "current_time_left": "الوقت المتبقي الحالي", diff --git a/src/ui/app/static/locales/bn.json b/src/ui/app/static/locales/bn.json index b3f90f5626..7a08c3bbbd 100644 --- a/src/ui/app/static/locales/bn.json +++ b/src/ui/app/static/locales/bn.json @@ -42,6 +42,7 @@ "bans": "নিষেধাজ্ঞা", "cache": "ক্যাশ", "configs": "কনফিগারেশন", + "customcert": "কাস্টম সার্টিফিকেট", "extra_pages": "অতিরিক্ত পৃষ্ঠা", "global_settings": "গ্লোবাল সেটিংস", "home": "হোম", @@ -856,6 +857,7 @@ "bans": "নিষেধাজ্ঞা", "cache": "ক্যাশ", "configs": "কনফিগারেশন", + "customcert": "কাস্টম সার্টিফিকেট", "extra_pages": "অতিরিক্ত পৃষ্ঠা", "global_settings": "গ্লোবাল সেটিংস", "home": "হোম", @@ -1213,6 +1215,7 @@ "async": "অ্যাসিঙ্ক্রোনাস", "checksum": "চেকসাম", "configs": "কনফিগারেশন", + "customcert": "কাস্টম সার্টিফিকেট", "country": "দেশ", "created": "তৈরি করা হয়েছে", "current_time_left": "বর্তমান অবশিষ্ট সময়", @@ -1265,6 +1268,7 @@ "button_remove": "সরান", "clone_badge": "{{template}} থেকে ক্লোন করা হয়েছে", "configs": "কাস্টম কনফিগারেশন", + "customcert": "কাস্টম সার্টিফিকেট", "configs_help": "এই টেমপ্লেটে ঐচ্ছিক কাস্টম কনফিগারেশন স্নিপেট সংযুক্ত করুন।", "delete_step_confirm": "আপনি কি নিশ্চিত যে আপনি এই ধাপটি মুছে ফেলতে চান?", "details": "টেমপ্লেটের বিবরণ", @@ -1667,6 +1671,7 @@ "templates": { "actions": "এই টেমপ্লেটের জন্য উপলব্ধ ক্রিয়াগুলি", "configs": "টেমপ্লেটের কনফিগারেশনের সংখ্যা", + "customcert": "কাস্টম সার্টিফিকেট", "created": "টেমপ্লেট তৈরির তারিখ", "details": "টেমপ্লেটের বিবরণ দেখান", "id": "টেমপ্লেটের আইডি", diff --git a/src/ui/app/static/locales/br.json b/src/ui/app/static/locales/br.json index dc4d345215..d5debb9f61 100644 --- a/src/ui/app/static/locales/br.json +++ b/src/ui/app/static/locales/br.json @@ -42,6 +42,7 @@ "bans": "Bloqueios", "cache": "Cache", "configs": "Configurações", + "customcert": "Certificados Personalizados", "extra_pages": "Páginas Extras", "global_settings": "Configurações Globais", "home": "Início", @@ -856,6 +857,7 @@ "bans": "Bloqueios", "cache": "Cache", "configs": "Configurações", + "customcert": "Certificados Personalizados", "extra_pages": "Páginas Extras", "global_settings": "Configurações Globais", "home": "Início", @@ -1265,6 +1267,7 @@ "button_remove": "Remover", "clone_badge": "Clonado de {{template}}", "configs": "Configurações personalizadas", + "customcert": "Certificados Personalizados", "configs_help": "Anexe trechos de configuração personalizados opcionais a este modelo.", "delete_step_confirm": "Tem certeza de que deseja excluir este passo?", "details": "Detalhes do modelo", diff --git a/src/ui/app/static/locales/de.json b/src/ui/app/static/locales/de.json index ba186db88b..45ec59c12e 100644 --- a/src/ui/app/static/locales/de.json +++ b/src/ui/app/static/locales/de.json @@ -42,6 +42,7 @@ "bans": "Sperren", "cache": "Cache", "configs": "Konfigurationen", + "customcert": "Benutzerdefinierte Zertifikate", "extra_pages": "Zusätzliche Seiten", "global_settings": "Globale Einstellungen", "home": "Startseite", @@ -856,6 +857,7 @@ "bans": "Sperren", "cache": "Cache", "configs": "Konfigurationen", + "customcert": "Benutzerdefinierte Zertifikate", "extra_pages": "Zusätzliche Seiten", "global_settings": "Globale Einstellungen", "home": "Startseite", @@ -1266,6 +1268,7 @@ "button_remove": "Entfernen", "clone_badge": "Geklont von {{template}}", "configs": "Benutzerdefinierte Konfigurationen", + "customcert": "Benutzerdefinierte Zertifikate", "configs_help": "Fügen Sie dieser Vorlage optionale benutzerdefinierte Konfigurationsschnipsel hinzu.", "delete_step_confirm": "Sind Sie sicher, dass Sie diesen Schritt löschen möchten?", "details": "Vorlagendetails", @@ -1671,6 +1674,7 @@ "templates": { "actions": "Verfügbare Aktionen für diese Vorlage", "configs": "Anzahl der benutzerdefinierten Konfigurationen der Vorlage", + "customcert": "Benutzerdefinierte Zertifikate", "created": "Erstellungsdatum der Vorlage", "details": "Vorlagendetails anzeigen", "id": "Die ID der Vorlage", diff --git a/src/ui/app/static/locales/en.json b/src/ui/app/static/locales/en.json index f1bc84199d..870be6e754 100644 --- a/src/ui/app/static/locales/en.json +++ b/src/ui/app/static/locales/en.json @@ -856,6 +856,7 @@ "bans": "Bans", "cache": "Cache", "configs": "Configs", + "customcert": "Custom Certificates", "extra_pages": "Extra Pages", "global_settings": "Global Settings", "home": "Home", diff --git a/src/ui/app/static/locales/es.json b/src/ui/app/static/locales/es.json index 37b12abea8..5345a473e0 100644 --- a/src/ui/app/static/locales/es.json +++ b/src/ui/app/static/locales/es.json @@ -42,6 +42,7 @@ "bans": "Bloqueos", "cache": "Caché", "configs": "Configuraciones", + "customcert": "Certificados Personalizados", "extra_pages": "Páginas Adicionales", "global_settings": "Ajustes globales", "home": "Inicio", @@ -856,6 +857,7 @@ "bans": "Bloqueos", "cache": "Caché", "configs": "Configuraciones", + "customcert": "Certificados Personalizados", "extra_pages": "Páginas Adicionales", "global_settings": "Ajustes globales", "home": "Inicio", @@ -1265,6 +1267,7 @@ "button_remove": "Eliminar", "clone_badge": "Clonado de {{template}}", "configs": "Configuraciones personalizadas", + "customcert": "Certificados Personalizados", "configs_help": "Adjunte fragmentos de configuración personalizados opcionales a esta plantilla.", "delete_step_confirm": "¿Estás seguro de que quieres eliminar este paso?", "details": "Detalles de la plantilla", diff --git a/src/ui/app/static/locales/fr.json b/src/ui/app/static/locales/fr.json index d6bec62296..2811ee87e5 100644 --- a/src/ui/app/static/locales/fr.json +++ b/src/ui/app/static/locales/fr.json @@ -42,6 +42,7 @@ "bans": "Bannissements", "cache": "Cache", "configs": "Configurations", + "customcert": "Certificats Personnalisés", "extra_pages": "Pages Supplémentaires", "global_settings": "Paramètres globaux", "home": "Accueil", @@ -856,6 +857,7 @@ "bans": "Bannissements", "cache": "Cache", "configs": "Configurations", + "customcert": "Certificats Personnalisés", "extra_pages": "Pages Supplémentaires", "global_settings": "Paramètres globaux", "home": "Accueil", @@ -1264,6 +1266,7 @@ "button_remove": "Supprimer", "clone_badge": "Cloné depuis {{template}}", "configs": "Configurations personnalisées", + "customcert": "Certificats Personnalisés", "configs_help": "Joignez des extraits de configuration personnalisés facultatifs à ce modèle.", "delete_step_confirm": "Êtes-vous sûr de vouloir supprimer cette étape ?", "details": "Détails du modèle", diff --git a/src/ui/app/static/locales/hi.json b/src/ui/app/static/locales/hi.json index 703e53a343..e342d2140d 100644 --- a/src/ui/app/static/locales/hi.json +++ b/src/ui/app/static/locales/hi.json @@ -42,6 +42,7 @@ "bans": "प्रतिबंध", "cache": "कैश", "configs": "कॉन्फ़िगरेशन", + "customcert": "कस्टम प्रमाणपत्र", "extra_pages": "अतिरिक्त पृष्ठ", "global_settings": "ग्लोबल सेटिंग्स", "home": "होम", @@ -856,6 +857,7 @@ "bans": "प्रतिबंध", "cache": "कैश", "configs": "कॉन्फ़िगरेशन", + "customcert": "कस्टम प्रमाणपत्र", "extra_pages": "अतिरिक्त पृष्ठ", "global_settings": "ग्लोबल सेटिंग्स", "home": "होम", @@ -1264,6 +1266,7 @@ "button_remove": "हटाएं", "clone_badge": "{{template}} से क्लोन किया गया", "configs": "कस्टम कॉन्फ़िगरेशन", + "customcert": "कस्टम प्रमाणपत्र", "configs_help": "इस टेम्पलेट में वैकल्पिक कस्टम कॉन्फ़िगरेशन स्निपेट संलग्न करें।", "delete_step_confirm": "क्या आप वाकई इस चरण को हटाना चाहते हैं?", "details": "टेम्पलेट विवरण", diff --git a/src/ui/app/static/locales/it.json b/src/ui/app/static/locales/it.json index eb2d1f7655..7a0fda6ee6 100644 --- a/src/ui/app/static/locales/it.json +++ b/src/ui/app/static/locales/it.json @@ -42,6 +42,7 @@ "bans": "Divieti", "cache": "Cache", "configs": "Configurazioni", + "customcert": "Certificati Personalizzati", "extra_pages": "Pagine Extra", "global_settings": "Impostazioni globali", "home": "Home", @@ -856,6 +857,7 @@ "bans": "Divieti", "cache": "Cache", "configs": "Configurazioni", + "customcert": "Certificati Personalizzati", "extra_pages": "Pagine Extra", "global_settings": "Impostazioni globali", "home": "Home", @@ -1266,6 +1268,7 @@ "button_remove": "Rimuovi", "clone_badge": "Clonato da {{template}}", "configs": "Configurazioni personalizzate", + "customcert": "Certificati Personalizzati", "configs_help": "Allega snippet di configurazione personalizzati opzionali a questo modello.", "delete_step_confirm": "Sei sicuro di voler eliminare questo passo?", "details": "Dettagli del modello", diff --git a/src/ui/app/static/locales/ko.json b/src/ui/app/static/locales/ko.json index 6cee0156eb..c8ff57ed4d 100644 --- a/src/ui/app/static/locales/ko.json +++ b/src/ui/app/static/locales/ko.json @@ -42,6 +42,7 @@ "bans": "차단 목록", "cache": "캐시", "configs": "설정", + "customcert": "사용자 정의 인증서", "extra_pages": "추가 페이지", "global_settings": "전역 설정", "home": "홈", @@ -856,6 +857,7 @@ "bans": "차단 목록", "cache": "캐시", "configs": "설정", + "customcert": "사용자 정의 인증서", "extra_pages": "추가 페이지", "global_settings": "전역 설정", "home": "홈", @@ -1212,6 +1214,7 @@ "async": "비동기", "checksum": "체크섬", "configs": "설정", + "customcert": "사용자 정의 인증서", "country": "국가", "created": "생성일", "current_time_left": "남은 시간", @@ -1267,6 +1270,7 @@ "button_remove": "제거", "clone_badge": "{{template}}에서 복제됨", "configs": "사용자 정의 설정", + "customcert": "사용자 정의 인증서", "configs_help": "이 템플릿에 선택적 사용자 정의 설정 코드 조각을 첨부합니다.", "delete_step_confirm": "이 단계를 삭제하시겠습니까?", "details": "템플릿 상세 정보", @@ -1669,6 +1673,7 @@ "templates": { "actions": "이 템플릿에 사용 가능한 작업", "configs": "템플릿 설정 수", + "customcert": "사용자 정의 인증서", "created": "템플릿 생성일", "details": "템플릿 상세 정보 표시", "id": "템플릿 ID", diff --git a/src/ui/app/static/locales/pl.json b/src/ui/app/static/locales/pl.json index ec2b9a8087..7f32069c9f 100644 --- a/src/ui/app/static/locales/pl.json +++ b/src/ui/app/static/locales/pl.json @@ -42,6 +42,7 @@ "bans": "Blokady", "cache": "Pamięć podręczna", "configs": "Konfiguracje", + "customcert": "Niestandardowe Certyfikaty", "extra_pages": "Dodatkowe strony", "global_settings": "Ustawienia globalne", "home": "Strona główna", @@ -856,6 +857,7 @@ "bans": "Blokady", "cache": "Pamięć podręczna", "configs": "Konfiguracje", + "customcert": "Niestandardowe Certyfikaty", "extra_pages": "Dodatkowe strony", "global_settings": "Ustawienia globalne", "home": "Strona główna", @@ -1212,6 +1214,7 @@ "async": "Asymetryczne", "checksum": "Suma kontrolna", "configs": "Konfiguracje", + "customcert": "Niestandardowe Certyfikaty", "country": "Kraj", "created": "Utworzono", "current_time_left": "Aktualny czas pozostały", @@ -1264,6 +1267,7 @@ "button_remove": "Usuń", "clone_badge": "Sklonowano z {{template}}", "configs": "Konfiguracje niestandardowe", + "customcert": "Niestandardowe Certyfikaty", "configs_help": "Dołącz opcjonalne fragmenty konfiguracji niestandardowej do tego szablonu.", "delete_step_confirm": "Czy na pewno chcesz usunąć ten krok?", "details": "Szczegóły szablonu", diff --git a/src/ui/app/static/locales/pt.json b/src/ui/app/static/locales/pt.json index 5bf1556a32..a61a7eb913 100644 --- a/src/ui/app/static/locales/pt.json +++ b/src/ui/app/static/locales/pt.json @@ -42,6 +42,7 @@ "bans": "Bloqueios", "cache": "Cache", "configs": "Configurações", + "customcert": "Certificados Personalizados", "extra_pages": "Páginas Extras", "global_settings": "Configurações Globais", "home": "Início", @@ -856,6 +857,7 @@ "bans": "Bloqueios", "cache": "Cache", "configs": "Configurações", + "customcert": "Certificados Personalizados", "extra_pages": "Páginas Extras", "global_settings": "Configurações Globais", "home": "Início", @@ -1215,6 +1217,7 @@ "async": "Assíncrono", "checksum": "Checksum", "configs": "Configurações", + "customcert": "Certificados Personalizados", "country": "País", "created": "Criado", "current_time_left": "Tempo Restante Atual", @@ -1267,6 +1270,7 @@ "button_remove": "Remover", "clone_badge": "Clonado de {{template}}", "configs": "Configurações personalizadas", + "customcert": "Certificados Personalizados", "configs_help": "Anexe excertos de configuração personalizados opcionais a este modelo.", "delete_step_confirm": "Tem a certeza de que deseja eliminar este passo?", "details": "Detalhes do modelo", diff --git a/src/ui/app/static/locales/ru.json b/src/ui/app/static/locales/ru.json index 37bf31a752..233ad36315 100644 --- a/src/ui/app/static/locales/ru.json +++ b/src/ui/app/static/locales/ru.json @@ -43,6 +43,7 @@ "bans": "Баны", "cache": "Кэш", "configs": "Конфигурации", + "customcert": "Пользовательские сертификаты", "extra_pages": "Дополнительные страницы", "global_settings": "Глобальные настройки", "home": "Главная", @@ -857,6 +858,7 @@ "bans": "Баны", "cache": "Кэш", "configs": "Конфигурации", + "customcert": "Пользовательские сертификаты", "extra_pages": "Дополнительные страницы", "global_settings": "Глобальные настройки", "home": "Главная", diff --git a/src/ui/app/static/locales/tr.json b/src/ui/app/static/locales/tr.json index 4a5ab399d8..5d83e39e05 100644 --- a/src/ui/app/static/locales/tr.json +++ b/src/ui/app/static/locales/tr.json @@ -42,6 +42,7 @@ "bans": "Yasaklamalar", "cache": "Önbellek", "configs": "Yapılandırmalar", + "customcert": "Özel Sertifikalar", "extra_pages": "Ekstra Sayfalar", "global_settings": "Genel Ayarlar", "home": "Ana Sayfa", @@ -856,6 +857,7 @@ "bans": "Yasaklamalar", "cache": "Önbellek", "configs": "Yapılandırmalar", + "customcert": "Özel Sertifikalar", "extra_pages": "Ekstra Sayfalar", "global_settings": "Genel Ayarlar", "home": "Ana Sayfa", @@ -1213,6 +1215,7 @@ "async": "Asenkron", "checksum": "Sağlama", "configs": "Yapılandırmalar", + "customcert": "Özel Sertifikalar", "country": "Ülke", "created": "Oluşturulma", "current_time_left": "Mevcut Kalan Süre", diff --git a/src/ui/app/static/locales/tw.json b/src/ui/app/static/locales/tw.json index a78bdf1f7d..2afa106d03 100644 --- a/src/ui/app/static/locales/tw.json +++ b/src/ui/app/static/locales/tw.json @@ -42,6 +42,7 @@ "bans": "封鎖", "cache": "快取", "configs": "設定", + "customcert": "自訂憑證", "extra_pages": "附加頁面", "global_settings": "全域設定", "home": "首頁", @@ -861,6 +862,7 @@ "bans": "封鎖", "cache": "快取", "configs": "設定", + "customcert": "自訂憑證", "extra_pages": "附加頁面", "global_settings": "全域設定", "home": "首頁", @@ -1219,6 +1221,7 @@ "async": "非同步", "checksum": "校驗和", "configs": "設定檔", + "customcert": "自訂憑證", "country": "國家/地區", "created": "建立時間", "current_time_left": "目前剩餘時間", @@ -1271,6 +1274,7 @@ "button_remove": "移除", "clone_badge": "從 {{template}} 複製", "configs": "自訂設定", + "customcert": "自訂憑證", "configs_help": "為此範本附加選用的自訂設定片段。", "delete_step_confirm": "您確定要刪除此步驟嗎?", "details": "範本詳細資訊", @@ -1676,6 +1680,7 @@ "templates": { "actions": "此範本可用的操作", "configs": "範本的設定檔數量", + "customcert": "自訂憑證", "created": "範本的建立日期", "details": "顯示範本詳情", "id": "範本 ID", diff --git a/src/ui/app/static/locales/ur.json b/src/ui/app/static/locales/ur.json index ec19158517..f2a44db51d 100644 --- a/src/ui/app/static/locales/ur.json +++ b/src/ui/app/static/locales/ur.json @@ -42,6 +42,7 @@ "bans": "پابندیاں", "cache": "کیشے", "configs": "کنفیگریشنز", + "customcert": "کسٹم سرٹیفکیٹ", "extra_pages": "اضافی صفحات", "global_settings": "عالمی سیٹنگز", "home": "مرکزی صفحہ", @@ -856,6 +857,7 @@ "bans": "پابندیاں", "cache": "کیشے", "configs": "کنفیگریشنز", + "customcert": "کسٹم سرٹیفکیٹ", "extra_pages": "اضافی صفحات", "global_settings": "عالمی سیٹنگز", "home": "مرکزی صفحہ", @@ -1213,6 +1215,7 @@ "async": "غیر مطابقت پذیر", "checksum": "چیک سم", "configs": "کنفیگریشنز", + "customcert": "کسٹم سرٹیفکیٹ", "country": "ملک", "created": "بنایا گیا", "current_time_left": "موجودہ باقی وقت", @@ -1265,6 +1268,7 @@ "button_remove": "ہٹائیں", "clone_badge": "{{template}} سے کلون کیا گیا", "configs": "کسٹم کنفیگریشنز", + "customcert": "کسٹم سرٹیفکیٹ", "configs_help": "اس ٹیمپلیٹ میں اختیاری کسٹم کنفیگریشن کے ٹکڑے منسلک کریں۔", "delete_step_confirm": "کیا آپ واقعی اس مرحلے کو حذف کرنا چاہتے ہیں؟", "details": "ٹیمپلیٹ کی تفصیلات", @@ -1667,6 +1671,7 @@ "templates": { "actions": "اس ٹیمپلیٹ کے لیے دستیاب کارروائیاں", "configs": "ٹیمپلیٹ کی کنفیگریشنز کی تعداد", + "customcert": "کسٹم سرٹیفکیٹ", "created": "ٹیمپلیٹ بنانے کی تاریخ", "details": "ٹیمپلیٹ کی تفصیلات دکھائیں", "id": "ٹیمپلیٹ کی شناخت", diff --git a/src/ui/app/static/locales/zh.json b/src/ui/app/static/locales/zh.json index ebbb940979..af3a6ae5ae 100644 --- a/src/ui/app/static/locales/zh.json +++ b/src/ui/app/static/locales/zh.json @@ -42,6 +42,7 @@ "bans": "封禁", "cache": "缓存", "configs": "配置", + "customcert": "自定义证书", "extra_pages": "附加页面", "global_settings": "全局设置", "home": "主页", @@ -856,6 +857,7 @@ "bans": "封禁", "cache": "缓存", "configs": "配置", + "customcert": "自定义证书", "extra_pages": "附加页面", "global_settings": "全局设置", "home": "主页", @@ -1215,6 +1217,7 @@ "async": "异步", "checksum": "校验和", "configs": "配置", + "customcert": "自定义证书", "country": "国家/地区", "created": "创建时间", "current_time_left": "当前剩余时间", @@ -1267,6 +1270,7 @@ "button_remove": "移除", "clone_badge": "克隆自 {{template}}", "configs": "自定义配置", + "customcert": "自定义证书", "configs_help": "为此模板附加可选的自定义配置片段。", "delete_step_confirm": "您确定要删除此步骤吗?", "details": "模板详情", @@ -1670,6 +1674,7 @@ "templates": { "actions": "此模板可用的操作", "configs": "模板的配置数量", + "customcert": "自定义证书", "created": "模板的创建日期", "details": "显示模板详细信息", "id": "模板 ID", diff --git a/src/ui/app/templates/customcert.html b/src/ui/app/templates/customcert.html new file mode 100644 index 0000000000..659b15c410 --- /dev/null +++ b/src/ui/app/templates/customcert.html @@ -0,0 +1,564 @@ +{% extends "dashboard.html" %} + +{% block content %} +
Overview of all custom SSL certificates configured for your services
+| Service Name | +Certificate CN | +Alternative Names | +Issuer | +Key Type | +Validation | +Expires In | +Details | +
|---|---|---|---|---|---|---|---|
| + {{ cert.service_name }} + {% if cert.is_draft is defined and cert.is_draft %} + Draft + {% endif %} + | + + +
+ {% if cert.cn %}
+ {{ cert.cn }}
+ {% else %}
+ —
+ {% endif %}
+ |
+
+
+
+ {% if cert.sans %}
+
+
+
+
+ {% else %}
+ —
+ {% endif %}
+ |
+
+
+
+ {% if cert.issuer %}
+
+ {% set issuer_parts = cert.issuer.split(',') %}
+ {% for part in issuer_parts[:2] %}
+ {{ part.strip() }} + {% endfor %} + + {% else %} + — + {% endif %} + |
+
+
+
+ {% if cert.key_info %}
+ {{ cert.key_info }}
+ {% else %}
+ —
+ {% endif %}
+ |
+
+
+
+ {% if cert.error %}
+ Error
+ {{ cert.error }}
+ {% else %}
+
+
+ {% if cert.cert_validation is defined %}
+ {% if cert.cert_validation.valid == true %}
+
+ Domain Match
+
+ {% elif cert.cert_validation.valid == false %}
+
+ Domain Mismatch
+
+ {% if cert.cert_validation.warnings %}
+ {{ cert.cert_validation.warnings[0] }}
+ {% endif %}
+ {% elif cert.cert_validation.valid == null %}
+ ? Validation Error
+ {% endif %}
+ {% endif %}
+
+
+ {% if cert.server_auth_enabled is defined %}
+ {% if cert.server_auth_enabled %}
+ ServerAuth
+ {% else %}
+
+ No ServerAuth
+
+ {% endif %}
+ {% endif %}
+
+
+ {% if cert.not_yet_valid is defined and cert.not_yet_valid %}
+
+ Not Yet Valid (in {{ "%.1f"|format(cert.not_yet_valid_days) }} days)
+
+ {% endif %}
+
+
+ {% if cert.ocsp_urls is defined and cert.ocsp_urls %}
+
+ OCSP
+
+ {% endif %}
+
+
+ {% if cert.valid %}
+ {% if cert.ttl_days > 30 %}
+ ✓ Valid
+ {% elif cert.ttl_days > 7 %}
+ Expiring Soon
+ {% else %}
+ Expiring
+ {% endif %}
+ {% else %}
+ Expired
+ {% endif %}
+
+ {% endif %}
+ |
+
+
+
+ {% if cert.error %}
+ —
+ {% elif cert.ttl_days is defined %}
+ {% if cert.ttl_days > 0 %}
+ {{ "%.1f"|format(cert.ttl_days) }} days
+ + {{ cert.expires_at[:10] }} + {% else %} + Expired + + {{ cert.expires_at[:10] }} + {% endif %} + {% else %} + — + {% endif %} + |
+
+
+ + {% if not cert.error %} + + {% else %} + — + {% endif %} + | +
No services have custom SSL certificates configured. To use custom certificates:
+Custom certificates allow you to use your own SSL/TLS certificates instead of Let's Encrypt. Each service can have its own certificate, and they are stored securely in the cache directory.
+
+ ✓ Valid - Certificate is valid and not expiring soon (>30 days)
+ Expiring Soon - Certificate expires within 7-30 days
+ Expiring - Certificate expires within 7 days
+ Expired - Certificate has already expired
+ ServerAuth - Server authentication enabled in Extended Key Usage
+ OCSP - OCSP responder URL configured for certificate validation
+ Not Yet Valid - Certificate is not yet valid (future effective date)
+