Files
digiserver-v2/app/utils/caddy_manager.py
T
ske087 46602f1933 Sanitize codebase, reorganize docs, and add missing deploy files
Remove dead code identified in docs/SANITIZATION-REVIEW.md:
- app/blueprints/content_old.py and app/blueprints/playlist.py
- app/models/group.py, app/utils/nginx_config_reader.py
- orphaned templates (content_list, edit_content, upload_content,
  player_page) and the related group/Template references

Result: 6 blueprints, 82 routes, no dead modules or orphan templates.

Add files that deploy.sh and docker-entrypoint.sh already require but
which were never tracked:
- https_manager.py       (referenced by deploy.sh, migrate_network.sh,
                          docker-entrypoint.sh)
- Caddyfile.example      (seeded by deploy.sh; its absence aborts deploy)

Relocate generated Graphify artifacts from graphify-out/ to
docs/graphify-out/ (110 files, no content change) and archive the
superseded docs under docs/.

Ignore hygiene:
- ignore ad-hoc .env backups (.env.bak*) — they contain live secrets
- keep the pre-sanitization snapshots (docs/legacy code/,
  docs/old_code_documentation/) on disk but out of the repo

Fix .env.example: drop a duplicated config block, genericize the
hardcoded host IP, and document HOSTNAME_INTERNAL.
2026-09-11 12:18:34 +03:00

192 lines
7.9 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,
http_fallback: bool = True,
http_port: int = 80,
https_port: int = 443) -> str:
"""Generate a complete Caddyfile.
Design goals
------------
* **One HTTP endpoint** (port 80) that always answers, whatever the Host
header is — so ``http://<ip>`` and ``http://<hostname>`` both work.
* **HTTPS on port 443** for the same names when it is enabled.
* If HTTPS is disabled or never configured, port 80 simply serves the
app — there is no separate "HTTP mode" to configure.
Behaviour by configuration
--------------------------
* HTTPS off, or no address configured → plain HTTP on ``:http_port``.
* HTTPS on → the app is served on port 80 for every configured name and
on port 443 over TLS. Whether port 80 *serves* or *redirects* to
HTTPS is controlled by ``http_fallback``.
Which certificate each name gets
--------------------------------
* ``domain`` (when set) → Caddy obtains a certificate automatically
(Let's Encrypt/ACME). Only valid for a **publicly resolvable** name.
* ``ip_address`` / ``hostname`` → ``tls internal`` (Caddy's local CA).
This needs no public DNS and no ACME, which is the right choice for an
intranet name such as ``digiserver.sibiusb.harting.intra``.
Args:
config: HTTPSConfig instance, or None to load from the database.
http_fallback: When True, port 80 keeps *serving* the app alongside
HTTPS. This is the resilient default: clients that cannot trust
the internal CA (e.g. a Kivy player with ``verify_ssl: true``)
are still able to connect. When False, port 80 issues a 301
redirect to HTTPS instead.
http_port: Port Caddy listens on for plain HTTP (default 80).
https_port: Port used to build redirect targets when
``http_fallback`` is False (default 443).
Returns:
The complete Caddyfile as a string.
"""
if config is None:
config = HTTPSConfig.get_config()
email = (config.email or "admin@localhost") if config else "admin@localhost"
https_enabled = bool(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 ""
hostname = (config.hostname or "").strip() if config else ""
# Every name the server should answer to, in priority order, without
# duplicates. The IP comes first because it always resolves.
names: list[str] = []
for candidate in (ip_address, hostname, domain):
if candidate and candidate not in names:
names.append(candidate)
global_block = f"{{\n admin 0.0.0.0:2019\n email {email}\n"
# ── TLS with no SNI ────────────────────────────────────────────────
# Browsers do NOT send SNI when the URL is an IP address (an IP is not
# a valid SNI hostname). Without a fallback Caddy would identify such a
# connection by the container's own internal IP, match no certificate
# and abort the handshake with:
# "no certificate available for '<container-ip>'"
# `default_sni` makes a SNI-less ClientHello resolve to a name we do
# serve, so https://<ip> works in the browser.
if https_enabled and not domain and ip_address:
global_block += f" default_sni {ip_address}\n"
global_block += "}\n\n"
# ── Plain HTTP only: HTTPS disabled, or no address to certify ───────
if not (https_enabled and names):
return global_block + f":{http_port} {{\n{_PROXY_SNIPPET}}}\n"
caddyfile = global_block
# ── Port 80: catch-all so ANY Host header is answered ──────────────
# Without this, a request for an unexpected name (e.g. a bare IP when
# only a hostname is configured) would hit no site block and fail.
caddyfile += f":{http_port} {{\n{_PROXY_SNIPPET}}}\n\n"
# ── Port 80: explicit per-name blocks ──────────────────────────────
for name in names:
if http_fallback:
caddyfile += f"http://{name} {{\n{_PROXY_SNIPPET}}}\n\n"
else:
# Redirect to the port the host actually publishes.
https_url = (f"https://{name}" if https_port == 443
else f"https://{name}:{https_port}")
caddyfile += f"http://{name} {{\n redir {https_url}{{uri}} 301\n}}\n\n"
# ── Port 443: TLS listeners ────────────────────────────────────────
for name in names:
if domain and name == domain:
# Public name → let Caddy obtain a real certificate.
caddyfile += f"https://{name} {{\n{_PROXY_SNIPPET}}}\n\n"
else:
# IP or intranet name → Caddy's internal CA.
caddyfile += (f"https://{name} {{\n tls internal\n"
f"{_PROXY_SNIPPET}}}\n\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)}")