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
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:
@@ -455,6 +455,134 @@ def request_ssl():
|
|||||||
log_operation('request_ssl', False, str(e))
|
log_operation('request_ssl', False, str(e))
|
||||||
return jsonify({'status': 'error', 'message': str(e)}), 500
|
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'])
|
@app.route('/api/certificates/renew', methods=['POST'])
|
||||||
@require_api_key
|
@require_api_key
|
||||||
def renew_certificates():
|
def renew_certificates():
|
||||||
|
|||||||
Reference in New Issue
Block a user