11# ABOUTME: Domain and Nginx configuration manager for deployment subdomains
2- # ABOUTME: Handles automatic nginx config generation and SSL setup
2+ # ABOUTME: Handles automatic nginx config generation and SSL setup with per-deployment auth
33
44import os
55import logging
@@ -28,18 +28,90 @@ def generate_subdomain(self, deployment_id: str) -> str:
2828 # Simple subdomain: deployment-id.base-domain
2929 return f"{ deployment_id } .{ self .config .base_domain } "
3030
31+ def create_htpasswd_file (self , deployment : Deployment ) -> bool :
32+ """Create htpasswd file for basic auth"""
33+ if not deployment .auth_password :
34+ logger .warning (f"No auth password for deployment { deployment .id } " )
35+ return False
36+
37+ htpasswd_path = Path (f"/etc/nginx/htpasswd-{ deployment .id } " )
38+
39+ try :
40+ # Generate APR1-MD5 hash (nginx compatible)
41+ import crypt
42+ # Use deployment.id as username
43+ username = deployment .id
44+ # Create htpasswd entry
45+ htpasswd_entry = f"{ username } :{ crypt .crypt (deployment .auth_password , crypt .mksalt (crypt .METHOD_MD5 ))} \n "
46+
47+ # Write to temporary file first
48+ with tempfile .NamedTemporaryFile (mode = 'w' , delete = False ) as tmp :
49+ tmp .write (htpasswd_entry )
50+ tmp_path = tmp .name
51+
52+ # Move to nginx directory (requires sudo)
53+ result = run_command (
54+ ["sudo" , "mv" , tmp_path , str (htpasswd_path )]
55+ )
56+
57+ if result .returncode != 0 :
58+ logger .error (f"Failed to save htpasswd file: { result .stderr } " )
59+ if os .path .exists (tmp_path ):
60+ os .unlink (tmp_path )
61+ return False
62+
63+ # Set proper permissions
64+ run_command (["sudo" , "chmod" , "644" , str (htpasswd_path )])
65+
66+ logger .info (f"Created htpasswd file for { deployment .id } " )
67+ return True
68+
69+ except Exception as e :
70+ logger .error (f"Failed to create htpasswd file: { e } " )
71+ return False
72+
3173 def generate_nginx_config (self , deployment : Deployment ) -> str :
32- """Generate Nginx reverse proxy configuration"""
33- # Support both external (test.openspp.org) and internal (openspp-test.internal) domains
74+ """Generate Nginx reverse proxy configuration with auth for external domains only"""
3475 external_domain = deployment .subdomain # e.g., test1.test.openspp.org
3576 internal_domain = f"{ deployment .id } .openspp-test.internal" # e.g., penn-qa-farmer.openspp-test.internal
77+ odoo_port = deployment .port_mappings ['odoo' ]
78+ smtp_port = deployment .port_mappings .get ('smtp' , deployment .port_base + 25 )
79+ pgweb_port = deployment .port_mappings .get ('pgweb' , deployment .port_base + 81 )
80+
81+ # Common proxy configuration
82+ proxy_config = f"""
83+ proxy_pass http://localhost:{ odoo_port } ;
84+ proxy_set_header Host $host;
85+ proxy_set_header X-Real-IP $remote_addr;
86+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
87+ proxy_set_header X-Forwarded-Proto $scheme;
88+ proxy_set_header X-Forwarded-Host $host;
89+ proxy_redirect off;
90+ client_max_body_size 100M;"""
91+
92+ websocket_config = f"""
93+ proxy_pass http://localhost:{ odoo_port } /websocket;
94+ proxy_http_version 1.1;
95+ proxy_set_header Upgrade $http_upgrade;
96+ proxy_set_header Connection "upgrade";
97+ proxy_set_header Host $host;
98+ proxy_set_header X-Real-IP $remote_addr;
99+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
100+ proxy_set_header X-Forwarded-Proto $scheme;
101+ proxy_read_timeout 86400s;
102+ proxy_send_timeout 86400s;"""
36103
37104 config = f"""# Auto-generated Nginx configuration for { deployment .id }
38105# Generated by OpenSPP Deployment Manager
39106
107+ # ===== EXTERNAL DOMAIN WITH AUTHENTICATION =====
40108server {{
41109 listen 80;
42- server_name { external_domain } { internal_domain } ;
110+ server_name { external_domain } ;
111+
112+ # Basic authentication (username: { deployment .id } )
113+ auth_basic "OpenSPP Deployment { deployment .id } ";
114+ auth_basic_user_file /etc/nginx/htpasswd-{ deployment .id } ;
43115
44116 # Security headers
45117 add_header X-Frame-Options "SAMEORIGIN" always;
@@ -51,84 +123,103 @@ def generate_nginx_config(self, deployment: Deployment) -> str:
51123 proxy_send_timeout 600s;
52124 proxy_read_timeout 600s;
53125
54- # Main Odoo application
55- location / {{
56- proxy_pass http://localhost:{ deployment .port_mappings ['odoo' ]} ;
57- proxy_set_header Host $host;
58- proxy_set_header X-Real-IP $remote_addr;
59- proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
60- proxy_set_header X-Forwarded-Proto $scheme;
61- proxy_set_header X-Forwarded-Host $host;
62-
63- # Odoo specific
64- proxy_redirect off;
65-
66- # Max upload size
67- client_max_body_size 100M;
126+ location / {{{ proxy_config }
127+ }}
128+
129+ location /websocket {{{ websocket_config }
68130 }}
69131
70- # Websocket support for live chat and real-time features
71- location /websocket {{
72- proxy_pass http://localhost:{ deployment .port_mappings ['odoo' ]} /websocket;
132+ location /longpolling {{
133+ proxy_pass http://localhost:{ odoo_port } /longpolling;
73134 proxy_http_version 1.1;
74- proxy_set_header Upgrade $http_upgrade;
75- proxy_set_header Connection "upgrade";
76135 proxy_set_header Host $host;
77136 proxy_set_header X-Real-IP $remote_addr;
78137 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
79138 proxy_set_header X-Forwarded-Proto $scheme;
80-
81- # Websocket timeouts
82- proxy_read_timeout 86400s;
83- proxy_send_timeout 86400s;
84139 }}
85140
86- # Longpolling support
141+ access_log /var/log/nginx/{ deployment .id } _ext_access.log;
142+ error_log /var/log/nginx/{ deployment .id } _ext_error.log;
143+ }}
144+
145+ # ===== INTERNAL DOMAIN WITHOUT AUTHENTICATION =====
146+ server {{
147+ listen 80;
148+ server_name { internal_domain } ;
149+
150+ # No authentication for internal access
151+
152+ # Security headers
153+ add_header X-Frame-Options "SAMEORIGIN" always;
154+ add_header X-Content-Type-Options "nosniff" always;
155+ add_header X-XSS-Protection "1; mode=block" always;
156+
157+ # Proxy timeouts
158+ proxy_connect_timeout 600s;
159+ proxy_send_timeout 600s;
160+ proxy_read_timeout 600s;
161+
162+ location / {{{ proxy_config }
163+ }}
164+
165+ location /websocket {{{ websocket_config }
166+ }}
167+
87168 location /longpolling {{
88- proxy_pass http://localhost:{ deployment . port_mappings [ 'odoo' ] } /longpolling;
169+ proxy_pass http://localhost:{ odoo_port } /longpolling;
89170 proxy_http_version 1.1;
90171 proxy_set_header Host $host;
91172 proxy_set_header X-Real-IP $remote_addr;
92173 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
93174 proxy_set_header X-Forwarded-Proto $scheme;
94175 }}
95176
96- # Static files with caching
97- location ~* \.(js|css|png|jpg|jpeg|gif|ico|svg|woff|woff2|ttf|eot)$ {{
98- proxy_pass http://localhost:{ deployment .port_mappings ['odoo' ]} ;
177+ access_log /var/log/nginx/{ deployment .id } _int_access.log;
178+ error_log /var/log/nginx/{ deployment .id } _int_error.log;
179+ }}
180+
181+ # ===== MAILHOG SERVICE =====
182+ server {{
183+ listen 80;
184+ server_name mailhog-{ external_domain } ;
185+
186+ auth_basic "OpenSPP Mailhog { deployment .id } ";
187+ auth_basic_user_file /etc/nginx/htpasswd-{ deployment .id } ;
188+
189+ location / {{
190+ proxy_pass http://localhost:{ smtp_port } ;
99191 proxy_set_header Host $host;
100- proxy_cache_valid 200 90d;
101- proxy_cache_valid 404 1m;
102- expires 30d;
103- add_header Cache-Control "public, immutable";
192+ proxy_set_header X-Real-IP $remote_addr;
193+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
194+ proxy_set_header X-Forwarded-Proto $scheme;
104195 }}
196+ }}
197+
198+ server {{
199+ listen 80;
200+ server_name mailhog-{ deployment .id } .openspp-test.internal;
105201
106- # Health check endpoint
107- location /health {{
108- access_log off;
109- return 200 "healthy\\ n";
110- add_header Content-Type text/plain;
111- }}
202+ # No auth for internal
112203
113- # Deny access to sensitive files
114- location ~ /\. {{
115- deny all;
116- access_log off;
117- log_not_found off;
204+ location / {{
205+ proxy_pass http://localhost:{ smtp_port } ;
206+ proxy_set_header Host $host;
207+ proxy_set_header X-Real-IP $remote_addr;
208+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
209+ proxy_set_header X-Forwarded-Proto $scheme;
118210 }}
119-
120- # Logging
121- access_log /var/log/nginx/{ deployment .id } _access.log;
122- error_log /var/log/nginx/{ deployment .id } _error.log;
123211}}
124212
125- # Additional services on different ports
213+ # ===== PGWEB SERVICE =====
126214server {{
127215 listen 80;
128- server_name mailhog-{ external_domain } mailhog-{ deployment .id } .openspp-test.internal;
216+ server_name pgweb-{ external_domain } ;
217+
218+ auth_basic "OpenSPP PGWeb { deployment .id } ";
219+ auth_basic_user_file /etc/nginx/htpasswd-{ deployment .id } ;
129220
130221 location / {{
131- proxy_pass http://localhost:{ deployment . port_mappings . get ( 'smtp' , deployment . port_base + 25 ) } ;
222+ proxy_pass http://localhost:{ pgweb_port } ;
132223 proxy_set_header Host $host;
133224 proxy_set_header X-Real-IP $remote_addr;
134225 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -138,10 +229,12 @@ def generate_nginx_config(self, deployment: Deployment) -> str:
138229
139230server {{
140231 listen 80;
141- server_name pgweb-{ external_domain } pgweb-{ deployment .id } .openspp-test.internal;
232+ server_name pgweb-{ deployment .id } .openspp-test.internal;
233+
234+ # No auth for internal
142235
143236 location / {{
144- proxy_pass http://localhost:{ deployment . port_mappings . get ( 'pgweb' , deployment . port_base + 81 ) } ;
237+ proxy_pass http://localhost:{ pgweb_port } ;
145238 proxy_set_header Host $host;
146239 proxy_set_header X-Real-IP $remote_addr;
147240 proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
@@ -294,6 +387,11 @@ def reload_nginx(self) -> bool:
294387
295388 def setup_deployment_domain (self , deployment : Deployment ) -> bool :
296389 """Complete domain setup for a deployment"""
390+ # Create htpasswd file for authentication
391+ if deployment .auth_password :
392+ if not self .create_htpasswd_file (deployment ):
393+ logger .warning (f"Failed to create htpasswd file for { deployment .id } , continuing..." )
394+
297395 # Save nginx config
298396 if not self .save_nginx_config (deployment ):
299397 return False
@@ -310,6 +408,15 @@ def setup_deployment_domain(self, deployment: Deployment) -> bool:
310408
311409 def cleanup_deployment_domain (self , deployment_id : str ) -> bool :
312410 """Clean up domain configuration for a deployment"""
411+ # Remove htpasswd file
412+ htpasswd_path = Path (f"/etc/nginx/htpasswd-{ deployment_id } " )
413+ if htpasswd_path .exists ():
414+ try :
415+ run_command (["sudo" , "rm" , str (htpasswd_path )])
416+ logger .info (f"Removed htpasswd file for { deployment_id } " )
417+ except Exception as e :
418+ logger .warning (f"Failed to remove htpasswd file: { e } " )
419+
313420 # Remove nginx config
314421 if not self .remove_nginx_config (deployment_id ):
315422 return False
0 commit comments