Files
ServerKit/backend/app/api/proxy.py
T
Juan DenisandClaude Opus 4.8 0e71ab346a feat: make the ingress-plane boundary explicit (host Nginx vs proxy stack)
Both reverse proxies bind 80/443, so pointing the same domain at host Nginx and
a Dockerized proxy stack is an operational footgun. This tags each app with the
plane it expects and surfaces disagreements instead of leaving them implicit.

- Each app carries an `ingress_plane` ('nginx' | 'proxy_stack'); NULL reads as
  the host-Nginx default. New pure helpers in app/utils/ingress.py: only
  container-based services are proxy-stack eligible, and a requested proxy_stack
  is forced back to nginx for PHP/WordPress/static/Python apps. to_dict() also
  exposes `ingress_proxy_eligible`.
- ProxyStackService.ingress_audit(server) reports which of a server's apps
  disagree with its active proxy mode; fleet_overview now includes per-server
  app_count + mismatch_count. New GET /servers/<id>/proxy/ingress-audit.
- Create wizard offers the proxy stack only for Docker/Compose services and
  keeps the default on Nginx; app detail shows an Ingress badge; the fleet proxy
  dashboard flags mismatch counts; the server proxy panel warns and lists the
  specific mismatched apps with reasons.

Migration 035 adds applications.ingress_plane. Backend suite green (incl. 8 new
ingress tests); frontend builds clean.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-23 20:57:40 -04:00

174 lines
5.5 KiB
Python

"""Managed reverse-proxy stack API.
Per-server endpoints for the opt-in Dockerized proxy (Traefik/Caddy). Host
nginx remains the default; these routes let an operator switch a server to a
managed proxy stack, preview the generated compose, and (best-effort)
regenerate/deploy it.
Mounted by the app factory at ``/api/v1/servers`` so paths are
``/servers/<server_id>/proxy*``. Reads require auth; mutations require
developer access (matching servers.py group/server mutations).
"""
from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required
from app.models.server import Server
from app.middleware.rbac import developer_required
from app.services.proxy_stack_service import ProxyStackService
proxy_bp = Blueprint('proxy', __name__)
def _require_server(server_id):
"""Return (server, None) or (None, error_response_tuple)."""
server = Server.query.get(server_id)
if not server:
return None, (jsonify({'error': 'Server not found'}), 404)
return server, None
@proxy_bp.route('/proxy/overview', methods=['GET'])
@jwt_required()
def proxy_overview():
"""Fleet-wide proxy posture: one row per server.
Mounted under ``/api/v1/servers`` → full path ``/api/v1/servers/proxy/
overview``. The static ``proxy`` segment can't match the ``<server_id>``
converter on ``/<server_id>/proxy``, and Werkzeug prefers static rules over
dynamic ones, so the two never collide. Best-effort; always returns JSON.
"""
return jsonify({'servers': ProxyStackService.fleet_overview()})
@proxy_bp.route('/<server_id>/proxy', methods=['GET'])
@jwt_required()
def get_proxy(server_id):
"""Get the managed proxy stack state for a server (best-effort status)."""
_, err = _require_server(server_id)
if err:
return err
return jsonify(ProxyStackService.status(server_id))
@proxy_bp.route('/<server_id>/proxy/ingress-audit', methods=['GET'])
@jwt_required()
def ingress_audit(server_id):
"""Which of a server's apps disagree with its configured proxy mode.
Returns the expected ingress plane for the server plus a per-app list with
a ``mismatch`` flag, so the UI can warn when host-Nginx apps are running on
a server whose active proxy is a Dockerized stack (or vice versa).
"""
_, err = _require_server(server_id)
if err:
return err
return jsonify(ProxyStackService.ingress_audit(server_id))
@proxy_bp.route('/<server_id>/proxy/compose-preview', methods=['GET'])
@jwt_required()
def compose_preview(server_id):
"""Preview the generated docker-compose for a proxy type (no writes).
``?proxy_type=traefik|caddy|nginx`` overrides the stored type so the UI
can preview before switching. Returns ``compose: null`` for nginx.
"""
_, err = _require_server(server_id)
if err:
return err
stack = ProxyStackService.get_or_create(server_id)
proxy_type = request.args.get('proxy_type', stack.proxy_type)
options = {}
if request.args.get('acme_email'):
options['acme_email'] = request.args.get('acme_email')
if request.args.get('dashboard') in ('1', 'true', 'yes'):
options['dashboard'] = True
try:
compose = ProxyStackService.generate_compose(server_id, proxy_type, options)
config = ProxyStackService.generate_config(
proxy_type, [], stack.custom_snippet
)
except ValueError as e:
return jsonify({'error': str(e)}), 400
return jsonify({
'proxy_type': proxy_type,
'compose': compose,
'config': config,
})
@proxy_bp.route('/<server_id>/proxy/configure', methods=['POST'])
@jwt_required()
@developer_required
def configure_proxy(server_id):
"""Update proxy type and/or custom snippet for a server.
Body: ``{proxy_type?, custom_snippet?, deploy?}``. When ``deploy`` is
truthy and the type is a stack proxy, a best-effort compose-up runs.
"""
_, err = _require_server(server_id)
if err:
return err
data = request.get_json() or {}
proxy_type = data.get('proxy_type')
custom_snippet = data.get('custom_snippet')
try:
stack = ProxyStackService.configure(
server_id,
proxy_type=proxy_type,
custom_snippet=custom_snippet,
)
except ValueError as e:
return jsonify({'error': str(e)}), 400
result = stack.to_dict()
if data.get('deploy') and stack.proxy_type != 'nginx':
result['deploy'] = ProxyStackService.deploy_stack(server_id)
return jsonify(result)
@proxy_bp.route('/<server_id>/proxy/regenerate', methods=['POST'])
@jwt_required()
@developer_required
def regenerate_proxy(server_id):
"""Rewrite the proxy config and best-effort hot-reload the stack."""
_, err = _require_server(server_id)
if err:
return err
data = request.get_json() or {}
sites = data.get('sites')
result = ProxyStackService.regenerate(server_id, sites=sites)
status = 200 if result.get('success') else 502
return jsonify(result), status
@proxy_bp.route('/<server_id>/proxy/switch', methods=['POST'])
@jwt_required()
@developer_required
def switch_proxy(server_id):
"""Switch a server to a different proxy type (nginx/traefik/caddy)."""
_, err = _require_server(server_id)
if err:
return err
data = request.get_json() or {}
proxy_type = data.get('proxy_type')
if not proxy_type:
return jsonify({'error': 'proxy_type is required'}), 400
try:
stack = ProxyStackService.switch(server_id, proxy_type)
except ValueError as e:
return jsonify({'error': str(e)}), 400
return jsonify(stack.to_dict())