Skip to content

Commit 63df1db

Browse files
committed
feat: add per-deployment authentication for external domains
- Added auth_password field to Deployment model - Generate 16-character random passwords on deployment creation - Create htpasswd files for nginx basic authentication - Separate nginx server blocks for external (with auth) and internal (no auth) domains - Display credentials in UI (both deployment card and management view) - Clean up htpasswd files on deployment removal Security improvements: - External domains (*.test.openspp.org) require basic auth - Internal domains (*.openspp-test.internal) remain auth-free for convenience - Each deployment has unique credentials (username: deployment-id, password: random) - Credentials are displayed in the management UI for easy access
1 parent 52eece4 commit 63df1db

File tree

4 files changed

+199
-58
lines changed

4 files changed

+199
-58
lines changed

app.py

Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -218,6 +218,18 @@ def show_deployment_card(deployment, col_actions):
218218
with st.expander("📝 Notes"):
219219
st.text(deployment.notes)
220220

221+
# Show auth credentials if available
222+
config = load_config()
223+
if config.nginx_enabled and deployment.auth_password:
224+
with st.expander("🔐 Credentials"):
225+
col1, col2 = st.columns(2)
226+
with col1:
227+
st.text("Username:")
228+
st.code(deployment.id)
229+
with col2:
230+
st.text("Password:")
231+
st.code(deployment.auth_password)
232+
221233
st.divider()
222234

223235
def show_create_deployment_form():
@@ -482,6 +494,19 @@ def show_deployment_management(deployment_id):
482494
with col2:
483495
st.code(url)
484496

497+
# Display authentication credentials for external access
498+
if config.nginx_enabled and deployment.auth_password:
499+
st.markdown("### 🔐 Authentication Credentials")
500+
st.info(f"External domains (*.test.openspp.org) require authentication")
501+
col1, col2 = st.columns(2)
502+
with col1:
503+
st.text("Username:")
504+
st.code(deployment.id)
505+
with col2:
506+
st.text("Password:")
507+
st.code(deployment.auth_password)
508+
st.caption("💡 Internal domains (*.openspp-test.internal) do not require authentication")
509+
485510
st.divider()
486511

487512
# Create tabs for different management sections

src/deployment_manager.py

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -78,6 +78,12 @@ def create_deployment(self, params: DeploymentParams, progress_callback=None) ->
7878
# Get port mappings
7979
port_mappings = get_port_mappings(port_base)
8080

81+
# Generate random password for nginx auth
82+
import secrets
83+
import string
84+
alphabet = string.ascii_letters + string.digits
85+
auth_password = ''.join(secrets.choice(alphabet) for _ in range(16))
86+
8187
# Create deployment object
8288
deployment = Deployment(
8389
id=deployment_id,
@@ -90,7 +96,8 @@ def create_deployment(self, params: DeploymentParams, progress_callback=None) ->
9096
port_base=port_base,
9197
port_mappings=port_mappings,
9298
subdomain=self._generate_subdomain(deployment_id),
93-
notes=params.notes
99+
notes=params.notes,
100+
auth_password=auth_password
94101
)
95102

96103
# Save initial deployment record

src/domain_manager.py

Lines changed: 163 additions & 56 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,5 @@
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

44
import os
55
import 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 =====
40108
server {{
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 =====
126214
server {{
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
139230
server {{
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

src/models.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -34,6 +34,7 @@ class Deployment:
3434
modules_installed: List[str] = field(default_factory=list) # Installed modules
3535
last_action: str = "" # Last executed action
3636
notes: str = "" # Tester notes
37+
auth_password: str = "" # Random password for nginx basic auth
3738

3839
def to_dict(self) -> dict:
3940
"""Convert deployment to dictionary for JSON serialization"""
@@ -52,7 +53,8 @@ def to_dict(self) -> dict:
5253
"subdomain": self.subdomain,
5354
"modules_installed": self.modules_installed,
5455
"last_action": self.last_action,
55-
"notes": self.notes
56+
"notes": self.notes,
57+
"auth_password": self.auth_password
5658
}
5759

5860
@classmethod

0 commit comments

Comments
 (0)