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
« 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.
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"""
11from pathlib import Path
14class StandaloneNginxConfigManager:
15 """
16 Manages nginx server block configurations for external (standalone) domains.
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.
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
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 """
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
38server {{
39 server_name {domain};
40 listen 80;
41 access_log /var/log/nginx/access.log;
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 }}
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"""
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
62server {{
63 server_name {domain};
64 listen 80;
65 access_log /var/log/nginx/access.log;
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 }}
73 # Redirect all other HTTP traffic to HTTPS
74 location / {{
75 return 301 https://$host$request_uri;
76 }}
77}}
79server {{
80 server_name {domain};
81 listen 443 ssl;
82 http2 on;
83 access_log /var/log/nginx/access.log;
85 ssl_session_timeout 5m;
86 ssl_session_cache shared:SSL:50m;
87 ssl_session_tickets off;
89 ssl_certificate {certs_dir}/{domain}.crt;
90 ssl_certificate_key {certs_dir}/{domain}.key;
92 # Serve ACME challenge files (for renewals)
93 location ^~ /.well-known/acme-challenge/ {{
94 default_type "text/plain";
95 root {webroot_dir};
96 }}
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"""
106 def __init__(self, conf_dir: Path, webroot_dir_container: str, certs_dir_container: str):
107 """
108 Initialize the standalone nginx config manager.
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
119 # Ensure config directory exists
120 self.conf_dir.mkdir(parents=True, exist_ok=True)
122 def create_http_config(self, domain: str) -> Path:
123 """
124 Create HTTP-only nginx config for a standalone domain.
126 This is used during initial certificate generation to serve ACME challenges.
127 After certificate is generated, call create_https_config() to enable SSL.
129 Args:
130 domain: Domain name
132 Returns:
133 Path to created config file
134 """
135 config_file = self.conf_dir / f"{domain}.conf"
137 config_content = self.HTTP_SERVER_TEMPLATE.format(
138 domain=domain,
139 webroot_dir=self.webroot_dir,
140 )
142 config_file.write_text(config_content)
143 return config_file
145 def create_https_config(self, domain: str) -> Path:
146 """
147 Create HTTPS nginx config for a standalone domain.
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.
152 Args:
153 domain: Domain name
155 Returns:
156 Path to created config file
157 """
158 config_file = self.conf_dir / f"{domain}.conf"
160 config_content = self.HTTPS_SERVER_TEMPLATE.format(
161 domain=domain,
162 webroot_dir=self.webroot_dir,
163 certs_dir=self.certs_dir,
164 )
166 config_file.write_text(config_content)
167 return config_file
169 def remove_config(self, domain: str) -> bool:
170 """
171 Remove nginx config for a standalone domain.
173 Args:
174 domain: Domain name
176 Returns:
177 True if config was removed, False if it didn't exist
178 """
179 config_file = self.conf_dir / f"{domain}.conf"
181 if config_file.exists():
182 config_file.unlink()
183 return True
184 return False
186 def config_exists(self, domain: str) -> bool:
187 """
188 Check if a config file exists for a domain.
190 Args:
191 domain: Domain name
193 Returns:
194 True if config exists, False otherwise
195 """
196 config_file = self.conf_dir / f"{domain}.conf"
197 return config_file.exists()
199 def get_config_path(self, domain: str) -> Path:
200 """
201 Get the path to the config file for a domain.
203 Args:
204 domain: Domain name
206 Returns:
207 Path to the config file (may not exist)
208 """
209 return self.conf_dir / f"{domain}.conf"