Coverage for frappe_manager / ssl_manager / external_domain_manager.py: 29%
87 statements
« prev ^ index » next coverage.py v7.13.5, created at 2026-07-02 18:35 +0530
« prev ^ index » next coverage.py v7.13.5, created at 2026-07-02 18:35 +0530
1"""
2External Domain Configuration Manager
4Manages SSL certificates for external (non-bench) Docker projects that use
5FM's nginx-proxy. Stores configurations in external_domains.toml.
7This module allows Frappe Manager to provide SSL management for any Docker
8project, not just Frappe benches, by tracking external domain configurations
9separately from bench configurations.
11Example external_domains.toml structure:
12 [domains.myapp_example_com]
13 domain = "myapp.example.com"
14 ssl_type = "letsencrypt"
15 added_at = "2026-01-14T12:00:00"
16 challenge_type = "http01"
17 acme_client = "acme.sh"
18"""
20from dataclasses import dataclass
21from pathlib import Path
23import tomlkit
25from frappe_manager.ssl_manager import LETSENCRYPT_PREFERRED_CHALLENGE, SUPPORTED_SSL_TYPES
26from frappe_manager.ssl_manager.certificate import SSLCertificate
27from frappe_manager.ssl_manager.letsencrypt_certificate import CustomDomainCertificate, LetsencryptSSLCertificate
30@dataclass
31class ExternalDomainConfig:
32 """
33 Configuration for an external domain SSL certificate.
35 Attributes:
36 domain: The domain name (e.g., "myapp.example.com")
37 ssl_type: Certificate type (always "letsencrypt" for now)
38 added_at: ISO 8601 timestamp when certificate was added
39 challenge_type: Challenge type ("http01" or "dns01")
40 delegation_cname: Optional CNAME for DNS-01 delegation
41 acme_client: ACME client to use (currently only "acme.sh" is supported)
42 """
44 domain: str
45 ssl_type: str
46 added_at: str
47 challenge_type: str # Changed from preferred_challenge to challenge_type
48 delegation_cname: str | None = None
49 acme_client: str = "acme.sh"
52class ExternalDomainConfigManager:
53 """
54 Manages external domain SSL configurations stored in external_domains.toml.
56 This manager handles SSL certificate configurations for domains not associated
57 with Frappe benches, enabling FM's SSL management to work with any Docker project
58 that uses FM's nginx-proxy (via fm-global-frontend-network).
60 Storage location: <services_path>/nginx-proxy/external_domains.toml
62 Usage:
63 manager = ExternalDomainConfigManager(config_path)
64 manager.add_domain(ExternalDomainConfig(...))
65 domains = manager.list_domains()
66 manager.remove_domain("example.com")
67 """
69 def __init__(self, config_path: Path):
70 """
71 Initialize the external domain config manager.
73 Args:
74 config_path: Path to external_domains.toml file
75 """
76 self.config_path = config_path
77 self.config_path.parent.mkdir(parents=True, exist_ok=True)
79 # Create empty config if doesn't exist
80 if not self.config_path.exists():
81 self._save({})
83 def _load(self) -> dict[str, ExternalDomainConfig]:
84 """
85 Load all external domains from TOML file.
87 Returns:
88 Dictionary mapping domain names to ExternalDomainConfig objects
89 """
90 if not self.config_path.exists():
91 return {}
93 try:
94 data = tomlkit.parse(self.config_path.read_text())
95 except Exception:
96 # If file is corrupted or empty, return empty dict
97 return {}
99 domains = {}
101 for key, value in data.get("domains", {}).items():
102 try:
103 # Backward compatibility: rename preferred_challenge to challenge_type
104 if "preferred_challenge" in value and "challenge_type" not in value:
105 value["challenge_type"] = value.pop("preferred_challenge")
107 # Backward compatibility: remove email field if present (discontinued June 2025)
108 value.pop("email", None)
110 domains[value["domain"]] = ExternalDomainConfig(**value)
111 except (KeyError, TypeError):
112 # Skip invalid entries
113 continue
115 return domains
117 def _save(self, domains: dict[str, ExternalDomainConfig]):
118 """
119 Save all external domains to TOML file.
121 Args:
122 domains: Dictionary mapping domain names to ExternalDomainConfig objects
123 """
124 doc = tomlkit.document()
125 domains_table = tomlkit.table()
127 for domain, config in domains.items():
128 # Normalize domain to valid TOML key (replace dots/hyphens with underscores)
129 safe_key = domain.replace(".", "_").replace("-", "_")
131 domain_table = tomlkit.table()
132 domain_table["domain"] = config.domain
133 domain_table["ssl_type"] = config.ssl_type
134 # Email field removed - Let's Encrypt discontinued notifications (June 2025)
135 domain_table["added_at"] = config.added_at
136 domain_table["challenge_type"] = config.challenge_type
137 domain_table["acme_client"] = config.acme_client
139 if config.delegation_cname:
140 domain_table["delegation_cname"] = config.delegation_cname
142 domains_table[safe_key] = domain_table
144 doc["domains"] = domains_table
146 with open(self.config_path, "w") as f:
147 f.write(tomlkit.dumps(doc))
149 def add_domain(self, config: ExternalDomainConfig):
150 """
151 Add a new external domain configuration.
153 Args:
154 config: External domain configuration to add
156 Raises:
157 ValueError: If domain already exists in external domains
158 """
159 domains = self._load()
161 if config.domain in domains:
162 raise ValueError(f"Domain {config.domain} already exists in external domains")
164 domains[config.domain] = config
165 self._save(domains)
167 def remove_domain(self, domain: str) -> bool:
168 """
169 Remove an external domain configuration.
171 Args:
172 domain: Domain name to remove
174 Returns:
175 True if domain was removed, False if domain was not found
176 """
177 domains = self._load()
179 if domain not in domains:
180 return False
182 del domains[domain]
183 self._save(domains)
184 return True
186 def get_domain(self, domain: str) -> ExternalDomainConfig | None:
187 """
188 Get configuration for a specific domain.
190 Args:
191 domain: Domain name to retrieve
193 Returns:
194 ExternalDomainConfig or None if domain not found
195 """
196 domains = self._load()
197 return domains.get(domain)
199 def list_domains(self) -> list[ExternalDomainConfig]:
200 """
201 List all external domain configurations.
203 Returns:
204 List of all external domain configurations sorted by domain name
205 """
206 domains = self._load()
207 return sorted(domains.values(), key=lambda d: d.domain)
209 def domain_exists(self, domain: str) -> bool:
210 """
211 Check if a domain exists in external domains.
213 Args:
214 domain: Domain name to check
216 Returns:
217 True if domain exists, False otherwise
218 """
219 return domain in self._load()
221 def to_ssl_certificate(self, domain: str) -> SSLCertificate | None:
222 """
223 Convert external domain config to SSLCertificate object.
225 This method creates an SSLCertificate object from the stored configuration,
226 which can then be used with SSLCertificateManager for certificate operations.
228 Args:
229 domain: Domain name
231 Returns:
232 LetsencryptSSLCertificate or CustomDomainCertificate object, or None if domain not found
233 """
234 config = self.get_domain(domain)
235 if not config:
236 return None
238 # Parse challenge type
239 if config.challenge_type == "dns01":
240 challenge_type = LETSENCRYPT_PREFERRED_CHALLENGE.dns01
241 else:
242 challenge_type = LETSENCRYPT_PREFERRED_CHALLENGE.http01
244 # Create certificate object with CNAME delegation if present
245 if config.delegation_cname:
246 return CustomDomainCertificate(
247 domain=config.domain,
248 ssl_type=SUPPORTED_SSL_TYPES.le,
249 # Email removed - Let's Encrypt discontinued notifications (June 2025)
250 # Cloudflare credentials loaded from FM config at runtime
251 api_token=None,
252 api_key=None,
253 challenge_type=challenge_type,
254 delegation_cname=config.delegation_cname,
255 acme_client=config.acme_client,
256 )
257 return LetsencryptSSLCertificate(
258 domain=config.domain,
259 ssl_type=SUPPORTED_SSL_TYPES.le,
260 # Email removed - Let's Encrypt discontinued notifications (June 2025)
261 # Cloudflare credentials loaded from FM config at runtime
262 api_token=None,
263 api_key=None,
264 challenge_type=challenge_type,
265 acme_client=config.acme_client,
266 )
268 def get_count(self) -> int:
269 """
270 Get the total number of external domains configured.
272 Returns:
273 Number of external domains
274 """
275 return len(self._load())