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

1""" 

2External Domain Configuration Manager 

3 

4Manages SSL certificates for external (non-bench) Docker projects that use 

5FM's nginx-proxy. Stores configurations in external_domains.toml. 

6 

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. 

10 

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""" 

19 

20from dataclasses import dataclass 

21from pathlib import Path 

22 

23import tomlkit 

24 

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 

28 

29 

30@dataclass 

31class ExternalDomainConfig: 

32 """ 

33 Configuration for an external domain SSL certificate. 

34 

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 """ 

43 

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" 

50 

51 

52class ExternalDomainConfigManager: 

53 """ 

54 Manages external domain SSL configurations stored in external_domains.toml. 

55 

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). 

59 

60 Storage location: <services_path>/nginx-proxy/external_domains.toml 

61 

62 Usage: 

63 manager = ExternalDomainConfigManager(config_path) 

64 manager.add_domain(ExternalDomainConfig(...)) 

65 domains = manager.list_domains() 

66 manager.remove_domain("example.com") 

67 """ 

68 

69 def __init__(self, config_path: Path): 

70 """ 

71 Initialize the external domain config manager. 

72 

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) 

78 

79 # Create empty config if doesn't exist 

80 if not self.config_path.exists(): 

81 self._save({}) 

82 

83 def _load(self) -> dict[str, ExternalDomainConfig]: 

84 """ 

85 Load all external domains from TOML file. 

86 

87 Returns: 

88 Dictionary mapping domain names to ExternalDomainConfig objects 

89 """ 

90 if not self.config_path.exists(): 

91 return {} 

92 

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 {} 

98 

99 domains = {} 

100 

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") 

106 

107 # Backward compatibility: remove email field if present (discontinued June 2025) 

108 value.pop("email", None) 

109 

110 domains[value["domain"]] = ExternalDomainConfig(**value) 

111 except (KeyError, TypeError): 

112 # Skip invalid entries 

113 continue 

114 

115 return domains 

116 

117 def _save(self, domains: dict[str, ExternalDomainConfig]): 

118 """ 

119 Save all external domains to TOML file. 

120 

121 Args: 

122 domains: Dictionary mapping domain names to ExternalDomainConfig objects 

123 """ 

124 doc = tomlkit.document() 

125 domains_table = tomlkit.table() 

126 

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("-", "_") 

130 

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 

138 

139 if config.delegation_cname: 

140 domain_table["delegation_cname"] = config.delegation_cname 

141 

142 domains_table[safe_key] = domain_table 

143 

144 doc["domains"] = domains_table 

145 

146 with open(self.config_path, "w") as f: 

147 f.write(tomlkit.dumps(doc)) 

148 

149 def add_domain(self, config: ExternalDomainConfig): 

150 """ 

151 Add a new external domain configuration. 

152 

153 Args: 

154 config: External domain configuration to add 

155 

156 Raises: 

157 ValueError: If domain already exists in external domains 

158 """ 

159 domains = self._load() 

160 

161 if config.domain in domains: 

162 raise ValueError(f"Domain {config.domain} already exists in external domains") 

163 

164 domains[config.domain] = config 

165 self._save(domains) 

166 

167 def remove_domain(self, domain: str) -> bool: 

168 """ 

169 Remove an external domain configuration. 

170 

171 Args: 

172 domain: Domain name to remove 

173 

174 Returns: 

175 True if domain was removed, False if domain was not found 

176 """ 

177 domains = self._load() 

178 

179 if domain not in domains: 

180 return False 

181 

182 del domains[domain] 

183 self._save(domains) 

184 return True 

185 

186 def get_domain(self, domain: str) -> ExternalDomainConfig | None: 

187 """ 

188 Get configuration for a specific domain. 

189 

190 Args: 

191 domain: Domain name to retrieve 

192 

193 Returns: 

194 ExternalDomainConfig or None if domain not found 

195 """ 

196 domains = self._load() 

197 return domains.get(domain) 

198 

199 def list_domains(self) -> list[ExternalDomainConfig]: 

200 """ 

201 List all external domain configurations. 

202 

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) 

208 

209 def domain_exists(self, domain: str) -> bool: 

210 """ 

211 Check if a domain exists in external domains. 

212 

213 Args: 

214 domain: Domain name to check 

215 

216 Returns: 

217 True if domain exists, False otherwise 

218 """ 

219 return domain in self._load() 

220 

221 def to_ssl_certificate(self, domain: str) -> SSLCertificate | None: 

222 """ 

223 Convert external domain config to SSLCertificate object. 

224 

225 This method creates an SSLCertificate object from the stored configuration, 

226 which can then be used with SSLCertificateManager for certificate operations. 

227 

228 Args: 

229 domain: Domain name 

230 

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 

237 

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 

243 

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 ) 

267 

268 def get_count(self) -> int: 

269 """ 

270 Get the total number of external domains configured. 

271 

272 Returns: 

273 Number of external domains 

274 """ 

275 return len(self._load())