mirror of
https://github.com/jhd3197/ServerKit.git
synced 2026-11-09 21:43:05 +09:00
- run wildcard/SAN certbot privileged and fail on non-zero exit instead of reporting unissued cert paths as success - cloudflare DNS-01 credentials: unpredictable mkstemp file, 0600, always deleted (was predictable /tmp path, never cleaned up) - route53 credentials via sudo env A=B (sudo env_reset strips env= kwarg) - validate domains before they reach certbot argv or cert file paths - validate reverse-proxy builder input (domains, upstreams, headers, locations, rate-limit/cache values) against nginx config injection - write_vhost: refuse non-plain-filename site names and roll back the vhost when the reload itself fails, not only when nginx -t fails - create_proxy API returns 400 on service errors instead of always 201
286 lines
12 KiB
Python
286 lines
12 KiB
Python
import json
|
|
import logging
|
|
import subprocess
|
|
import tempfile
|
|
from datetime import datetime
|
|
from app.utils.system import run_privileged, run_unprivileged
|
|
from app.services.nginx_service import _validate_domain
|
|
from app.services.ssl_service import resolve_certbot_bin
|
|
|
|
|
|
def _certbot_bin():
|
|
"""Resolved certbot binary, falling back to the bare name (PATH lookup)."""
|
|
return resolve_certbot_bin() or 'certbot'
|
|
|
|
logger = logging.getLogger(__name__)
|
|
|
|
|
|
class AdvancedSSLService:
|
|
"""Service for advanced SSL certificate features."""
|
|
|
|
SSL_PROFILES = {
|
|
'modern': {
|
|
'label': 'Modern (TLS 1.3 only)',
|
|
'protocols': 'TLSv1.3',
|
|
'ciphers': '',
|
|
'description': 'Best security. Supports only modern browsers.',
|
|
},
|
|
'intermediate': {
|
|
'label': 'Intermediate (TLS 1.2+)',
|
|
'protocols': 'TLSv1.2 TLSv1.3',
|
|
'ciphers': 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384',
|
|
'description': 'Recommended for most servers. Good compatibility.',
|
|
},
|
|
'legacy': {
|
|
'label': 'Legacy (TLS 1.0+)',
|
|
'protocols': 'TLSv1 TLSv1.1 TLSv1.2 TLSv1.3',
|
|
'ciphers': 'ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-AES128-SHA256:ECDHE-RSA-AES128-SHA256',
|
|
'description': 'Maximum compatibility. Supports old clients.',
|
|
},
|
|
}
|
|
|
|
@staticmethod
|
|
def get_ssl_profiles():
|
|
return AdvancedSSLService.SSL_PROFILES
|
|
|
|
@staticmethod
|
|
def issue_wildcard_cert(domain, dns_provider, credentials, email=None):
|
|
"""Issue a wildcard cert (domain + *.domain) via Let's Encrypt DNS-01.
|
|
|
|
Supports cloudflare (API token) and route53 (AWS key/secret). The certbot
|
|
DNS plugin matching the system certbot is installed best-effort first.
|
|
Returns the on-disk cert/key paths on success.
|
|
"""
|
|
import os
|
|
from app.utils.system import PackageManager
|
|
|
|
if dns_provider not in ('cloudflare', 'route53'):
|
|
return {'success': False, 'error': f'Unsupported DNS provider: {dns_provider}'}
|
|
# The domain becomes certbot argv below — reject anything that is not a
|
|
# plain domain so a crafted value cannot be parsed as certbot flags.
|
|
if not _validate_domain(domain):
|
|
return {'success': False, 'error': f'Invalid domain: {domain}'}
|
|
|
|
wildcard = f'*.{domain}'
|
|
cmd = [_certbot_bin(), 'certonly', '--non-interactive', '--agree-tos',
|
|
'--dns-' + dns_provider, '-d', domain, '-d', wildcard]
|
|
cmd.extend(['--email', email] if email else ['--register-unsafely-without-email'])
|
|
|
|
# Best-effort: ensure the DNS plugin matching the system certbot exists.
|
|
if PackageManager.is_available():
|
|
try:
|
|
PackageManager.install([f'python3-certbot-dns-{dns_provider}'], timeout=300)
|
|
except Exception:
|
|
pass # certbot reports a clear error below if the plugin is truly missing
|
|
|
|
# certbot writes /etc/letsencrypt as root, so it runs through
|
|
# run_privileged — and the credentials must survive sudo. cloudflare
|
|
# gets an unpredictable root-readable file that is always deleted
|
|
# afterwards; route53 goes through `sudo env A=B …` because sudo's
|
|
# env_reset would silently strip a plain env= kwarg.
|
|
cred_file = None
|
|
if dns_provider == 'cloudflare':
|
|
fd, cred_file = tempfile.mkstemp(prefix='certbot-cloudflare-', suffix='.ini')
|
|
with os.fdopen(fd, 'w') as f:
|
|
f.write(f"dns_cloudflare_api_token = {credentials.get('api_token', '')}\n")
|
|
if os.name != 'nt':
|
|
os.chmod(cred_file, 0o600)
|
|
cmd.extend(['--dns-cloudflare-credentials', cred_file])
|
|
elif dns_provider == 'route53':
|
|
cmd = ['env',
|
|
f"AWS_ACCESS_KEY_ID={credentials.get('api_key', '')}",
|
|
f"AWS_SECRET_ACCESS_KEY={credentials.get('api_secret', '')}",
|
|
] + cmd
|
|
|
|
try:
|
|
result = run_privileged(cmd, timeout=600)
|
|
if result.returncode != 0:
|
|
# A failed certbot run is a failure, full stop — never report
|
|
# the not-issued cert paths as if issuance had succeeded.
|
|
return {'success': False,
|
|
'error': (result.stderr or result.stdout
|
|
or 'certbot failed').strip()}
|
|
return {
|
|
'success': True, 'domain': domain, 'type': 'wildcard',
|
|
'certificate_path': f'/etc/letsencrypt/live/{domain}/fullchain.pem',
|
|
'private_key_path': f'/etc/letsencrypt/live/{domain}/privkey.pem',
|
|
'output': result.stdout or '',
|
|
}
|
|
except subprocess.TimeoutExpired:
|
|
return {'success': False, 'error': 'Certificate request timed out'}
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
finally:
|
|
if cred_file:
|
|
try:
|
|
os.remove(cred_file)
|
|
except OSError:
|
|
pass
|
|
|
|
@staticmethod
|
|
def issue_san_cert(domains):
|
|
"""Issue multi-domain (SAN) certificate."""
|
|
if not domains or len(domains) < 1:
|
|
raise ValueError('At least one domain required')
|
|
|
|
invalid = [d for d in domains if not isinstance(d, str)
|
|
or not _validate_domain(d)]
|
|
if invalid:
|
|
return {'success': False,
|
|
'error': f'Invalid domain(s): {", ".join(map(str, invalid))}'}
|
|
|
|
cmd = [_certbot_bin(), 'certonly', '--non-interactive', '--agree-tos',
|
|
'--webroot', '-w', '/var/www/html']
|
|
for d in domains:
|
|
cmd.extend(['-d', d])
|
|
|
|
try:
|
|
result = run_privileged(cmd, timeout=600)
|
|
if result.returncode != 0:
|
|
return {'success': False,
|
|
'error': (result.stderr or result.stdout
|
|
or 'certbot failed').strip()}
|
|
return {'success': True, 'domains': domains, 'type': 'san',
|
|
'output': result.stdout or ''}
|
|
except subprocess.TimeoutExpired:
|
|
return {'success': False, 'error': 'Certificate request timed out'}
|
|
except Exception as e:
|
|
return {'success': False, 'error': str(e)}
|
|
|
|
@staticmethod
|
|
def upload_custom_cert(domain, cert_pem, key_pem, chain_pem=None):
|
|
"""Upload custom certificate files."""
|
|
import os
|
|
# The domain is joined into the cert directory path below — refuse
|
|
# anything that is not a plain domain so it cannot traverse out of
|
|
# /etc/ssl/serverkit.
|
|
if not _validate_domain(domain):
|
|
raise ValueError(f'Invalid domain: {domain}')
|
|
cert_dir = f'/etc/ssl/serverkit/{domain}'
|
|
os.makedirs(cert_dir, exist_ok=True)
|
|
|
|
cert_path = os.path.join(cert_dir, 'cert.pem')
|
|
key_path = os.path.join(cert_dir, 'key.pem')
|
|
chain_path = os.path.join(cert_dir, 'chain.pem')
|
|
|
|
with open(cert_path, 'w') as f:
|
|
f.write(cert_pem)
|
|
with open(key_path, 'w') as f:
|
|
f.write(key_pem)
|
|
if os.name != 'nt':
|
|
os.chmod(key_path, 0o600)
|
|
if chain_pem:
|
|
with open(chain_path, 'w') as f:
|
|
f.write(chain_pem)
|
|
|
|
return {
|
|
'domain': domain,
|
|
'cert_path': cert_path,
|
|
'key_path': key_path,
|
|
'chain_path': chain_path if chain_pem else None,
|
|
}
|
|
|
|
@staticmethod
|
|
def get_cert_health(domain):
|
|
"""Check SSL health — grade, cipher suites, protocol versions."""
|
|
import ssl
|
|
import socket
|
|
from datetime import timezone
|
|
|
|
result = {
|
|
'domain': domain,
|
|
'valid': False,
|
|
'grade': 'F',
|
|
'protocols': [],
|
|
'cipher_suites': [],
|
|
'issuer': None,
|
|
'expires_at': None,
|
|
'days_remaining': None,
|
|
}
|
|
|
|
try:
|
|
ctx = ssl.create_default_context()
|
|
with socket.create_connection((domain, 443), timeout=10) as sock:
|
|
with ctx.wrap_socket(sock, server_hostname=domain) as ssock:
|
|
cert = ssock.getpeercert()
|
|
cipher = ssock.cipher()
|
|
|
|
result['valid'] = True
|
|
result['cipher_suites'] = [cipher[0]] if cipher else []
|
|
result['protocols'] = [ssock.version()]
|
|
|
|
# Parse expiry
|
|
not_after = cert.get('notAfter')
|
|
if not_after:
|
|
expiry = datetime.strptime(not_after, '%b %d %H:%M:%S %Y %Z')
|
|
result['expires_at'] = expiry.isoformat()
|
|
result['days_remaining'] = (expiry - datetime.utcnow()).days
|
|
|
|
# Issuer
|
|
issuer = cert.get('issuer', ())
|
|
for field in issuer:
|
|
for k, v in field:
|
|
if k == 'organizationName':
|
|
result['issuer'] = v
|
|
|
|
# Simple grading
|
|
version = ssock.version()
|
|
if version == 'TLSv1.3':
|
|
result['grade'] = 'A+'
|
|
elif version == 'TLSv1.2':
|
|
result['grade'] = 'A'
|
|
elif version == 'TLSv1.1':
|
|
result['grade'] = 'B'
|
|
else:
|
|
result['grade'] = 'C'
|
|
|
|
except Exception as e:
|
|
# The probe could not determine anything — report unknown, never
|
|
# the template's valid: False / grade: 'F' (a determined
|
|
# "terrible TLS config" for what may be a DNS timeout).
|
|
result['valid'] = None
|
|
result['grade'] = None
|
|
result['error'] = str(e)
|
|
|
|
return result
|
|
|
|
@staticmethod
|
|
def get_expiry_alerts(days_threshold=30):
|
|
"""Get certificates expiring within threshold days."""
|
|
import os
|
|
import glob
|
|
|
|
alerts = []
|
|
cert_paths = glob.glob('/etc/letsencrypt/live/*/cert.pem')
|
|
cert_paths += glob.glob('/etc/ssl/serverkit/*/cert.pem')
|
|
|
|
for cert_path in cert_paths:
|
|
domain = os.path.basename(os.path.dirname(cert_path))
|
|
try:
|
|
result = run_unprivileged(['openssl', 'x509', '-enddate', '-noout', '-in', cert_path])
|
|
stdout = result.get('stdout', '')
|
|
if 'notAfter=' in stdout:
|
|
date_str = stdout.split('notAfter=')[1].strip()
|
|
expiry = datetime.strptime(date_str, '%b %d %H:%M:%S %Y %Z')
|
|
days = (expiry - datetime.utcnow()).days
|
|
if days <= days_threshold:
|
|
alerts.append({
|
|
'domain': domain,
|
|
'expires_at': expiry.isoformat(),
|
|
'days_remaining': days,
|
|
'severity': 'critical' if days <= 7 else 'warning',
|
|
})
|
|
except Exception as e:
|
|
# An unprobeable cert is not "not expiring" — surface it as
|
|
# unknown instead of dropping it from the alert list.
|
|
alerts.append({
|
|
'domain': domain,
|
|
'expires_at': None,
|
|
'days_remaining': None,
|
|
'severity': 'unknown',
|
|
'error': str(e),
|
|
})
|
|
|
|
return sorted(alerts, key=lambda x: (
|
|
x['days_remaining'] if x.get('days_remaining') is not None else 999))
|