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.
This commit is contained in:
@@ -0,0 +1,390 @@
|
||||
#!/usr/bin/env python
|
||||
"""HTTPS management CLI.
|
||||
|
||||
Invoked by ``deploy.sh`` after the containers are healthy:
|
||||
|
||||
python /app/https_manager.py enable <hostname> <domain> <email> <ip> [port]
|
||||
python /app/https_manager.py disable
|
||||
python /app/https_manager.py status
|
||||
python /app/https_manager.py bootstrap # from environment variables
|
||||
|
||||
This is a thin wrapper around the very same code path the Admin UI uses
|
||||
(``HTTPSConfig`` + ``CaddyConfigGenerator``), so a command-line deployment and a
|
||||
UI-driven change always produce an identical Caddyfile.
|
||||
|
||||
Mode selection (delegated to ``CaddyConfigGenerator``):
|
||||
* ``--domain`` given → Let's Encrypt (needs a publicly resolvable name)
|
||||
* IP only, ``--domain`` empty → Caddy **internal CA** (no DNS, no ACME).
|
||||
Correct for intranet servers.
|
||||
* ``--no-https`` → HTTP only.
|
||||
|
||||
Exit codes:
|
||||
0 success
|
||||
1 invalid arguments / configuration error
|
||||
2 configuration applied, but Caddy could not be reloaded
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import argparse
|
||||
import os
|
||||
import ssl
|
||||
import sys
|
||||
import time
|
||||
import urllib.error
|
||||
import urllib.request
|
||||
|
||||
sys.path.insert(0, '/app')
|
||||
|
||||
from app.app import create_app # noqa: E402
|
||||
from app.models.https_config import HTTPSConfig # noqa: E402
|
||||
from app.utils.caddy_manager import CaddyConfigGenerator # noqa: E402
|
||||
from app.utils.logger import log_action # noqa: E402
|
||||
|
||||
CADDYFILE_PATH = '/etc/caddy/Caddyfile'
|
||||
|
||||
|
||||
def _truthy(value: str | None, default: bool = True) -> bool:
|
||||
"""Interpret a string env var as a boolean."""
|
||||
if value is None or value == '':
|
||||
return default
|
||||
return value.strip().lower() in ('1', 'true', 'yes', 'on')
|
||||
|
||||
|
||||
def verify_https(hostname: str, port: int = 443, timeout: float = 6.0,
|
||||
attempts: int = 3) -> tuple[bool, str]:
|
||||
"""Check that HTTPS actually answers on *hostname*:*port*.
|
||||
|
||||
The certificate is deliberately **not** validated: for an intranet name we
|
||||
expect Caddy's internal CA, which is not in this container's trust store.
|
||||
What matters is that the TLS listener is up and serving.
|
||||
|
||||
Retries a few times because Caddy may still be obtaining a certificate.
|
||||
|
||||
Args:
|
||||
hostname: Name or IP to connect to (e.g. the host IP or domain).
|
||||
port: TLS port.
|
||||
timeout: Per-attempt timeout in seconds.
|
||||
attempts: Number of attempts before giving up.
|
||||
|
||||
Returns:
|
||||
``(ok, detail)`` — ``detail`` is a human-readable reason.
|
||||
"""
|
||||
ctx = ssl.create_default_context()
|
||||
ctx.check_hostname = False
|
||||
ctx.verify_mode = ssl.CERT_NONE
|
||||
|
||||
last = 'no attempt made'
|
||||
for attempt in range(1, attempts + 1):
|
||||
url = f"https://{hostname}:{port}/api/health"
|
||||
try:
|
||||
req = urllib.request.Request(url, method='GET')
|
||||
with urllib.request.urlopen(req, timeout=timeout, context=ctx) as r:
|
||||
if r.status < 500:
|
||||
return True, f'HTTP {r.status} from {url}'
|
||||
last = f'HTTP {r.status} from {url}'
|
||||
except urllib.error.HTTPError as e:
|
||||
# An HTTP error still means TLS terminated successfully.
|
||||
if e.code < 500:
|
||||
return True, f'HTTP {e.code} from {url}'
|
||||
last = f'HTTP {e.code} from {url}'
|
||||
except Exception as e: # noqa: BLE001
|
||||
last = f'{type(e).__name__}: {e}'
|
||||
|
||||
if attempt < attempts:
|
||||
time.sleep(2)
|
||||
|
||||
return False, last
|
||||
|
||||
|
||||
def bootstrap_from_env(app) -> int:
|
||||
"""Configure HTTPS from environment variables at container startup.
|
||||
|
||||
Reads ``HOSTNAME_INTERNAL`` and ``HOST_IP``. Both must be set for HTTPS to
|
||||
be provisioned; when either is missing the app deliberately stays on the
|
||||
plain-HTTP fallback so it is always reachable, and HTTPS can be enabled
|
||||
later from Admin → HTTPS Configuration (which reloads Caddy live).
|
||||
|
||||
Ownership / precedence
|
||||
----------------------
|
||||
The admin UI is the ongoing source of truth. To avoid the environment
|
||||
silently overwriting an admin's change on every restart, the bootstrap only
|
||||
applies while the environment still *owns* the configuration:
|
||||
|
||||
* No config yet → apply from env (first deploy).
|
||||
* Last written by the env bootstrap → apply from env (env still owns it,
|
||||
so changing ``HOST_IP`` and redeploying works).
|
||||
* Last written by a real user → SKIP; the admin's settings are kept.
|
||||
|
||||
Provenance is taken from ``HTTPSConfig.updated_by`` (``'deploy.sh'`` marks an
|
||||
env/CLI write), so no schema change is needed.
|
||||
|
||||
Idempotent: re-running with an unchanged environment rewrites the same
|
||||
Caddyfile and reloads Caddy, which is harmless.
|
||||
|
||||
Returns one of the standard exit codes (0/1/2).
|
||||
"""
|
||||
hostname = (os.getenv('HOSTNAME_INTERNAL') or '').strip()
|
||||
ip_address = (os.getenv('HOST_IP') or '').strip()
|
||||
|
||||
if not hostname or not ip_address:
|
||||
missing = [n for n, v in (('HOSTNAME_INTERNAL', hostname),
|
||||
('HOST_IP', ip_address)) if not v]
|
||||
print(f'[https] {" and ".join(missing)} not set — staying on the '
|
||||
f'plain-HTTP fallback.')
|
||||
print('[https] Enable HTTPS later from Admin → HTTPS Configuration.')
|
||||
return 0
|
||||
|
||||
# Respect an explicit admin change: only seed while the env owns the config.
|
||||
with app.app_context():
|
||||
existing = HTTPSConfig.get_config()
|
||||
if existing is not None and existing.updated_by not in (None, '', 'deploy.sh'):
|
||||
print(f'[https] HTTPS already configured by "{existing.updated_by}" — '
|
||||
f'environment bootstrap skipped so the admin setting is kept.')
|
||||
return 0
|
||||
|
||||
domain = (os.getenv('DOMAIN') or '').strip()
|
||||
email = (os.getenv('SSL_EMAIL') or '').strip() or None
|
||||
port = int(os.getenv('HTTPS_PORT') or 443)
|
||||
http_fallback = _truthy(os.getenv('HTTPS_HTTP_FALLBACK'), default=True)
|
||||
# HTTS_VERIFY lets an operator skip the post-reload probe (e.g. when the
|
||||
# server is not reachable from inside the container, or in CI).
|
||||
do_verify = _truthy(os.getenv('HTTPS_VERIFY'), default=True)
|
||||
|
||||
print(f'[https] Bootstrapping from environment: host={hostname!r} ip={ip_address} '
|
||||
f'domain={domain!r} port={port} http_fallback={http_fallback}')
|
||||
|
||||
return _apply(app, https_enabled=True, hostname=hostname, domain=domain,
|
||||
email=email, ip_address=ip_address, port=port,
|
||||
http_fallback=http_fallback, verify=do_verify)
|
||||
|
||||
|
||||
def _apply(app, https_enabled: bool, hostname: str | None, domain: str | None,
|
||||
email: str | None, ip_address: str | None, port: int,
|
||||
http_fallback: bool, verify: bool = True) -> int:
|
||||
"""Persist the config, regenerate the Caddyfile and hot-reload Caddy.
|
||||
|
||||
When *verify* is set and HTTPS is being enabled, the TLS listener is probed
|
||||
after the reload. If it does not come up, the configuration is automatically
|
||||
reverted to plain HTTP so the server is never left unreachable — HTTPS then
|
||||
falls back to HTTP exactly as intended, and can be retried from the admin UI.
|
||||
"""
|
||||
with app.app_context():
|
||||
# An empty domain is meaningful: it selects the internal-CA path.
|
||||
config = HTTPSConfig.create_or_update(
|
||||
https_enabled=https_enabled,
|
||||
hostname=hostname,
|
||||
domain=domain or None,
|
||||
ip_address=ip_address,
|
||||
email=email,
|
||||
port=port,
|
||||
updated_by='deploy.sh',
|
||||
)
|
||||
|
||||
mode = ('HTTP only' if not https_enabled
|
||||
else 'Let\'s Encrypt' if config.domain
|
||||
else 'internal CA')
|
||||
|
||||
print(f' Mode: {mode}')
|
||||
print(f' Hostname: {config.hostname or "-"}')
|
||||
print(f' Domain: {config.domain or "(none)"}')
|
||||
print(f' IP address: {config.ip_address or "-"}')
|
||||
print(f' Email: {config.email or "-"}')
|
||||
print(f' HTTPS port: {config.port}')
|
||||
|
||||
caddyfile = CaddyConfigGenerator.generate_caddyfile(
|
||||
config, http_fallback=http_fallback,
|
||||
http_port=int(os.getenv('HTTP_PORT') or 80),
|
||||
https_port=int(os.getenv('HTTPS_PORT') or port or 443))
|
||||
|
||||
if not CaddyConfigGenerator.write_caddyfile(caddyfile):
|
||||
print(' ✗ Failed to write the Caddyfile', file=sys.stderr)
|
||||
return 1
|
||||
print(' ✓ Caddyfile written')
|
||||
|
||||
if not CaddyConfigGenerator.reload_caddy():
|
||||
print(' ⚠ Caddyfile written but Caddy reload failed — restart the '
|
||||
'caddy container to apply.', file=sys.stderr)
|
||||
log_action('warning', 'Caddy reload failed during CLI HTTPS setup')
|
||||
if not verify:
|
||||
return 2
|
||||
reload_ok = False
|
||||
else:
|
||||
print(' ✓ Caddy reloaded')
|
||||
reload_ok = True
|
||||
|
||||
# ── Verify the TLS listener, then fall back to HTTP if it failed ────
|
||||
if verify and https_enabled and reload_ok:
|
||||
probe_host = config.ip_address or config.hostname or domain
|
||||
# Probe whichever port the host actually publishes.
|
||||
probe_port = int(os.getenv('HTTPS_PORT') or config.port or 443)
|
||||
print(f' … Verifying HTTPS on {probe_host}:{probe_port}')
|
||||
ok, detail = verify_https(probe_host, probe_port)
|
||||
if ok:
|
||||
print(f' ✓ HTTPS verified ({detail})')
|
||||
log_action('info', f'HTTPS configured and verified (mode={mode})')
|
||||
return 0
|
||||
|
||||
print(f' ✗ HTTPS verification failed: {detail}', file=sys.stderr)
|
||||
print(' ↩ Falling back to plain HTTP so the server stays reachable.',
|
||||
file=sys.stderr)
|
||||
log_action('warning', f'HTTPS verification failed ({detail}); '
|
||||
f'fell back to HTTP')
|
||||
|
||||
# Revert to HTTP-only and re-apply so port 80 keeps serving.
|
||||
HTTPSConfig.create_or_update(
|
||||
https_enabled=False,
|
||||
hostname=config.hostname,
|
||||
domain=None,
|
||||
ip_address=config.ip_address,
|
||||
email=config.email,
|
||||
port=config.port,
|
||||
updated_by='deploy.sh',
|
||||
)
|
||||
http_only = CaddyConfigGenerator.generate_caddyfile(
|
||||
HTTPSConfig.get_config())
|
||||
CaddyConfigGenerator.write_caddyfile(http_only)
|
||||
CaddyConfigGenerator.reload_caddy()
|
||||
return 1
|
||||
|
||||
if verify and https_enabled and not reload_ok:
|
||||
log_action('warning', 'HTTPS configured but Caddy reload failed')
|
||||
return 2
|
||||
|
||||
log_action('info', f'HTTPS configured via CLI (mode={mode})')
|
||||
return 0
|
||||
|
||||
|
||||
def _status(app) -> int:
|
||||
with app.app_context():
|
||||
config = HTTPSConfig.get_config()
|
||||
if not config:
|
||||
print(' No HTTPS configuration found (HTTP-only defaults).')
|
||||
return 0
|
||||
|
||||
if not config.https_enabled:
|
||||
mode = 'HTTP only'
|
||||
elif config.domain:
|
||||
mode = "Let's Encrypt"
|
||||
else:
|
||||
mode = 'internal CA'
|
||||
|
||||
print(f' Enabled: {config.https_enabled}')
|
||||
print(f' Mode: {mode}')
|
||||
print(f' Hostname: {config.hostname or "-"}')
|
||||
print(f' Domain: {config.domain or "(none)"}')
|
||||
print(f' IP address: {config.ip_address or "-"}')
|
||||
print(f' Email: {config.email or "-"}')
|
||||
print(f' HTTPS port: {config.port}')
|
||||
print(f' Updated by: {config.updated_by or "-"}')
|
||||
if config.updated_at:
|
||||
print(f' Updated at: {config.updated_at.isoformat()}')
|
||||
return 0
|
||||
|
||||
|
||||
def _verify_current(app) -> int:
|
||||
"""Probe the currently configured HTTPS endpoint; fall back to HTTP if down.
|
||||
|
||||
Useful as a post-deploy check and as a self-healing step after a restart
|
||||
(e.g. certificate issuance failed, or the request path changed).
|
||||
"""
|
||||
with app.app_context():
|
||||
config = HTTPSConfig.get_config()
|
||||
if not config or not config.https_enabled:
|
||||
print(' HTTPS is not enabled — nothing to verify.')
|
||||
return 0
|
||||
|
||||
probe_host = config.ip_address or config.hostname or config.domain
|
||||
probe_port = int(os.getenv('HTTPS_PORT') or config.port or 443)
|
||||
print(f' Probing https://{probe_host}:{probe_port} …')
|
||||
|
||||
ok, detail = verify_https(probe_host, probe_port)
|
||||
if ok:
|
||||
print(f' ✓ HTTPS is working ({detail})')
|
||||
return 0
|
||||
|
||||
print(f' ✗ HTTPS is NOT working: {detail}', file=sys.stderr)
|
||||
print(' ↩ Falling back to plain HTTP so the server stays reachable.',
|
||||
file=sys.stderr)
|
||||
log_action('warning', f'HTTPS verify failed ({detail}); fell back to HTTP')
|
||||
|
||||
HTTPSConfig.create_or_update(
|
||||
https_enabled=False,
|
||||
hostname=config.hostname,
|
||||
domain=None,
|
||||
ip_address=config.ip_address,
|
||||
email=config.email,
|
||||
port=config.port,
|
||||
updated_by='deploy.sh',
|
||||
)
|
||||
http_only = CaddyConfigGenerator.generate_caddyfile(HTTPSConfig.get_config())
|
||||
CaddyConfigGenerator.write_caddyfile(http_only)
|
||||
CaddyConfigGenerator.reload_caddy()
|
||||
return 1
|
||||
|
||||
|
||||
def main() -> int:
|
||||
parser = argparse.ArgumentParser(description='DigiServer HTTPS manager')
|
||||
sub = parser.add_subparsers(dest='command', required=True)
|
||||
|
||||
p_enable = sub.add_parser('enable', help='enable HTTPS and reload Caddy')
|
||||
p_enable.add_argument('hostname', nargs='?', default=None)
|
||||
p_enable.add_argument('domain', nargs='?', default=None,
|
||||
help='Public domain; leave empty for internal CA')
|
||||
p_enable.add_argument('email', nargs='?', default=None)
|
||||
p_enable.add_argument('ip_address', nargs='?', default=None)
|
||||
p_enable.add_argument('port', nargs='?', type=int, default=443)
|
||||
p_enable.add_argument('--redirect-only', action='store_true',
|
||||
help='Do not serve plain HTTP alongside HTTPS; '
|
||||
'redirect to HTTPS instead')
|
||||
p_enable.add_argument('--no-https', action='store_true',
|
||||
help='Configure HTTP only (no TLS)')
|
||||
p_enable.add_argument('--no-verify', action='store_true',
|
||||
help='Skip the post-reload HTTPS probe (no auto-fallback)')
|
||||
|
||||
sub.add_parser('disable', help='disable HTTPS (HTTP only)')
|
||||
sub.add_parser('status', help='print the current configuration')
|
||||
sub.add_parser(
|
||||
'verify',
|
||||
help='probe the HTTPS endpoint and fall back to HTTP if it is broken')
|
||||
sub.add_parser(
|
||||
'bootstrap',
|
||||
help='configure HTTPS from HOSTNAME_INTERNAL/HOST_IP env vars '
|
||||
'(no-op when unset)')
|
||||
|
||||
args = parser.parse_args()
|
||||
app = create_app()
|
||||
|
||||
if args.command == 'status':
|
||||
return _status(app)
|
||||
|
||||
if args.command == 'bootstrap':
|
||||
return bootstrap_from_env(app)
|
||||
|
||||
if args.command == 'verify':
|
||||
return _verify_current(app)
|
||||
|
||||
if args.command == 'disable':
|
||||
return _apply(app, https_enabled=False, hostname=None, domain=None,
|
||||
email=None, ip_address=None, port=443,
|
||||
http_fallback=True)
|
||||
|
||||
# enable
|
||||
if args.no_https:
|
||||
return _apply(app, https_enabled=False, hostname=args.hostname,
|
||||
domain=None, email=args.email,
|
||||
ip_address=args.ip_address, port=args.port,
|
||||
http_fallback=True)
|
||||
|
||||
if not args.ip_address:
|
||||
print('error: ip_address is required to enable HTTPS', file=sys.stderr)
|
||||
return 1
|
||||
|
||||
return _apply(app, https_enabled=True, hostname=args.hostname,
|
||||
domain=args.domain, email=args.email,
|
||||
ip_address=args.ip_address, port=args.port,
|
||||
http_fallback=not args.redirect_only,
|
||||
verify=not args.no_verify)
|
||||
|
||||
|
||||
if __name__ == '__main__':
|
||||
sys.exit(main())
|
||||
Reference in New Issue
Block a user