Coverage for frappe_manager / ssl_manager / standalone_nginx_config_manager.py: 33%

30 statements  

« prev     ^ index     » next       coverage.py v7.13.5, created at 2026-07-02 18:35 +0530

1""" 

2Manages standalone nginx server blocks for external domains without backends. 

3 

4This module creates static nginx configurations for domains that use FM's SSL 

5management but don't have Frappe benches as backends. It handles: 

6- Creating server blocks for HTTP-01 ACME challenges 

7- Serving .well-known/acme-challenge directory 

8- Providing placeholder responses until backend is connected 

9""" 

10 

11from pathlib import Path 

12 

13 

14class StandaloneNginxConfigManager: 

15 """ 

16 Manages nginx server block configurations for external (standalone) domains. 

17 

18 External domains are those that use FM's SSL infrastructure but don't have 

19 Frappe benches. Since nginx-proxy only generates configs for containers with 

20 VIRTUAL_HOST, we need to manually create server blocks for these domains. 

21 

22 This enables: 

23 - HTTP-01 ACME challenge support (serves .well-known/acme-challenge) 

24 - SSL certificate installation without requiring a backend container 

25 - Graceful handling when backend isn't connected yet 

26 

27 Attributes: 

28 conf_dir: Directory where standalone nginx configs are stored 

29 webroot_dir: Path to webroot for ACME challenges (container path) 

30 certs_dir: Path to SSL certificates directory (container path) 

31 """ 

32 

33 # Template for standalone domain server block (HTTP only, for ACME challenges) 

34 HTTP_SERVER_TEMPLATE = """# Standalone domain: {domain} 

35# Managed by Frappe Manager 

36# This configuration allows HTTP-01 ACME challenge for SSL certificate generation 

37 

38server {{ 

39 server_name {domain}; 

40 listen 80; 

41 access_log /var/log/nginx/access.log; 

42  

43 # Serve ACME challenge files for Let's Encrypt validation 

44 location ^~ /.well-known/acme-challenge/ {{ 

45 default_type "text/plain"; 

46 root {webroot_dir}; 

47 }} 

48  

49 # Default response for all other requests 

50 location / {{ 

51 return 503 '<html><head><title>503 Backend Not Connected</title></head><body><h1>503 Backend Not Connected</h1><p>This domain is managed by Frappe Manager but no backend service is configured.</p><p>To connect your Docker service:</p><ol><li>Add to your docker-compose.yml:</li><pre>services:\n your-app:\n environment:\n VIRTUAL_HOST: {domain}\n VIRTUAL_PORT: 80\n networks:\n - fm-global-frontend-network\n\nnetworks:\n fm-global-frontend-network:\n external: true</pre><li>Start your service: <code>docker compose up -d</code></li></ol></body></html>'; 

52 add_header Content-Type text/html; 

53 }} 

54}} 

55""" 

56 

57 # Template for standalone domain with SSL 

58 HTTPS_SERVER_TEMPLATE = """# Standalone domain: {domain} 

59# Managed by Frappe Manager 

60# This configuration provides SSL termination without requiring a backend 

61 

62server {{ 

63 server_name {domain}; 

64 listen 80; 

65 access_log /var/log/nginx/access.log; 

66  

67 # Serve ACME challenge files for Let's Encrypt validation 

68 location ^~ /.well-known/acme-challenge/ {{ 

69 default_type "text/plain"; 

70 root {webroot_dir}; 

71 }} 

72  

73 # Redirect all other HTTP traffic to HTTPS 

74 location / {{ 

75 return 301 https://$host$request_uri; 

76 }} 

77}} 

78 

79server {{ 

80 server_name {domain}; 

81 listen 443 ssl; 

82 http2 on; 

83 access_log /var/log/nginx/access.log; 

84  

85 ssl_session_timeout 5m; 

86 ssl_session_cache shared:SSL:50m; 

87 ssl_session_tickets off; 

88  

89 ssl_certificate {certs_dir}/{domain}.crt; 

90 ssl_certificate_key {certs_dir}/{domain}.key; 

91  

92 # Serve ACME challenge files (for renewals) 

93 location ^~ /.well-known/acme-challenge/ {{ 

94 default_type "text/plain"; 

95 root {webroot_dir}; 

96 }} 

97  

98 # Default response for all other requests 

99 location / {{ 

100 return 503 '<html><head><title>503 Backend Not Connected</title></head><body><h1>503 Backend Not Connected</h1><p>This domain has a valid SSL certificate but no backend service is configured.</p><p>To connect your Docker service:</p><ol><li>Add to your docker-compose.yml:</li><pre>services:\n your-app:\n environment:\n VIRTUAL_HOST: {domain}\n VIRTUAL_PORT: 80\n networks:\n - fm-global-frontend-network\n\nnetworks:\n fm-global-frontend-network:\n external: true</pre><li>Start your service: <code>docker compose up -d</code></li><li>Access at: https://{domain}</li></ol></body></html>'; 

101 add_header Content-Type text/html; 

102 }} 

103}} 

104""" 

