feat(api): add /api/ssl/bundle for per-site SAN cert issuance
All checks were successful
HAProxy Manager Build and Push / Build-and-Push (push) Successful in 1m1s

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 <primary> 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/<primary>.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) <noreply@anthropic.com>
This commit is contained in:
2026-05-09 11:32:15 -07:00
parent b731feab12
commit 90255cc4b3

View File

@@ -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 <primary>, 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/<primary>.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():