From 90255cc4b3241ed8613033bced09c79afe01772f Mon Sep 17 00:00:00 2001 From: Josh Knapp Date: Sat, 9 May 2026 11:32:15 -0700 Subject: [PATCH] feat(api): add /api/ssl/bundle for per-site SAN cert issuance WHP's renewal orchestrator now bundles a site's domains into one cert covering all SANs, instead of N separate single-domain orders. Single ACME order = better behavior under Let's Encrypt's 50/hour orders limit when many domains need attention at once. Endpoint: POST /api/ssl/bundle Body: {"primary": "example.com", "sans": ["www.example.com", ...]} - Uses --cert-name so the lineage stays stable across renewals (no -0001/-0002 proliferation seen with the legacy single-domain flow). - Single combined .pem at /etc/haproxy/certs/.pem; HAProxy SNI- matches against the cert's SAN list, so one file serves all included hostnames. - Updates the domains table for every SAN in the bundle. - Hard cap at 100 SANs (LE limit). Existing /api/ssl single-domain endpoint kept for backwards compat. The WHP haproxy_manager::bundleSSL() helper falls back to a per-domain loop if /api/ssl/bundle returns 404, so the WHP side keeps working during the rolling image upgrade window. Co-Authored-By: Claude Opus 4.7 (1M context) --- haproxy_manager.py | 128 +++++++++++++++++++++++++++++++++++++++++++++ 1 file changed, 128 insertions(+) diff --git a/haproxy_manager.py b/haproxy_manager.py index 991cbb5..3390bc6 100644 --- a/haproxy_manager.py +++ b/haproxy_manager.py @@ -455,6 +455,134 @@ def request_ssl(): log_operation('request_ssl', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 +@app.route('/api/ssl/bundle', methods=['POST']) +@require_api_key +def request_ssl_bundle(): + """Issue a single Let's Encrypt cert covering multiple SANs. + + Used by WHP's per-site bundling: one ACME order, one combined .pem, + one DB row update per included name. Replaces N separate single-domain + /api/ssl calls when a site has multiple domains. + + Body: + {"primary": "example.com", "sans": ["www.example.com", ...]} + + The cert lineage uses --cert-name , so renewal under the same + name doesn't proliferate -0001/-0002 dirs (the issue we hit with the + legacy single-domain flow). The combined PEM is written to + /etc/haproxy/certs/.pem; HAProxy matches SNI against the cert's + SAN list, so this single file serves all included names. + """ + data = request.get_json() or {} + primary = (data.get('primary') or '').strip() + sans = data.get('sans') or [] + + if not primary: + log_operation('request_ssl_bundle', False, 'primary not provided') + return jsonify({'status': 'error', 'message': '"primary" is required'}), 400 + + # Basic shape validation. certbot will hard-validate the rest. + domain_re = re.compile( + r'^(?:\*\.)?(?:[a-z0-9](?:[a-z0-9-]{0,61}[a-z0-9])?\.)+[a-z]{2,}$', + re.IGNORECASE, + ) + if not domain_re.match(primary): + return jsonify({'status': 'error', 'message': f'invalid primary: {primary!r}'}), 400 + + # Build the unique ordered name list — primary first, then de-duped SANs. + if not isinstance(sans, list): + return jsonify({'status': 'error', 'message': '"sans" must be a list'}), 400 + cleaned_sans = [] + for s in sans: + if not isinstance(s, str): + return jsonify({'status': 'error', 'message': f'invalid SAN entry: {s!r}'}), 400 + s = s.strip() + if not s: + continue + if not domain_re.match(s): + return jsonify({'status': 'error', 'message': f'invalid SAN: {s!r}'}), 400 + cleaned_sans.append(s) + + seen = {primary} + names = [primary] + for s in cleaned_sans: + if s not in seen: + names.append(s) + seen.add(s) + + # Let's Encrypt allows up to 100 names per cert. + if len(names) > 100: + return jsonify({ + 'status': 'error', + 'message': f'Too many SANs ({len(names)}); Let\'s Encrypt limit is 100', + }), 400 + + cmd = [ + 'certbot', 'certonly', '-n', '--standalone', + '--preferred-challenges', 'http', '--http-01-port=8688', + '--cert-name', primary, + ] + for n in names: + cmd.extend(['-d', n]) + + try: + result = subprocess.run(cmd, capture_output=True, text=True) + if result.returncode != 0: + stderr_excerpt = (result.stderr or '').strip()[:800] + error_msg = f'Failed to obtain SSL bundle for {primary}: {stderr_excerpt}' + log_operation('request_ssl_bundle', False, error_msg) + return jsonify({ + 'status': 'error', + 'message': error_msg, + 'primary': primary, + 'attempted_names': names, + }), 500 + + # Locate the lineage. With --cert-name primary, this should be a + # stable directory name (no -NNNN suffix on the first issuance). + live_dir = find_certbot_live_dir(primary) + if not live_dir: + error_msg = f'Bundle issued but live dir not found for {primary}' + log_operation('request_ssl_bundle', False, error_msg) + return jsonify({'status': 'error', 'message': error_msg}), 500 + + cert_path = os.path.join(live_dir, 'fullchain.pem') + key_path = os.path.join(live_dir, 'privkey.pem') + combined_path = f'{SSL_CERTS_DIR}/{primary}.pem' + + os.makedirs(SSL_CERTS_DIR, exist_ok=True) + with open(combined_path, 'w') as combined: + subprocess.run(['cat', cert_path, key_path], stdout=combined) + + # Mark every name in the bundle as ssl_enabled, all pointing at the + # same combined .pem. HAProxy serves one file for many SNI hostnames. + with sqlite3.connect(DB_FILE) as conn: + cursor = conn.cursor() + for n in names: + cursor.execute(''' + UPDATE domains + SET ssl_enabled = 1, ssl_cert_path = ? + WHERE domain = ? + ''', (combined_path, n)) + conn.commit() + cursor.close() + + generate_config() + log_operation( + 'request_ssl_bundle', True, + f'SSL bundle issued for {primary} covering {len(names)} names' + ) + return jsonify({ + 'status': 'success', + 'primary': primary, + 'names': names, + 'cert_path': combined_path, + 'message': f'Bundled certificate obtained for {len(names)} names', + }) + except Exception as e: + log_operation('request_ssl_bundle', False, str(e)) + return jsonify({'status': 'error', 'message': str(e)}), 500 + @app.route('/api/certificates/renew', methods=['POST']) @require_api_key def renew_certificates():