105 

106 def __init__(self, conf_dir: Path, webroot_dir_container: str, certs_dir_container: str): 

107 """ 

108 Initialize the standalone nginx config manager. 

109 

110 Args: 

111 conf_dir: Directory to store standalone nginx configs (host filesystem) 

112 webroot_dir_container: Path to webroot in container (/usr/share/nginx/html) 

113 certs_dir_container: Path to certs in container (/etc/nginx/certs) 

114 """ 

115 self.conf_dir = conf_dir 

116 self.webroot_dir = webroot_dir_container 

117 self.certs_dir = certs_dir_container 

118 

119 # Ensure config directory exists 

120 self.conf_dir.mkdir(parents=True, exist_ok=True) 

121 

122 def create_http_config(self, domain: str) -> Path: 

123 """ 

124 Create HTTP-only nginx config for a standalone domain. 

125 

126 This is used during initial certificate generation to serve ACME challenges. 

127 After certificate is generated, call create_https_config() to enable SSL. 

128 

129 Args: 

130 domain: Domain name 

131 

132 Returns: 

133 Path to created config file 

134 """ 

135 config_file = self.conf_dir / f"{domain}.conf" 

136 

137 config_content = self.HTTP_SERVER_TEMPLATE.format( 

138 domain=domain, 

139 webroot_dir=self.webroot_dir, 

140 ) 

141 

142 config_file.write_text(config_content) 

143 return config_file 

144 

145 def create_https_config(self, domain: str) -> Path: 

146 """ 

147 Create HTTPS nginx config for a standalone domain. 

148 

149 This should be called after the SSL certificate is successfully generated. 

150 It creates a full server block with SSL termination and HTTP→HTTPS redirect. 

151 

152 Args: 

153 domain: Domain name 

154 

155 Returns: 

156 Path to created config file 

157 """ 

158 config_file = self.conf_dir / f"{domain}.conf" 

159 

160 config_content = self.HTTPS_SERVER_TEMPLATE.format( 

161 domain=domain, 

162 webroot_dir=self.webroot_dir, 

163 certs_dir=self.certs_dir, 

164 ) 

165 

166 config_file.write_text(config_content) 

167 return config_file 

168 

169 def remove_config(self, domain: str) -> bool: 

170 """ 

171 Remove nginx config for a standalone domain. 

172 

173 Args: 

174 domain: Domain name 

175 

176 Returns: 

177 True if config was removed, False if it didn't exist 

178 """ 

179 config_file = self.conf_dir / f"{domain}.conf" 

180 

181 if config_file.exists(): 

182 config_file.unlink() 

183 return True 

184 return False 

185 

186 def config_exists(self, domain: str) -> bool: 

187 """ 

188 Check if a config file exists for a domain. 

189 

190 Args: 

191 domain: Domain name 

192 

193 Returns: 

194 True if config exists, False otherwise 

195 """ 

196 config_file = self.conf_dir / f"{domain}.conf" 

197 return config_file.exists() 

198 

199 def get_config_path(self, domain: str) -> Path: 

200 """ 

201 Get the path to the config file for a domain. 

202 

203 Args: 

204 domain: Domain name 

205 

206 Returns: 

207 Path to the config file (may not exist) 

208 """ 

209 return self.conf_dir / f"{domain}.conf"