From ef488a253d8528bdfde21059e7c5cbac19e08ce3 Mon Sep 17 00:00:00 2001 From: jknapp Date: Fri, 11 Jul 2025 17:14:01 -0700 Subject: [PATCH] Add /api/certificates/request endpoint for programmatic certificate requests, update docs and add test script --- README.md | 51 ++++++++ UPGRADE_SUMMARY.md | 19 ++- haproxy_manager.py | 142 ++++++++++++++++++++- scripts/test-certificate-request.sh | 186 ++++++++++++++++++++++++++++ 4 files changed, 394 insertions(+), 4 deletions(-) create mode 100755 scripts/test-certificate-request.sh diff --git a/README.md b/README.md index 552f43e..10968a3 100644 --- a/README.md +++ b/README.md @@ -205,6 +205,47 @@ Authorization: Bearer your-api-key ## New Certificate Management Endpoints +### Request Certificate Generation +Request certificate generation for one or more domains. + +```bash +POST /api/certificates/request +Authorization: Bearer your-api-key +Content-Type: application/json + +{ + "domains": ["example.com", "api.example.com"], + "force_renewal": false, + "include_www": true +} + +# Response +{ + "status": "completed", + "summary": { + "total": 2, + "successful": 2, + "failed": 0 + }, + "results": [ + { + "domain": "example.com", + "status": "success", + "message": "Certificate obtained successfully", + "cert_path": "/etc/haproxy/certs/example.com.pem", + "domains_covered": ["example.com", "www.example.com"] + }, + { + "domain": "api.example.com", + "status": "success", + "message": "Certificate obtained successfully", + "cert_path": "/etc/haproxy/certs/api.example.com.pem", + "domains_covered": ["api.example.com"] + } + ] +} +``` + ### Renew All Certificates Trigger renewal of all Let's Encrypt certificates and reload HAProxy. @@ -321,6 +362,16 @@ curl -X POST http://localhost:8000/api/ssl \ curl -X POST http://localhost:8000/api/certificates/renew \ -H "Authorization: Bearer your-secure-api-key-here" +# Request certificate generation for another service +curl -X POST http://localhost:8000/api/certificates/request \ + -H "Authorization: Bearer your-secure-api-key-here" \ + -H "Content-Type: application/json" \ + -d '{ + "domains": ["api.example.com"], + "force_renewal": false, + "include_www": false + }' + # Download certificate for another service curl -H "Authorization: Bearer your-secure-api-key-here" \ http://localhost:8000/api/certificates/example.com/download \ diff --git a/UPGRADE_SUMMARY.md b/UPGRADE_SUMMARY.md index 1818e0b..df22c2f 100644 --- a/UPGRADE_SUMMARY.md +++ b/UPGRADE_SUMMARY.md @@ -23,7 +23,19 @@ This document summarizes the new features and improvements added to the HAProxy - Returns detailed status of renewal process - **Error Handling**: Comprehensive error logging and status reporting -### 3. Certificate Download Endpoints +### 3. Certificate Request API +- **Endpoint**: `POST /api/certificates/request` +- **Functionality**: + - Request certificate generation for one or more domains + - Support for multiple domains in a single request + - Optional www subdomain inclusion + - Force renewal option + - Automatic domain addition to database if not exists + - Batch processing with detailed results +- **Use Case**: Allow other services to request certificate generation through the HAProxy service +- **Response**: Detailed status for each domain with success/failure information + +### 4. Certificate Download Endpoints - **Endpoints**: - `GET /api/certificates//download` - Combined certificate (cert + key) - `GET /api/certificates//key` - Private key only @@ -31,7 +43,7 @@ This document summarizes the new features and improvements added to the HAProxy - **Use Case**: Allow other services to securely download certificates for their own use - **Security**: All endpoints require API key authentication -### 4. Certificate Status Monitoring +### 5. Certificate Status Monitoring - **Endpoint**: `GET /api/certificates/status` - **Functionality**: - Lists all certificates with expiration dates @@ -39,7 +51,7 @@ This document summarizes the new features and improvements added to the HAProxy - Provides certificate file paths - Enables proactive certificate management -### 5. Comprehensive Error Logging and Alerting +### 6. Comprehensive Error Logging and Alerting - **Logging System**: - Structured JSON logging for all operations - Separate error log file (`/var/log/haproxy-manager-errors.log`) @@ -143,6 +155,7 @@ This document summarizes the new features and improvements added to the HAProxy - `GET /api/reload` - Reload HAProxy ### New Endpoints +- `POST /api/certificates/request` - Request certificate generation for domains - `POST /api/certificates/renew` - Renew all certificates - `GET /api/certificates/status` - Get certificate status - `GET /api/certificates//download` - Download combined certificate diff --git a/haproxy_manager.py b/haproxy_manager.py index 0214b08..05b861e 100644 --- a/haproxy_manager.py +++ b/haproxy_manager.py @@ -289,6 +289,7 @@ def index(): @app.route('/api/ssl', methods=['POST']) @require_api_key def request_ssl(): + """Legacy endpoint for requesting SSL certificate for a single domain""" data = request.get_json() domain = data.get('domain') @@ -310,6 +311,9 @@ def request_ssl(): key_path = f'/etc/letsencrypt/live/{domain}/privkey.pem' combined_path = f'{SSL_CERTS_DIR}/{domain}.pem' + # Ensure SSL certs directory exists + os.makedirs(SSL_CERTS_DIR, exist_ok=True) + with open(combined_path, 'w') as combined: subprocess.run(['cat', cert_path, key_path], stdout=combined) @@ -326,7 +330,12 @@ def request_ssl(): conn.close() generate_config() log_operation('request_ssl', True, f'SSL certificate obtained for {domain}') - return jsonify({'status': 'success'}) + return jsonify({ + 'status': 'success', + 'domain': domain, + 'cert_path': combined_path, + 'message': 'Certificate obtained successfully' + }) else: error_msg = f'Failed to obtain SSL certificate: {result.stderr}' log_operation('request_ssl', False, error_msg) @@ -491,6 +500,137 @@ def get_certificate_status(): log_operation('get_certificate_status', False, str(e)) return jsonify({'status': 'error', 'message': str(e)}), 500 +@app.route('/api/certificates/request', methods=['POST']) +@require_api_key +def request_certificates(): + """Request certificate generation for one or more domains""" + data = request.get_json() + domains = data.get('domains', []) + force_renewal = data.get('force_renewal', False) + include_www = data.get('include_www', True) + + if not domains: + log_operation('request_certificates', False, 'No domains provided') + return jsonify({'status': 'error', 'message': 'At least one domain is required'}), 400 + + if not isinstance(domains, list): + domains = [domains] # Convert single domain to list + + results = [] + success_count = 0 + error_count = 0 + + for domain in domains: + try: + # Prepare domain list for certbot (include www subdomain if requested) + certbot_domains = [domain] + if include_www and not domain.startswith('www.'): + certbot_domains.append(f'www.{domain}') + + # Build certbot command + cmd = [ + 'certbot', 'certonly', '-n', '--standalone', + '--preferred-challenges', 'http', '--http-01-port=8688' + ] + + if force_renewal: + cmd.append('--force-renewal') + + # Add domains + for d in certbot_domains: + cmd.extend(['-d', d]) + + # Request certificate + result = subprocess.run(cmd, capture_output=True, text=True) + + if result.returncode == 0: + # Combine cert files and copy to HAProxy certs directory + cert_path = f'/etc/letsencrypt/live/{domain}/fullchain.pem' + key_path = f'/etc/letsencrypt/live/{domain}/privkey.pem' + combined_path = f'{SSL_CERTS_DIR}/{domain}.pem' + + # Ensure SSL certs directory exists + os.makedirs(SSL_CERTS_DIR, exist_ok=True) + + with open(combined_path, 'w') as combined: + subprocess.run(['cat', cert_path, key_path], stdout=combined) + + # Update database (add domain if it doesn't exist) + with sqlite3.connect(DB_FILE) as conn: + cursor = conn.cursor() + + # Check if domain exists + cursor.execute('SELECT id FROM domains WHERE domain = ?', (domain,)) + domain_exists = cursor.fetchone() + + if domain_exists: + # Update existing domain + cursor.execute(''' + UPDATE domains + SET ssl_enabled = 1, ssl_cert_path = ? + WHERE domain = ? + ''', (combined_path, domain)) + else: + # Add new domain with SSL enabled + cursor.execute(''' + INSERT INTO domains (domain, ssl_enabled, ssl_cert_path) + VALUES (?, 1, ?) + ''', (domain, combined_path)) + + results.append({ + 'domain': domain, + 'status': 'success', + 'message': 'Certificate obtained successfully', + 'cert_path': combined_path, + 'domains_covered': certbot_domains + }) + success_count += 1 + + else: + error_msg = f'Failed to obtain certificate for {domain}: {result.stderr}' + results.append({ + 'domain': domain, + 'status': 'error', + 'message': error_msg, + 'stderr': result.stderr + }) + error_count += 1 + + except Exception as e: + error_msg = f'Exception while processing {domain}: {str(e)}' + results.append({ + 'domain': domain, + 'status': 'error', + 'message': error_msg + }) + error_count += 1 + + # Regenerate HAProxy config if any certificates were successful + if success_count > 0: + try: + generate_config() + log_operation('request_certificates', True, f'Successfully obtained {success_count} certificates, {error_count} failed') + except Exception as e: + log_operation('request_certificates', False, f'Certificates obtained but config generation failed: {str(e)}') + + # Return results + response = { + 'status': 'completed', + 'summary': { + 'total': len(domains), + 'successful': success_count, + 'failed': error_count + }, + 'results': results + } + + if error_count == 0: + return jsonify(response), 200 + elif success_count > 0: + return jsonify(response), 207 # Multi-status (some succeeded, some failed) + else: + return jsonify(response), 500 # All failed + @app.route('/api/domain', methods=['DELETE']) @require_api_key def remove_domain(): diff --git a/scripts/test-certificate-request.sh b/scripts/test-certificate-request.sh new file mode 100755 index 0000000..cf73fe5 --- /dev/null +++ b/scripts/test-certificate-request.sh @@ -0,0 +1,186 @@ +#!/bin/bash + +# HAProxy Manager Certificate Request Test Script +# This script tests the new certificate request endpoint + +BASE_URL="http://localhost:8000" +API_KEY="${HAPROXY_API_KEY:-}" + +# Colors for output +RED='\033[0;31m' +GREEN='\033[0;32m' +YELLOW='\033[1;33m' +BLUE='\033[0;34m' +NC='\033[0m' # No Color + +# Function to print colored output +print_status() { + local status=$1 + local message=$2 + + case $status in + "PASS") + echo -e "${GREEN}✓ PASS${NC}: $message" + ;; + "FAIL") + echo -e "${RED}✗ FAIL${NC}: $message" + ;; + "INFO") + echo -e "${BLUE}ℹ INFO${NC}: $message" + ;; + "WARN") + echo -e "${YELLOW}⚠ WARN${NC}: $message" + ;; + esac +} + +# Function to make API request +api_request() { + local method=$1 + local endpoint=$2 + local data=$3 + + local headers="" + if [ -n "$API_KEY" ]; then + headers="-H \"Authorization: Bearer $API_KEY\"" + fi + + if [ -n "$data" ]; then + headers="$headers -H \"Content-Type: application/json\" -d '$data'" + fi + + eval "curl -s -w \"%{http_code}\" -o /tmp/cert_request_response.json $headers -X $method $BASE_URL$endpoint" +} + +# Test single domain certificate request +test_single_domain_request() { + print_status "INFO" "Testing single domain certificate request..." + + local test_domain="test-$(date +%s).example.com" + local data="{\"domains\": [\"$test_domain\"], \"force_renewal\": false, \"include_www\": false}" + + local response=$(api_request "POST" "/api/certificates/request" "$data") + local status_code=$(echo "$response" | tail -c 4) + + if [ "$status_code" = "200" ] || [ "$status_code" = "207" ] || [ "$status_code" = "401" ]; then + print_status "PASS" "Single domain request endpoint responded (status: $status_code)" + + if [ "$status_code" != "401" ]; then + # Parse response + local success_count=$(jq -r '.summary.successful' /tmp/cert_request_response.json 2>/dev/null) + local failed_count=$(jq -r '.summary.failed' /tmp/cert_request_response.json 2>/dev/null) + + if [ "$success_count" = "1" ]; then + print_status "PASS" "Certificate request successful for $test_domain" + elif [ "$failed_count" = "1" ]; then + print_status "WARN" "Certificate request failed for $test_domain (expected for test domain)" + else + print_status "FAIL" "Unexpected response format" + fi + fi + else + print_status "FAIL" "Single domain request failed with status $status_code" + fi +} + +# Test multiple domain certificate request +test_multiple_domain_request() { + print_status "INFO" "Testing multiple domain certificate request..." + + local test_domains="[\"test1-$(date +%s).example.com\", \"test2-$(date +%s).example.com\"]" + local data="{\"domains\": $test_domains, \"force_renewal\": false, \"include_www\": true}" + + local response=$(api_request "POST" "/api/certificates/request" "$data") + local status_code=$(echo "$response" | tail -c 4) + + if [ "$status_code" = "200" ] || [ "$status_code" = "207" ] || [ "$status_code" = "401" ]; then + print_status "PASS" "Multiple domain request endpoint responded (status: $status_code)" + + if [ "$status_code" != "401" ]; then + local total=$(jq -r '.summary.total' /tmp/cert_request_response.json 2>/dev/null) + if [ "$total" = "2" ]; then + print_status "PASS" "Multiple domain request processed correctly" + else + print_status "FAIL" "Multiple domain request response format error" + fi + fi + else + print_status "FAIL" "Multiple domain request failed with status $status_code" + fi +} + +# Test certificate request with force renewal +test_force_renewal_request() { + print_status "INFO" "Testing certificate request with force renewal..." + + local test_domain="test-force-$(date +%s).example.com" + local data="{\"domains\": [\"$test_domain\"], \"force_renewal\": true, \"include_www\": false}" + + local response=$(api_request "POST" "/api/certificates/request" "$data") + local status_code=$(echo "$response" | tail -c 4) + + if [ "$status_code" = "200" ] || [ "$status_code" = "207" ] || [ "$status_code" = "401" ]; then + print_status "PASS" "Force renewal request endpoint responded (status: $status_code)" + else + print_status "FAIL" "Force renewal request failed with status $status_code" + fi +} + +# Test invalid request (no domains) +test_invalid_request() { + print_status "INFO" "Testing invalid request (no domains)..." + + local data="{\"domains\": [], \"force_renewal\": false, \"include_www\": false}" + + local response=$(api_request "POST" "/api/certificates/request" "$data") + local status_code=$(echo "$response" | tail -c 4) + + if [ "$status_code" = "400" ] || [ "$status_code" = "401" ]; then + print_status "PASS" "Invalid request properly rejected (status: $status_code)" + else + print_status "FAIL" "Invalid request not properly rejected (status: $status_code)" + fi +} + +# Test certificate status endpoint +test_certificate_status() { + print_status "INFO" "Testing certificate status endpoint..." + + local response=$(api_request "GET" "/api/certificates/status") + local status_code=$(echo "$response" | tail -c 4) + + if [ "$status_code" = "200" ] || [ "$status_code" = "401" ]; then + print_status "PASS" "Certificate status endpoint responded (status: $status_code)" + + if [ "$status_code" != "401" ]; then + local cert_count=$(jq -r '.certificates | length' /tmp/cert_request_response.json 2>/dev/null) + print_status "INFO" "Found $cert_count certificates in status" + fi + else + print_status "FAIL" "Certificate status failed with status $status_code" + fi +} + +# Main test execution +main() { + echo "HAProxy Manager Certificate Request Test Suite" + echo "==============================================" + echo "Base URL: $BASE_URL" + echo "API Key: ${API_KEY:-"Not configured"}" + echo "" + + test_invalid_request + test_single_domain_request + test_multiple_domain_request + test_force_renewal_request + test_certificate_status + + echo "" + echo "Test completed. Check /tmp/cert_request_response.json for detailed responses." + echo "" + echo "Note: Certificate requests for test domains will likely fail as they don't" + echo "resolve to this server. This is expected behavior for testing." +} + +# Run tests +main "$@" \ No newline at end of file