2af04e1db3
- Content model: add url column + is_weblink() for web page content items - Player model: add deployment tracking fields (status, timestamps, message) - Content blueprint: add add_weblink and add_weblink_to_playlist routes; weblinks auto-deleted when removed from playlist - Players blueprint: add SSH deploy mode in add_player; weblink-aware playlist - API blueprint: weblink URL served directly in playlist response; add /api/deploy/test-ssh and /api/deploy/player endpoints - Admin blueprint: add build-player page (clone from Gitea, write base config); replace nginx status card with Caddy status on HTTPS config page - caddy_manager: rewritten to generate proper HTTPS/internal-CA Caddyfiles and reload Caddy via admin API (/load) for live config updates - ssh_deploy, background_tasks, player_build: new utils for SSH deployment - background_tasks: push Flask app context into background thread so DB updates after deployment complete correctly - ssh_deploy: robust install script detection with passwordless sudo injection (uses SSH credentials, cleaned up after install) - Dockerfile: add git, sshpass, openssh-client, rsync - docker-compose: switch nginx to Caddy on ports 80/443; add port 5000 for dev - Templates: add_player deploy mode UI, weblink form in playlist/upload pages, build_player admin page, Caddy status on HTTPS config page - Migrations: add_url_to_content, add_deployment_fields_to_player - app.py: call db.create_all() on startup for schema bootstrap - config.py: add PLAYER_CODE_DIR and PLAYER_REPO_URL settings
269 lines
8.4 KiB
Python
269 lines
8.4 KiB
Python
"""Caddy configuration generator and manager."""
|
|
import os
|
|
from typing import Optional
|
|
from app.models.https_config import HTTPSConfig
|
|
|
|
# Shared reverse-proxy snippet used in every Caddy site block
|
|
_PROXY_SNIPPET = """\
|
|
reverse_proxy digiserver-app:5000 {
|
|
header_up Host {host}
|
|
header_up X-Real-IP {remote_host}
|
|
header_up X-Forwarded-Proto {scheme}
|
|
transport http {
|
|
read_timeout 300s
|
|
write_timeout 300s
|
|
}
|
|
}
|
|
|
|
request_body {
|
|
max_size 2GB
|
|
}
|
|
|
|
encode gzip
|
|
|
|
header {
|
|
X-Frame-Options "SAMEORIGIN"
|
|
X-Content-Type-Options "nosniff"
|
|
X-XSS-Protection "1; mode=block"
|
|
}
|
|
|
|
log {
|
|
output file /var/log/caddy/access.log
|
|
}
|
|
"""
|
|
|
|
|
|
class CaddyConfigGenerator:
|
|
"""Generate Caddyfile configuration based on HTTPSConfig."""
|
|
|
|
@staticmethod
|
|
def generate_caddyfile(config: Optional['HTTPSConfig'] = None) -> str:
|
|
"""Generate a complete Caddyfile.
|
|
|
|
Behaviour:
|
|
- HTTPS disabled / no domain → HTTP-only on port 80 (initial deploy mode).
|
|
- HTTPS enabled + real domain → Caddy auto-provisions a Let's Encrypt cert
|
|
for that domain; HTTP redirects to HTTPS automatically.
|
|
- HTTPS enabled + IP only (no domain) → TLS with Caddy's internal CA
|
|
(self-signed, trusted within the Docker network).
|
|
"""
|
|
if config is None:
|
|
config = HTTPSConfig.get_config()
|
|
|
|
email = (config.email or "admin@localhost") if config else "admin@localhost"
|
|
https_enabled = config.https_enabled if config else False
|
|
domain = (config.domain or "").strip() if config else ""
|
|
ip_address = (config.ip_address or "").strip() if config else ""
|
|
|
|
global_block = f"""{{\n admin 0.0.0.0:2019\n email {email}\n}}\n\n"""
|
|
|
|
if https_enabled and domain:
|
|
# Caddy handles Let's Encrypt + HTTP→HTTPS redirect automatically
|
|
# when a plain hostname (no scheme) is used.
|
|
caddyfile = global_block
|
|
caddyfile += f"{domain} {{\n{_PROXY_SNIPPET}}}\n"
|
|
# Also accept requests on the raw IP (HTTP only, no cert needed)
|
|
if ip_address:
|
|
caddyfile += f"\nhttp://{ip_address} {{\n{_PROXY_SNIPPET}}}\n"
|
|
elif https_enabled and ip_address:
|
|
# No public domain — use Caddy's internal CA (self-signed)
|
|
caddyfile = global_block
|
|
caddyfile += f"https://{ip_address} {{\n tls internal\n{_PROXY_SNIPPET}}}\n"
|
|
caddyfile += f"\nhttp://{ip_address} {{\n redir https://{ip_address}{{uri}} 301\n}}\n"
|
|
else:
|
|
# HTTP-only fallback (first deploy, before HTTPS is configured)
|
|
caddyfile = "{\n admin 0.0.0.0:2019\n}\n\n"
|
|
caddyfile += f":80 {{\n{_PROXY_SNIPPET}}}\n"
|
|
|
|
return caddyfile
|
|
|
|
@staticmethod
|
|
def write_caddyfile(caddyfile_content: str,
|
|
path: str = '/etc/caddy/Caddyfile') -> bool:
|
|
"""Write Caddyfile to disk.
|
|
|
|
The default path is /etc/caddy/Caddyfile — the standard location inside
|
|
the caddy:2-alpine container when a volume is mounted there.
|
|
"""
|
|
try:
|
|
os.makedirs(os.path.dirname(path), exist_ok=True)
|
|
with open(path, 'w') as f:
|
|
f.write(caddyfile_content)
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error writing Caddyfile: {str(e)}")
|
|
return False
|
|
|
|
@staticmethod
|
|
def reload_caddy() -> bool:
|
|
"""Push the current Caddyfile to Caddy via its admin API (/load).
|
|
|
|
Caddy applies the new config live without dropping connections.
|
|
"""
|
|
try:
|
|
import urllib.request
|
|
|
|
caddyfile_path = '/etc/caddy/Caddyfile'
|
|
if not os.path.exists(caddyfile_path):
|
|
print(f"Caddyfile not found at {caddyfile_path}")
|
|
return False
|
|
|
|
with open(caddyfile_path, 'rb') as f:
|
|
caddyfile_bytes = f.read()
|
|
|
|
req = urllib.request.Request(
|
|
'http://caddy:2019/load',
|
|
data=caddyfile_bytes,
|
|
headers={'Content-Type': 'text/caddyfile'},
|
|
method='POST',
|
|
)
|
|
response = urllib.request.urlopen(req, timeout=10)
|
|
return response.status == 200
|
|
except Exception as e:
|
|
print(f"Caddy reload error: {str(e)}")
|
|
return False
|
|
|
|
"""Generate complete Caddyfile content.
|
|
|
|
Args:
|
|
config: HTTPSConfig instance or None
|
|
|
|
Returns:
|
|
Complete Caddyfile content as string
|
|
"""
|
|
# Get config from database if not provided
|
|
if config is None:
|
|
config = HTTPSConfig.get_config()
|
|
|
|
# Base configuration
|
|
email = "admin@localhost"
|
|
if config and config.email:
|
|
email = config.email
|
|
|
|
base_config = f"""{{
|
|
# Global options
|
|
email {email}
|
|
# Admin API for configuration management (listen on all interfaces)
|
|
admin 0.0.0.0:2019
|
|
# Uncomment for testing to avoid rate limits
|
|
# acme_ca https://acme-staging-v02.api.letsencrypt.org/directory
|
|
}}
|
|
|
|
# Shared reverse proxy configuration
|
|
(reverse_proxy_config) {{
|
|
reverse_proxy digiserver-app:5000 {{
|
|
header_up Host {{host}}
|
|
header_up X-Real-IP {{remote_host}}
|
|
header_up X-Forwarded-Proto {{scheme}}
|
|
|
|
# Timeouts for large uploads
|
|
transport http {{
|
|
read_timeout 300s
|
|
write_timeout 300s
|
|
}}
|
|
}}
|
|
|
|
# File upload size limit (2GB)
|
|
request_body {{
|
|
max_size 2GB
|
|
}}
|
|
|
|
# Security headers
|
|
header {{
|
|
X-Frame-Options "SAMEORIGIN"
|
|
X-Content-Type-Options "nosniff"
|
|
X-XSS-Protection "1; mode=block"
|
|
}}
|
|
|
|
# Logging
|
|
log {{
|
|
output file /var/log/caddy/access.log
|
|
}}
|
|
}}
|
|
|
|
# Localhost (development/local access)
|
|
http://localhost {{
|
|
import reverse_proxy_config
|
|
}}
|
|
"""
|
|
|
|
# Add main domain/IP configuration if HTTPS is enabled
|
|
if config and config.https_enabled and config.domain and config.ip_address:
|
|
# Internal domain configuration
|
|
domain_config = f"""
|
|
# Internal domain (HTTP only - internal use)
|
|
http://{config.domain} {{
|
|
import reverse_proxy_config
|
|
}}
|
|
|
|
# Handle IP address access
|
|
http://{config.ip_address} {{
|
|
import reverse_proxy_config
|
|
}}
|
|
"""
|
|
base_config += domain_config
|
|
else:
|
|
# Default fallback configuration
|
|
base_config += """
|
|
# Internal domain (HTTP only - internal use)
|
|
http://digiserver.sibiusb.harting.intra {
|
|
import reverse_proxy_config
|
|
}
|
|
|
|
# Handle IP address access
|
|
http://10.76.152.164 {
|
|
import reverse_proxy_config
|
|
}
|
|
"""
|
|
|
|
# Add catch-all for any other HTTP requests
|
|
base_config += """
|
|
# Catch-all for any other HTTP requests
|
|
http://* {
|
|
import reverse_proxy_config
|
|
}
|
|
"""
|
|
|
|
return base_config
|
|
|
|
@staticmethod
|
|
def write_caddyfile(caddyfile_content: str, path: str = '/app/Caddyfile') -> bool:
|
|
"""Write Caddyfile to disk.
|
|
|
|
Args:
|
|
caddyfile_content: Content to write
|
|
path: Path to Caddyfile
|
|
|
|
Returns:
|
|
True if successful, False otherwise
|
|
"""
|
|
try:
|
|
with open(path, 'w') as f:
|
|
f.write(caddyfile_content)
|
|
return True
|
|
except Exception as e:
|
|
print(f"Error writing Caddyfile: {str(e)}")
|
|
return False
|
|
|
|
@staticmethod
|
|
def reload_caddy() -> bool:
|
|
"""Reload Caddy configuration without restart.
|
|
|
|
Note: Caddy monitoring is handled via file watching. After writing the Caddyfile,
|
|
Caddy should automatically reload. If it doesn't, you may need to restart the
|
|
Caddy container manually.
|
|
|
|
Returns:
|
|
True if configuration was written successfully (Caddy will auto-reload)
|
|
"""
|
|
try:
|
|
# Just verify that Caddy is reachable
|
|
import urllib.request
|
|
response = urllib.request.urlopen('http://caddy:2019/config/', timeout=2)
|
|
return response.status == 200
|
|
except Exception as e:
|
|
# Caddy might not be reachable, but Caddyfile was already written
|
|
# Caddy should reload automatically when it detects file changes
|
|
print(f"Note: Caddy reload check returned: {str(e)}")
|
|
return True # Return True anyway since Caddyfile was written
|