Files
ServerKit/backend/app/api/ssl.py
T
Juan DenisandClaude Opus 5 fc000a3b97 feat(ssl): remember one Let's Encrypt contact instead of asking every time
Plan 73 item 5. The Domains SSL modal asked for a contact email on every
single certificate, and the backend hard-required one per call. The app-detail
Settings tab did remember it -- in localStorage, so the memory was per browser
and invisible to the other surface. The two never agreed, and neither could
help the paths with no form at all: domain attach passed `admin@<host>` when
it had nothing else, an address that usually does not exist, which is where
every expiry notice for those certificates went to die.

There is now a single panel-wide ACME contact in system settings.
get_acme_contact() resolves an explicit address first and falls back to the
stored one; remember_acme_contact() writes it back whenever a request carries
an explicit address. Both are best-effort on the settings side by design: a
settings failure must not break issuance, and failing to remember an address
must not turn a successful certificate into an error. An empty input never
clears a stored contact, since the automatic paths pass whatever they were
given and that is frequently nothing.

Wired into every issuance entry point -- the domain SSL enable route, the
generic certificate route, and domain attach, which now consults the stored
contact before falling back to the admin@<host> guess. The address is only
remembered after issuance actually succeeds; remembering one Let's Encrypt
rejected would be worse than remembering none.

Prefill needed its own endpoint. The settings routes are all admin-gated, but
issuing a certificate for an app you can edit is not an admin action, so
GET /ssl/acme-contact is jwt_required only -- it returns an address the
operator chose for notices, not a credential. The Domains modal and the
Settings tab both prefill from it, and the Settings tab keeps its localStorage
seed so a browser that already has the value shows it without a flash.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
2026-08-13 16:22:33 -04:00

150 lines
4.6 KiB
Python

from flask import Blueprint, request, jsonify
from flask_jwt_extended import jwt_required, get_jwt_identity
from app.middleware.rbac import admin_required
from app.services.ssl_service import SSLService, get_acme_contact, remember_acme_contact
ssl_bp = Blueprint('ssl', __name__)
@ssl_bp.route('/acme-contact', methods=['GET'])
@jwt_required()
def acme_contact():
"""The panel-wide ACME contact, for prefilling certificate forms.
Deliberately not admin-gated: issuing a certificate for an app you can
edit is not an admin action, so the surfaces that need this prefill are
reachable by non-admins too. The value is the address the operator chose
to receive expiry notices at, not a credential.
"""
return jsonify({'email': get_acme_contact() or ''}), 200
@ssl_bp.route('/certificates', methods=['GET'])
@jwt_required()
@admin_required
def list_certificates():
"""List all SSL certificates."""
certificates = SSLService.list_certificates()
return jsonify({'certificates': certificates}), 200
@ssl_bp.route('/certificates/<domain>', methods=['GET'])
@jwt_required()
@admin_required
def get_certificate(domain):
"""Get details for a specific certificate."""
info = SSLService.get_certificate_info(domain)
if info:
return jsonify({'certificate': info}), 200
return jsonify({'error': 'Certificate not found'}), 404
@ssl_bp.route('/certificates', methods=['POST'])
@jwt_required()
@admin_required
def obtain_certificate():
"""Obtain a new SSL certificate from Let's Encrypt."""
data = request.get_json()
if not data:
return jsonify({'error': 'No data provided'}), 400
domains = data.get('domains')
# Falls back to the stored panel-wide contact, so a caller that has one
# configured no longer has to repeat it on every request.
email = get_acme_contact(data.get('email'))
if not domains or not email:
return jsonify({'error': 'domains and email are required'}), 400
result = SSLService.obtain_certificate(
domains=domains,
email=email,
webroot_path=data.get('webroot_path'),
use_nginx=data.get('use_nginx', True)
)
if result.get('success'):
remember_acme_contact(data.get('email'), user_id=get_jwt_identity())
return jsonify(result), 201 if result['success'] else 400
@ssl_bp.route('/certificates/<domain>/renew', methods=['POST'])
@jwt_required()
@admin_required
def renew_certificate(domain):
"""Renew a specific certificate."""
result = SSLService.renew_certificate(domain)
return jsonify(result), 200 if result['success'] else 400
@ssl_bp.route('/certificates/renew-all', methods=['POST'])
@jwt_required()
@admin_required
def renew_all_certificates():
"""Renew all certificates that need renewal."""
result = SSLService.renew_certificate()
return jsonify(result), 200 if result['success'] else 400
@ssl_bp.route('/certificates/<domain>', methods=['DELETE'])
@jwt_required()
@admin_required
def revoke_certificate(domain):
"""Revoke and delete a certificate."""
result = SSLService.revoke_certificate(domain)
return jsonify(result), 200 if result['success'] else 400
@ssl_bp.route('/certificates/<domain>/check', methods=['GET'])
@jwt_required()
@admin_required
def check_certificate_expiry(domain):
"""Check if a certificate is expiring soon."""
result = SSLService.check_expiry(domain)
return jsonify(result), 200
@ssl_bp.route('/auto-renewal', methods=['POST'])
@jwt_required()
@admin_required
def setup_auto_renewal():
"""Set up automatic certificate renewal."""
result = SSLService.setup_auto_renewal()
return jsonify(result), 200 if result['success'] else 400
@ssl_bp.route('/install-certbot', methods=['POST'])
@jwt_required()
@admin_required
def install_certbot():
"""Install certbot if not already installed."""
if SSLService.is_certbot_installed():
return jsonify({'success': True, 'message': 'Certbot is already installed'}), 200
result = SSLService.install_certbot()
return jsonify(result), 200 if result['success'] else 400
@ssl_bp.route('/status', methods=['GET'])
@jwt_required()
@admin_required
def get_ssl_status():
"""Get overall SSL status."""
is_installed = SSLService.is_certbot_installed()
certificates = SSLService.list_certificates()
expiring_soon = []
for cert in certificates:
check = SSLService.check_expiry(cert['name'])
if check.get('expiring_soon'):
expiring_soon.append(cert['name'])
return jsonify({
'certbot_installed': is_installed,
'total_certificates': len(certificates),
'expiring_soon': expiring_soon,
'certificates': certificates
}), 200