2025-02-19 07:53:26 -08:00
import sqlite3
import os
2025-07-11 06:24:56 -07:00
from flask import Flask , request , jsonify , render_template , send_file
2025-02-19 07:53:26 -08:00
from pathlib import Path
import subprocess
import jinja2
import socket
import psutil
2025-07-11 06:24:56 -07:00
import functools
import logging
from datetime import datetime
import json
2025-02-19 07:53:26 -08:00
app = Flask ( __name__ )
2025-07-11 06:24:56 -07:00
# Configuration
2025-02-20 16:26:27 -08:00
DB_FILE = ' /etc/haproxy/haproxy_config.db '
2025-02-19 07:53:26 -08:00
TEMPLATE_DIR = Path ( ' templates ' )
HAPROXY_CONFIG_PATH = ' /etc/haproxy/haproxy.cfg '
SSL_CERTS_DIR = ' /etc/haproxy/certs '
2025-07-11 06:24:56 -07:00
API_KEY = os . environ . get ( ' HAPROXY_API_KEY ' ) # Optional API key for authentication
# Setup logging
logging . basicConfig (
level = logging . INFO ,
format = ' %(asctime)s - %(name)s - %(levelname)s - %(message)s ' ,
handlers = [
logging . FileHandler ( ' /var/log/haproxy-manager.log ' ) ,
logging . StreamHandler ( )
]
)
logger = logging . getLogger ( __name__ )
def require_api_key ( f ) :
""" Decorator to require API key authentication if API_KEY is set """
@functools.wraps ( f )
def decorated_function ( * args , * * kwargs ) :
if API_KEY :
auth_header = request . headers . get ( ' Authorization ' )
if not auth_header or auth_header != f ' Bearer { API_KEY } ' :
return jsonify ( { ' error ' : ' Unauthorized - Invalid or missing API key ' } ) , 401
return f ( * args , * * kwargs )
return decorated_function
def log_operation ( operation , success = True , error_message = None ) :
""" Log operations for monitoring and alerting """
log_entry = {
' timestamp ' : datetime . now ( ) . isoformat ( ) ,
' operation ' : operation ,
' success ' : success ,
' error ' : error_message
}
if success :
logger . info ( f " Operation { operation } completed successfully " )
else :
logger . error ( f " Operation { operation } failed: { error_message } " )
# Here you could add additional alerting (email, webhook, etc.)
# For now, we'll just log to a dedicated error log
with open ( ' /var/log/haproxy-manager-errors.log ' , ' a ' ) as f :
f . write ( json . dumps ( log_entry ) + ' \n ' )
return log_entry
2025-02-19 07:53:26 -08:00
def init_db ( ) :
with sqlite3 . connect ( DB_FILE ) as conn :
cursor = conn . cursor ( )
2025-02-20 13:41:38 -08:00
2025-02-19 07:53:26 -08:00
# Create domains table
cursor . execute ( '''
CREATE TABLE IF NOT EXISTS domains (
id INTEGER PRIMARY KEY ,
domain TEXT UNIQUE NOT NULL ,
ssl_enabled BOOLEAN DEFAULT 0 ,
2025-02-21 08:01:16 -08:00
ssl_cert_path TEXT ,
2025-03-06 16:51:29 -08:00
template_override TEXT
2025-02-19 07:53:26 -08:00
)
''' )
2025-02-20 13:41:38 -08:00
2025-02-19 07:53:26 -08:00
# Create backends table
cursor . execute ( '''
CREATE TABLE IF NOT EXISTS backends (
id INTEGER PRIMARY KEY ,
name TEXT UNIQUE NOT NULL ,
domain_id INTEGER ,
settings TEXT ,
FOREIGN KEY ( domain_id ) REFERENCES domains ( id )
)
''' )
2025-02-20 13:41:38 -08:00
2025-02-19 07:53:26 -08:00
# Create backend_servers table
cursor . execute ( '''
CREATE TABLE IF NOT EXISTS backend_servers (
id INTEGER PRIMARY KEY ,
backend_id INTEGER ,
server_name TEXT NOT NULL ,
server_address TEXT NOT NULL ,
server_port INTEGER NOT NULL ,
server_options TEXT ,
FOREIGN KEY ( backend_id ) REFERENCES backends ( id )
)
''' )
conn . commit ( )
2025-02-20 14:01:53 -08:00
def certbot_register ( ) :
""" Register with Let ' s Encrypt using the certbot client and agree to the terms of service """
result = subprocess . run ( [ ' certbot ' , ' show_account ' ] , capture_output = True )
if result . returncode != 0 :
subprocess . run ( [ ' certbot ' , ' register ' , ' --agree-tos ' , ' --register-unsafely-without-email ' , ' --no-eff-email ' ] )
2025-02-19 07:53:26 -08:00
def generate_self_signed_cert ( ssl_certs_dir ) :
""" Generate a self-signed certificate for a domain. """
self_sign_cert = os . path . join ( ssl_certs_dir , " default_self_signed_cert.pem " )
2025-02-20 13:41:38 -08:00
print ( self_sign_cert )
2025-02-19 07:53:26 -08:00
if os . path . exists ( self_sign_cert ) :
print ( " Self Signed Cert Found " )
return True
try :
os . mkdir ( ssl_certs_dir )
except FileExistsError :
pass
DOMAIN = socket . gethostname ( )
# Generate private key and certificate
subprocess . run ( [
2025-02-20 13:41:38 -08:00
' openssl ' , ' req ' , ' -x509 ' , ' -newkey ' , ' rsa:4096 ' ,
2025-02-19 07:53:26 -08:00
' -keyout ' , ' /tmp/key.pem ' ,
' -out ' , ' /tmp/cert.pem ' ,
' -days ' , ' 3650 ' ,
' -nodes ' , # No passphrase
' -subj ' , f ' /CN= { DOMAIN } '
] , check = True )
2025-02-20 13:41:38 -08:00
2025-02-19 07:53:26 -08:00
# Combine cert and key for HAProxy
with open ( self_sign_cert , ' wb ' ) as combined :
for file in [ ' /tmp/cert.pem ' , ' /tmp/key.pem ' ] :
with open ( file , ' rb ' ) as f :
combined . write ( f . read ( ) )
os . remove ( file ) # Clean up temporary files
generate_config ( )
return True
def is_process_running ( process_name ) :
for process in psutil . process_iter ( [ ' name ' ] ) :
if process . info [ ' name ' ] == process_name :
return True
return False
# Initialize template engine
template_loader = jinja2 . FileSystemLoader ( TEMPLATE_DIR )
template_env = jinja2 . Environment ( loader = template_loader )
2025-03-06 16:51:29 -08:00
@app.route ( ' /api/domains ' , methods = [ ' GET ' ] )
2025-07-11 06:24:56 -07:00
@require_api_key
2025-03-06 16:51:29 -08:00
def get_domains ( ) :
try :
with sqlite3 . connect ( DB_FILE ) as conn :
conn . row_factory = sqlite3 . Row
cursor = conn . cursor ( )
cursor . execute ( '''
SELECT d . * , b . name as backend_name
FROM domains d
LEFT JOIN backends b ON d . id = b . domain_id
''' )
domains = [ dict ( row ) for row in cursor . fetchall ( ) ]
2025-07-11 06:24:56 -07:00
log_operation ( ' get_domains ' , True )
2025-03-06 16:51:29 -08:00
return jsonify ( domains )
except Exception as e :
2025-07-11 06:24:56 -07:00
log_operation ( ' get_domains ' , False , str ( e ) )
2025-03-06 16:51:29 -08:00
return jsonify ( { ' status ' : ' error ' , ' message ' : str ( e ) } ) , 500
2025-02-20 13:41:38 -08:00
@app.route ( ' /health ' , methods = [ ' GET ' ] )
def health_check ( ) :
try :
# Check if HAProxy is running
haproxy_running = is_process_running ( ' haproxy ' )
2025-03-06 16:51:29 -08:00
2025-02-20 13:41:38 -08:00
# Check if database is accessible
with sqlite3 . connect ( DB_FILE ) as conn :
cursor = conn . cursor ( )
cursor . execute ( ' SELECT 1 ' )
cursor . fetchone ( )
2025-03-06 16:51:29 -08:00
2025-02-20 13:41:38 -08:00
return jsonify ( {
' status ' : ' healthy ' ,
' haproxy_status ' : ' running ' if haproxy_running else ' stopped ' ,
' database ' : ' connected '
} ) , 200
except Exception as e :
return jsonify ( {
' status ' : ' unhealthy ' ,
' error ' : str ( e )
2025-02-20 15:40:32 -08:00
} ) , 500
2025-03-06 16:51:29 -08:00
2025-02-20 16:26:27 -08:00
@app.route ( ' /api/regenerate ' , methods = [ ' GET ' ] )
2025-07-11 06:24:56 -07:00
@require_api_key
2025-02-20 15:40:32 -08:00
def regenerate_conf ( ) :
try :
generate_config ( )
2025-07-11 06:24:56 -07:00
log_operation ( ' regenerate_config ' , True )
2025-02-20 15:40:32 -08:00
return jsonify ( { ' status ' : ' success ' } ) , 200
except Exception as e :
2025-07-11 06:24:56 -07:00
log_operation ( ' regenerate_config ' , False , str ( e ) )
2025-02-20 15:40:32 -08:00
return jsonify ( {
' status ' : ' failed ' ,
' error ' : str ( e )
2025-02-20 13:41:38 -08:00
} ) , 500
2025-03-09 10:59:03 -07:00
2025-03-09 11:07:21 -07:00
@app.route ( ' /api/reload ' , methods = [ ' GET ' ] )
2025-07-11 06:24:56 -07:00
@require_api_key
2025-03-09 10:59:03 -07:00
def reload_haproxy ( ) :
2025-07-11 06:24:56 -07:00
try :
if is_process_running ( ' haproxy ' ) :
# Use a proper shell command string when shell=True is set
result = subprocess . run ( ' echo " reload " | socat stdio /tmp/haproxy-cli ' ,
check = True , capture_output = True , text = True , shell = True )
print ( f " Reload result: { result . stdout } , { result . stderr } , { result . returncode } " )
log_operation ( ' reload_haproxy ' , True )
return jsonify ( { ' status ' : ' success ' } ) , 200
else :
# Start HAProxy if it's not running
2025-03-09 10:59:03 -07:00
result = subprocess . run (
[ ' haproxy ' , ' -W ' , ' -S ' , ' /tmp/haproxy-cli,level,admin ' , ' -f ' , HAPROXY_CONFIG_PATH ] ,
check = True ,
capture_output = True ,
text = True
)
if result . returncode == 0 :
print ( " HAProxy started successfully " )
2025-07-11 06:24:56 -07:00
log_operation ( ' start_haproxy ' , True )
2025-03-09 10:59:03 -07:00
return jsonify ( { ' status ' : ' success ' } ) , 200
else :
2025-07-11 06:24:56 -07:00
error_msg = f " HAProxy start command returned: { result . stdout } \n Error output: { result . stderr } "
print ( error_msg )
log_operation ( ' start_haproxy ' , False , error_msg )
return jsonify ( { ' status ' : ' failed ' , ' error ' : error_msg } ) , 500
except subprocess . CalledProcessError as e :
error_msg = f " Failed to start HAProxy: { e . stdout } \n { e . stderr } "
print ( error_msg )
log_operation ( ' reload_haproxy ' , False , error_msg )
return jsonify ( { ' status ' : ' failed ' , ' error ' : error_msg } ) , 500
2025-02-20 13:41:38 -08:00
2025-02-19 07:53:26 -08:00
@app.route ( ' /api/domain ' , methods = [ ' POST ' ] )
2025-07-11 06:24:56 -07:00
@require_api_key
2025-02-19 07:53:26 -08:00
def add_domain ( ) :
data = request . get_json ( )
domain = data . get ( ' domain ' )
2025-02-21 08:01:16 -08:00
template_override = data . get ( ' template_override ' )
2025-02-19 07:53:26 -08:00
backend_name = data . get ( ' backend_name ' )
servers = data . get ( ' servers ' , [ ] )
2025-02-20 13:41:38 -08:00
2025-07-11 06:24:56 -07:00
if not domain or not backend_name :
log_operation ( ' add_domain ' , False , ' Domain and backend_name are required ' )
return jsonify ( { ' status ' : ' error ' , ' message ' : ' Domain and backend_name are required ' } ) , 400
2025-02-20 13:41:38 -08:00
2025-07-11 06:24:56 -07:00
try :
with sqlite3 . connect ( DB_FILE ) as conn :
cursor = conn . cursor ( )
# Add domain
cursor . execute ( ' INSERT INTO domains (domain, template_override) VALUES (?, ?) ' , ( domain , template_override ) )
domain_id = cursor . lastrowid
# Add backend
cursor . execute ( ' INSERT INTO backends (name, domain_id) VALUES (?, ?) ' ,
( backend_name , domain_id ) )
backend_id = cursor . lastrowid
for server in servers :
cursor . execute ( '''
INSERT INTO backend_servers
( backend_id , server_name , server_address , server_port , server_options )
VALUES ( ? , ? , ? , ? , ? )
''' , (backend_id, server[ ' name ' ], server[ ' address ' ],
server [ ' port ' ] , server . get ( ' options ' ) ) )
# Close cursor and connection
cursor . close ( )
conn . close ( )
generate_config ( )
log_operation ( ' add_domain ' , True , f ' Domain { domain } added successfully ' )
return jsonify ( { ' status ' : ' success ' , ' domain_id ' : domain_id } )
except Exception as e :
log_operation ( ' add_domain ' , False , str ( e ) )
return jsonify ( { ' status ' : ' error ' , ' message ' : str ( e ) } ) , 500
2025-02-19 07:53:26 -08:00
2025-03-06 16:51:29 -08:00
@app.route ( ' / ' )
def index ( ) :
return render_template ( ' index.html ' )
2025-02-19 07:53:26 -08:00
@app.route ( ' /api/ssl ' , methods = [ ' POST ' ] )
2025-07-11 06:24:56 -07:00
@require_api_key
2025-02-19 07:53:26 -08:00
def request_ssl ( ) :
2025-07-11 17:14:01 -07:00
""" Legacy endpoint for requesting SSL certificate for a single domain """
2025-02-19 07:53:26 -08:00
data = request . get_json ( )
domain = data . get ( ' domain ' )
2025-02-20 13:41:38 -08:00
2025-07-11 06:24:56 -07:00
if not domain :
log_operation ( ' request_ssl ' , False , ' Domain not provided ' )
return jsonify ( { ' status ' : ' error ' , ' message ' : ' Domain is required ' } ) , 400
2025-02-20 13:41:38 -08:00
2025-07-11 06:24:56 -07:00
try :
# Request Let's Encrypt certificate
result = subprocess . run ( [
' certbot ' , ' certonly ' , ' -n ' , ' --standalone ' ,
' --preferred-challenges ' , ' http ' , ' --http-01-port=8688 ' ,
' -d ' , domain
] , 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 '
2025-07-11 17:14:01 -07:00
# Ensure SSL certs directory exists
os . makedirs ( SSL_CERTS_DIR , exist_ok = True )
2025-07-11 06:24:56 -07:00
with open ( combined_path , ' w ' ) as combined :
subprocess . run ( [ ' cat ' , cert_path , key_path ] , stdout = combined )
# Update database
with sqlite3 . connect ( DB_FILE ) as conn :
cursor = conn . cursor ( )
cursor . execute ( '''
UPDATE domains
SET ssl_enabled = 1 , ssl_cert_path = ?
WHERE domain = ?
''' , (combined_path, domain))
# Close cursor and connection
cursor . close ( )
conn . close ( )
generate_config ( )
log_operation ( ' request_ssl ' , True , f ' SSL certificate obtained for { domain } ' )
2025-07-11 17:14:01 -07:00
return jsonify ( {
' status ' : ' success ' ,
' domain ' : domain ,
' cert_path ' : combined_path ,
' message ' : ' Certificate obtained successfully '
} )
2025-07-11 06:24:56 -07:00
else :
error_msg = f ' Failed to obtain SSL certificate: { result . stderr } '
log_operation ( ' request_ssl ' , False , error_msg )
return jsonify ( { ' status ' : ' error ' , ' message ' : error_msg } ) , 500
except Exception as e :
log_operation ( ' request_ssl ' , False , str ( e ) )
return jsonify ( { ' status ' : ' error ' , ' message ' : str ( e ) } ) , 500
@app.route ( ' /api/certificates/renew ' , methods = [ ' POST ' ] )
@require_api_key
def renew_certificates ( ) :
""" Renew all certificates and reload HAProxy """
try :
# Run certbot renew
result = subprocess . run ( [
' certbot ' , ' renew ' , ' --quiet '
] , capture_output = True , text = True )
if result . returncode == 0 :
# Check if any certificates were renewed
if ' Congratulations ' in result . stdout or ' renewed ' in result . stdout :
# Update combined certificates for HAProxy
with sqlite3 . connect ( DB_FILE ) as conn :
cursor = conn . cursor ( )
cursor . execute ( ' SELECT domain, ssl_cert_path FROM domains WHERE ssl_enabled = 1 ' )
domains = cursor . fetchall ( )
for domain , cert_path in domains :
if cert_path and os . path . exists ( cert_path ) :
# Recreate combined certificate
letsencrypt_cert = f ' /etc/letsencrypt/live/ { domain } /fullchain.pem '
letsencrypt_key = f ' /etc/letsencrypt/live/ { domain } /privkey.pem '
if os . path . exists ( letsencrypt_cert ) and os . path . exists ( letsencrypt_key ) :
with open ( cert_path , ' w ' ) as combined :
subprocess . run ( [ ' cat ' , letsencrypt_cert , letsencrypt_key ] , stdout = combined )
# Regenerate config and reload HAProxy
generate_config ( )
reload_result = subprocess . run ( ' echo " reload " | socat stdio /tmp/haproxy-cli ' ,
capture_output = True , text = True , shell = True )
if reload_result . returncode == 0 :
log_operation ( ' renew_certificates ' , True , ' Certificates renewed and HAProxy reloaded ' )
return jsonify ( { ' status ' : ' success ' , ' message ' : ' Certificates renewed and HAProxy reloaded ' } )
else :
error_msg = f ' Certificates renewed but HAProxy reload failed: { reload_result . stderr } '
log_operation ( ' renew_certificates ' , False , error_msg )
return jsonify ( { ' status ' : ' partial_success ' , ' message ' : error_msg } ) , 500
else :
log_operation ( ' renew_certificates ' , True , ' No certificates needed renewal ' )
return jsonify ( { ' status ' : ' success ' , ' message ' : ' No certificates needed renewal ' } )
else :
error_msg = f ' Certificate renewal failed: { result . stderr } '
log_operation ( ' renew_certificates ' , False , error_msg )
return jsonify ( { ' status ' : ' error ' , ' message ' : error_msg } ) , 500
except Exception as e :
log_operation ( ' renew_certificates ' , False , str ( e ) )
return jsonify ( { ' status ' : ' error ' , ' message ' : str ( e ) } ) , 500
@app.route ( ' /api/certificates/<domain>/download ' , methods = [ ' GET ' ] )
@require_api_key
def download_certificate ( domain ) :
""" Download the combined certificate file for a domain """
try :
with sqlite3 . connect ( DB_FILE ) as conn :
cursor = conn . cursor ( )
cursor . execute ( ' SELECT ssl_cert_path FROM domains WHERE domain = ? AND ssl_enabled = 1 ' , ( domain , ) )
result = cursor . fetchone ( )
if not result or not result [ 0 ] :
return jsonify ( { ' status ' : ' error ' , ' message ' : ' Certificate not found for domain ' } ) , 404
cert_path = result [ 0 ]
if not os . path . exists ( cert_path ) :
return jsonify ( { ' status ' : ' error ' , ' message ' : ' Certificate file not found ' } ) , 404
log_operation ( ' download_certificate ' , True , f ' Certificate downloaded for { domain } ' )
return send_file ( cert_path , as_attachment = True , download_name = f ' { domain } .pem ' )
except Exception as e :
log_operation ( ' download_certificate ' , False , str ( e ) )
return jsonify ( { ' status ' : ' error ' , ' message ' : str ( e ) } ) , 500
@app.route ( ' /api/certificates/<domain>/key ' , methods = [ ' GET ' ] )
@require_api_key
def download_private_key ( domain ) :
""" Download the private key for a domain """
try :
2025-02-19 07:53:26 -08:00
key_path = f ' /etc/letsencrypt/live/ { domain } /privkey.pem '
2025-07-11 06:24:56 -07:00
if not os . path . exists ( key_path ) :
return jsonify ( { ' status ' : ' error ' , ' message ' : ' Private key not found for domain ' } ) , 404
log_operation ( ' download_private_key ' , True , f ' Private key downloaded for { domain } ' )
return send_file ( key_path , as_attachment = True , download_name = f ' { domain } _key.pem ' )
except Exception as e :
log_operation ( ' download_private_key ' , False , str ( e ) )
return jsonify ( { ' status ' : ' error ' , ' message ' : str ( e ) } ) , 500
2025-02-20 13:41:38 -08:00
2025-07-11 06:24:56 -07:00
@app.route ( ' /api/certificates/<domain>/cert ' , methods = [ ' GET ' ] )
@require_api_key
def download_cert_only ( domain ) :
""" Download only the certificate (without private key) for a domain """
try :
cert_path = f ' /etc/letsencrypt/live/ { domain } /fullchain.pem '
if not os . path . exists ( cert_path ) :
return jsonify ( { ' status ' : ' error ' , ' message ' : ' Certificate not found for domain ' } ) , 404
log_operation ( ' download_cert_only ' , True , f ' Certificate (only) downloaded for { domain } ' )
return send_file ( cert_path , as_attachment = True , download_name = f ' { domain } _cert.pem ' )
except Exception as e :
log_operation ( ' download_cert_only ' , False , str ( e ) )
return jsonify ( { ' status ' : ' error ' , ' message ' : str ( e ) } ) , 500
2025-02-20 13:41:38 -08:00
2025-07-11 06:24:56 -07:00
@app.route ( ' /api/certificates/status ' , methods = [ ' GET ' ] )
@require_api_key
def get_certificate_status ( ) :
""" Get status of all certificates including expiration dates """
try :
2025-02-19 07:53:26 -08:00
with sqlite3 . connect ( DB_FILE ) as conn :
cursor = conn . cursor ( )
2025-07-11 06:24:56 -07:00
cursor . execute ( ' SELECT domain, ssl_enabled, ssl_cert_path FROM domains WHERE ssl_enabled = 1 ' )
domains = cursor . fetchall ( )
cert_status = [ ]
for domain , ssl_enabled , cert_path in domains :
status = {
' domain ' : domain ,
' ssl_enabled ' : bool ( ssl_enabled ) ,
' cert_path ' : cert_path ,
' expires ' : None ,
' days_until_expiry ' : None
}
if cert_path and os . path . exists ( cert_path ) :
# Check certificate expiration using openssl
try :
result = subprocess . run ( [
' openssl ' , ' x509 ' , ' -in ' , cert_path , ' -noout ' , ' -dates '
] , capture_output = True , text = True )
if result . returncode == 0 :
# Parse the notAfter date
for line in result . stdout . split ( ' \n ' ) :
if ' notAfter= ' in line :
expiry_date_str = line . split ( ' = ' ) [ 1 ] . strip ( )
from datetime import datetime
expiry_date = datetime . strptime ( expiry_date_str , ' % b %d % H: % M: % S % Y % Z ' )
status [ ' expires ' ] = expiry_date . isoformat ( )
# Calculate days until expiry
days_until = ( expiry_date - datetime . now ( ) ) . days
status [ ' days_until_expiry ' ] = days_until
break
except Exception as e :
status [ ' error ' ] = str ( e )
cert_status . append ( status )
log_operation ( ' get_certificate_status ' , True )
return jsonify ( { ' certificates ' : cert_status } )
except Exception as e :
log_operation ( ' get_certificate_status ' , False , str ( e ) )
return jsonify ( { ' status ' : ' error ' , ' message ' : str ( e ) } ) , 500
2025-02-19 07:53:26 -08:00
2025-07-11 17:14:01 -07:00
@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
2025-02-20 13:41:38 -08:00
@app.route ( ' /api/domain ' , methods = [ ' DELETE ' ] )
2025-07-11 06:24:56 -07:00
@require_api_key
2025-02-20 13:41:38 -08:00
def remove_domain ( ) :
data = request . get_json ( )
domain = data . get ( ' domain ' )
if not domain :
2025-07-11 06:24:56 -07:00
log_operation ( ' remove_domain ' , False , ' Domain is required ' )
2025-02-20 13:41:38 -08:00
return jsonify ( { ' status ' : ' error ' , ' message ' : ' Domain is required ' } ) , 400
try :
with sqlite3 . connect ( DB_FILE ) as conn :
cursor = conn . cursor ( )
2025-03-06 16:51:29 -08:00
2025-02-20 13:41:38 -08:00
# Get domain ID and check if it exists
cursor . execute ( ' SELECT id FROM domains WHERE domain = ? ' , ( domain , ) )
domain_result = cursor . fetchone ( )
2025-03-06 16:51:29 -08:00
2025-02-20 13:41:38 -08:00
if not domain_result :
2025-07-11 06:24:56 -07:00
log_operation ( ' remove_domain ' , False , f ' Domain { domain } not found ' )
2025-02-20 13:41:38 -08:00
return jsonify ( { ' status ' : ' error ' , ' message ' : ' Domain not found ' } ) , 404
2025-03-06 16:51:29 -08:00
2025-02-20 13:41:38 -08:00
domain_id = domain_result [ 0 ]
# Get backend IDs associated with this domain
cursor . execute ( ' SELECT id FROM backends WHERE domain_id = ? ' , ( domain_id , ) )
backend_ids = [ row [ 0 ] for row in cursor . fetchall ( ) ]
# Delete backend servers
for backend_id in backend_ids :
cursor . execute ( ' DELETE FROM backend_servers WHERE backend_id = ? ' , ( backend_id , ) )
# Delete backends
cursor . execute ( ' DELETE FROM backends WHERE domain_id = ? ' , ( domain_id , ) )
# Delete domain
cursor . execute ( ' DELETE FROM domains WHERE id = ? ' , ( domain_id , ) )
# Delete SSL certificate if it exists
cursor . execute ( ' SELECT ssl_cert_path FROM domains WHERE id = ? AND ssl_enabled = 1 ' , ( domain_id , ) )
cert_result = cursor . fetchone ( )
if cert_result and cert_result [ 0 ] :
try :
os . remove ( cert_result [ 0 ] )
except OSError :
pass # Ignore errors if file doesn't exist
# Regenerate HAProxy config
generate_config ( )
2025-07-11 06:24:56 -07:00
log_operation ( ' remove_domain ' , True , f ' Domain { domain } removed successfully ' )
2025-02-20 13:41:38 -08:00
return jsonify ( { ' status ' : ' success ' , ' message ' : ' Domain configuration removed ' } )
except Exception as e :
2025-07-11 06:24:56 -07:00
log_operation ( ' remove_domain ' , False , str ( e ) )
2025-02-20 13:41:38 -08:00
return jsonify ( { ' status ' : ' error ' , ' message ' : str ( e ) } ) , 500
2025-02-19 07:53:26 -08:00
def generate_config ( ) :
try :
conn = sqlite3 . connect ( DB_FILE )
# Enable dictionary-like access to rows
conn . row_factory = sqlite3 . Row
cursor = conn . cursor ( )
2025-02-20 13:41:38 -08:00
2025-02-19 07:53:26 -08:00
query = '''
2025-02-20 13:41:38 -08:00
SELECT
2025-02-19 07:53:26 -08:00
d . id as domain_id ,
d . domain ,
d . ssl_enabled ,
d . ssl_cert_path ,
2025-02-21 10:17:15 -08:00
d . template_override ,
2025-02-19 07:53:26 -08:00
b . id as backend_id ,
b . name as backend_name
FROM domains d
LEFT JOIN backends b ON d . id = b . domain_id
'''
cursor . execute ( query )
2025-02-20 13:41:38 -08:00
2025-02-19 07:53:26 -08:00
# Fetch and immediately convert to list of dicts to avoid any cursor issues
domains = [ dict ( domain ) for domain in cursor . fetchall ( ) ]
config_parts = [ ]
# Add Haproxy Default Headers
default_headers = template_env . get_template ( ' hap_header.tpl ' ) . render ( )
config_parts . append ( default_headers )
# Add Listener Block
listener_block = template_env . get_template ( ' hap_listener.tpl ' ) . render (
crt_path = SSL_CERTS_DIR
)
config_parts . append ( listener_block )
# Add Let's Encrypt
letsencrypt_acl = template_env . get_template ( ' hap_letsencrypt.tpl ' ) . render ( )
config_parts . append ( letsencrypt_acl )
2025-02-20 15:29:42 -08:00
config_acls = [ ]
config_backends = [ ]
2025-07-11 19:10:05 -07:00
# Add default backend rule (will be used when no domain matches)
default_rule = " # Default backend for unmatched domains \n default_backend default-backend \n "
config_parts . append ( default_rule )
# Add domain configurations
2025-02-19 07:53:26 -08:00
for domain in domains :
if not domain [ ' backend_name ' ] :
2025-07-11 06:24:56 -07:00
logger . warning ( f " Skipping domain { domain [ ' domain ' ] } - no backend name " )
2025-02-19 07:53:26 -08:00
continue
# Add domain ACL
try :
domain_acl = template_env . get_template ( ' hap_subdomain_acl.tpl ' ) . render (
domain = domain [ ' domain ' ] ,
name = domain [ ' backend_name ' ]
)
2025-02-20 15:29:42 -08:00
config_acls . append ( domain_acl )
2025-07-11 06:24:56 -07:00
logger . info ( f " Added ACL for domain: { domain [ ' domain ' ] } " )
2025-02-19 07:53:26 -08:00
except Exception as e :
2025-07-11 06:24:56 -07:00
logger . error ( f " Error generating domain ACL for { domain [ ' domain ' ] } : { e } " )
2025-02-19 07:53:26 -08:00
continue
# Add backend configuration
try :
cursor . execute ( '''
SELECT * FROM backend_servers WHERE backend_id = ?
''' , (domain[ ' backend_id ' ],))
servers = [ dict ( server ) for server in cursor . fetchall ( ) ]
2025-02-20 13:41:38 -08:00
2025-02-19 07:53:26 -08:00
if not servers :
2025-07-11 06:24:56 -07:00
logger . warning ( f " No servers found for backend { domain [ ' backend_name ' ] } " )
2025-02-19 07:53:26 -08:00
continue
2025-03-06 16:51:29 -08:00
2025-02-21 10:17:15 -08:00
if domain [ ' template_override ' ] is not None :
2025-07-11 06:24:56 -07:00
logger . info ( f " Template Override is set to: { domain [ ' template_override ' ] } " )
2025-02-21 10:17:15 -08:00
template_file = domain [ ' template_override ' ] + " .tpl "
backend_block = template_env . get_template ( template_file ) . render (
2025-02-21 08:01:16 -08:00
name = domain [ ' backend_name ' ] ,
servers = servers
2025-03-06 16:51:29 -08:00
2025-02-21 08:01:16 -08:00
)
else :
2025-02-21 08:28:56 -08:00
backend_block = template_env . get_template ( ' hap_backend.tpl ' ) . render (
2025-02-21 08:01:16 -08:00
name = domain [ ' backend_name ' ] ,
2025-02-21 08:28:56 -08:00
ssl_enabled = domain [ ' ssl_enabled ' ] ,
2025-02-21 08:01:16 -08:00
servers = servers
)
2025-02-20 15:29:42 -08:00
config_backends . append ( backend_block )
2025-07-11 06:24:56 -07:00
logger . info ( f " Added backend block for: { domain [ ' backend_name ' ] } " )
2025-02-19 07:53:26 -08:00
except Exception as e :
2025-07-11 06:24:56 -07:00
logger . error ( f " Error generating backend block for { domain [ ' backend_name ' ] } : { e } " )
2025-02-19 07:53:26 -08:00
continue
2025-03-06 16:51:29 -08:00
2025-02-20 15:29:42 -08:00
# Add ACLS
config_parts . append ( ' \n ' . join ( config_acls ) )
2025-02-20 16:26:27 -08:00
# Add LetsEncrypt Backend
letsencrypt_backend = template_env . get_template ( ' hap_letsencrypt_backend.tpl ' ) . render ( )
config_parts . append ( letsencrypt_backend )
2025-07-11 19:10:05 -07:00
# Add Default Backend
try :
# Render the default page template with customizable content
default_page_template = template_env . get_template ( ' default_page.html ' )
default_page_content = default_page_template . render (
page_title = os . environ . get ( ' HAPROXY_DEFAULT_PAGE_TITLE ' , ' Site Not Configured ' ) ,
main_message = os . environ . get ( ' HAPROXY_DEFAULT_MAIN_MESSAGE ' , ' This domain has not been configured yet. Please contact your system administrator to set up this website. ' ) ,
secondary_message = os . environ . get ( ' HAPROXY_DEFAULT_SECONDARY_MESSAGE ' , ' If you believe this is an error, please check the domain name and try again. ' )
)
default_page_content = default_page_content . replace ( ' " ' , ' \\ " ' ) . replace ( ' \n ' , ' \\ n ' )
default_backend = template_env . get_template ( ' hap_default_backend.tpl ' ) . render (
default_page_content = default_page_content
)
config_parts . append ( default_backend )
except Exception as e :
logger . error ( f " Error generating default backend: { e } " )
# Fallback to a simple default backend
fallback_backend = ''' # Default backend for unmatched domains
backend default - backend
mode http
option http - server - close
http - response set - header Content - Type text / html
http - response set - body " <!DOCTYPE html><html><head><title>Site Not Configured</title></head><body><h1>Site Not Configured</h1><p>This domain has not been configured yet.</p></body></html> " '''
config_parts . append ( fallback_backend )
2025-02-20 15:29:42 -08:00
# Add Backends
2025-02-21 06:42:30 -08:00
config_parts . append ( ' \n ' . join ( config_backends ) + ' \n ' )
2025-03-06 16:51:29 -08:00
# Write complete configuration to tmp
2025-02-20 13:41:38 -08:00
temp_config_path = " /etc/haproxy/haproxy.cfg "
2025-02-19 07:53:26 -08:00
config_content = ' \n ' . join ( config_parts )
2025-07-11 06:24:56 -07:00
logger . debug ( " Generated HAProxy configuration " )
2025-02-20 13:41:38 -08:00
2025-02-19 07:53:26 -08:00
# Write complete configuration to tmp
# Check HAProxy Configuration, and reload if it works
2025-02-20 13:41:38 -08:00
with open ( temp_config_path , ' w ' ) as f :
f . write ( config_content )
2025-07-11 06:24:56 -07:00
result = subprocess . run ( [ ' haproxy ' , ' -c ' , ' -f ' , temp_config_path ] , capture_output = True , text = True )
2025-02-19 07:53:26 -08:00
if result . returncode == 0 :
2025-07-11 06:24:56 -07:00
logger . info ( " HAProxy configuration check passed " )
2025-02-19 07:53:26 -08:00
if is_process_running ( ' haproxy ' ) :
2025-07-11 06:24:56 -07:00
reload_result = subprocess . run ( ' echo " reload " | socat stdio /tmp/haproxy-cli ' ,
capture_output = True , text = True , shell = True )
if reload_result . returncode == 0 :
logger . info ( " HAProxy reloaded successfully " )
log_operation ( ' generate_config ' , True , ' Configuration generated and HAProxy reloaded ' )
else :
error_msg = f " HAProxy reload failed: { reload_result . stderr } "
logger . error ( error_msg )
log_operation ( ' generate_config ' , False , error_msg )
2025-02-19 07:53:26 -08:00
else :
try :
result = subprocess . run (
[ ' haproxy ' , ' -W ' , ' -S ' , ' /tmp/haproxy-cli,level,admin ' , ' -f ' , HAPROXY_CONFIG_PATH ] ,
check = True ,
capture_output = True ,
text = True
)
2025-02-21 06:28:51 -08:00
if result . returncode == 0 :
2025-07-11 06:24:56 -07:00
logger . info ( " HAProxy started successfully " )
log_operation ( ' generate_config ' , True , ' Configuration generated and HAProxy started ' )
2025-02-21 06:28:51 -08:00
else :
2025-07-11 06:24:56 -07:00
error_msg = f " HAProxy start command returned: { result . stdout } \n Error output: { result . stderr } "
logger . error ( error_msg )
log_operation ( ' generate_config ' , False , error_msg )
2025-02-19 07:53:26 -08:00
except subprocess . CalledProcessError as e :
2025-07-11 06:24:56 -07:00
error_msg = f " Failed to start HAProxy: { e . stdout } \n { e . stderr } "
logger . error ( error_msg )
log_operation ( ' generate_config ' , False , error_msg )
2025-02-19 07:53:26 -08:00
raise
2025-07-11 06:24:56 -07:00
else :
error_msg = f " HAProxy configuration check failed: { result . stderr } "
logger . error ( error_msg )
log_operation ( ' generate_config ' , False , error_msg )
raise Exception ( error_msg )
2025-02-19 07:53:26 -08:00
except Exception as e :
2025-07-11 06:24:56 -07:00
error_msg = f " Error generating config: { e } "
logger . error ( error_msg )
log_operation ( ' generate_config ' , False , error_msg )
2025-02-19 07:53:26 -08:00
import traceback
traceback . print_exc ( )
raise
2025-02-20 17:00:28 -08:00
def start_haproxy ( ) :
if not is_process_running ( ' haproxy ' ) :
try :
result = subprocess . run (
[ ' haproxy ' , ' -W ' , ' -S ' , ' /tmp/haproxy-cli,level,admin ' , ' -f ' , HAPROXY_CONFIG_PATH ] ,
check = True ,
capture_output = True ,
text = True
)
2025-02-21 06:28:51 -08:00
if result . returncode == 0 :
2025-07-11 06:24:56 -07:00
logger . info ( " HAProxy started successfully " )
log_operation ( ' start_haproxy ' , True , ' HAProxy started successfully ' )
2025-02-21 06:28:51 -08:00
else :
2025-07-11 06:24:56 -07:00
error_msg = f " HAProxy start command returned: { result . stdout } \n Error output: { result . stderr } "
logger . error ( error_msg )
log_operation ( ' start_haproxy ' , False , error_msg )
2025-02-20 17:00:28 -08:00
except subprocess . CalledProcessError as e :
2025-07-11 06:24:56 -07:00
error_msg = f " Failed to start HAProxy: { e . stdout } \n { e . stderr } "
logger . error ( error_msg )
log_operation ( ' start_haproxy ' , False , error_msg )
2025-02-20 17:00:28 -08:00
raise
2025-02-19 07:53:26 -08:00
if __name__ == ' __main__ ' :
init_db ( )
2025-02-20 14:01:53 -08:00
certbot_register ( )
2025-02-19 07:53:26 -08:00
generate_self_signed_cert ( SSL_CERTS_DIR )
2025-02-21 06:00:37 -08:00
start_haproxy ( )
certbot_register ( )
2025-02-19 07:53:26 -08:00
app . run ( host = ' 0.0.0.0 ' , port = 8000 )