mirror of
https://github.com/house-of-vanity/OutFleet.git
synced 2025-08-21 14:37:16 +00:00
Xray works
This commit is contained in:
403
vpn/letsencrypt/letsencrypt_dns.py
Normal file
403
vpn/letsencrypt/letsencrypt_dns.py
Normal file
@@ -0,0 +1,403 @@
|
||||
#!/usr/bin/env python3
|
||||
"""
|
||||
Let's Encrypt/ZeroSSL Certificate Library with Cloudflare DNS Challenge
|
||||
Generate publicly trusted SSL certificates using ACME DNS-01 challenge
|
||||
"""
|
||||
|
||||
import time
|
||||
import logging
|
||||
from typing import List, Tuple
|
||||
|
||||
from cryptography import x509
|
||||
from cryptography.x509.oid import NameOID
|
||||
from cryptography.hazmat.primitives import hashes, serialization
|
||||
from cryptography.hazmat.primitives.asymmetric import rsa
|
||||
from acme import client, messages, challenges, errors
|
||||
from acme.client import ClientV2
|
||||
import josepy as jose
|
||||
from cloudflare import Cloudflare
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
class AcmeDnsChallenge:
|
||||
"""ACME DNS-01 Challenge handler with Cloudflare API"""
|
||||
|
||||
def __init__(self, cloudflare_token: str, acme_directory: str = None):
|
||||
"""
|
||||
Initialize ACME DNS challenge handler
|
||||
|
||||
Args:
|
||||
cloudflare_token: Cloudflare API token with DNS edit permissions
|
||||
acme_directory: ACME directory URL (defaults to Let's Encrypt production)
|
||||
"""
|
||||
self.cf_token = cloudflare_token
|
||||
self.cf = Cloudflare(api_token=cloudflare_token)
|
||||
|
||||
# ACME directory URLs
|
||||
self.acme_directories = {
|
||||
'letsencrypt': 'https://acme-v02.api.letsencrypt.org/directory',
|
||||
'letsencrypt_staging': 'https://acme-staging-v02.api.letsencrypt.org/directory',
|
||||
'zerossl': 'https://acme.zerossl.com/v2/DV90'
|
||||
}
|
||||
|
||||
self.acme_directory = acme_directory or self.acme_directories['letsencrypt']
|
||||
self.acme_client = None
|
||||
self.account_key = None
|
||||
|
||||
def _generate_account_key(self) -> jose.JWKRSA:
|
||||
"""Generate RSA private key for ACME account"""
|
||||
# Generate cryptography key first
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=2048
|
||||
)
|
||||
# Convert to josepy format for ACME
|
||||
return jose.JWKRSA(key=private_key)
|
||||
|
||||
def _get_zone_id(self, domain: str) -> str:
|
||||
"""Get Cloudflare zone ID for domain"""
|
||||
try:
|
||||
# Get base domain (remove subdomains)
|
||||
parts = domain.split('.')
|
||||
if len(parts) >= 2:
|
||||
base_domain = '.'.join(parts[-2:])
|
||||
else:
|
||||
base_domain = domain
|
||||
|
||||
zones = self.cf.zones.list(name=base_domain)
|
||||
if not zones.result:
|
||||
raise ValueError(f"Domain {base_domain} not found in Cloudflare")
|
||||
|
||||
return zones.result[0].id
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to get zone ID for {domain}: {e}")
|
||||
raise
|
||||
|
||||
def _create_dns_record(self, domain: str, name: str, content: str) -> str:
|
||||
"""Create DNS TXT record for ACME challenge"""
|
||||
try:
|
||||
zone_id = self._get_zone_id(domain)
|
||||
|
||||
result = self.cf.dns.records.create(
|
||||
zone_id=zone_id,
|
||||
name=name,
|
||||
type='TXT',
|
||||
content=content,
|
||||
ttl=60 # 1 minute TTL for faster propagation
|
||||
)
|
||||
logger.info(f"Created DNS record: {name} = {content}")
|
||||
return result.id
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create DNS record {name}: {e}")
|
||||
raise
|
||||
|
||||
def _delete_dns_record(self, domain: str, record_id: str):
|
||||
"""Delete DNS TXT record"""
|
||||
try:
|
||||
zone_id = self._get_zone_id(domain)
|
||||
self.cf.dns.records.delete(zone_id=zone_id, dns_record_id=record_id)
|
||||
logger.info(f"Deleted DNS record: {record_id}")
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to delete DNS record {record_id}: {e}")
|
||||
|
||||
def _wait_for_dns_propagation(self, record_name: str, expected_value: str, wait_time: int = 20):
|
||||
"""Wait for DNS record to propagate - no local checks, just wait"""
|
||||
logger.info(f"Waiting {wait_time} seconds for DNS propagation of {record_name}...")
|
||||
logger.info(f"Record value: {expected_value}")
|
||||
logger.info("(No local DNS checks - Let's Encrypt servers will verify)")
|
||||
|
||||
time.sleep(wait_time)
|
||||
|
||||
logger.info("DNS propagation wait completed - proceeding with challenge")
|
||||
return True
|
||||
|
||||
def create_acme_client(self, email: str, accept_tos: bool = True) -> ClientV2:
|
||||
"""Create and register ACME client"""
|
||||
if self.acme_client:
|
||||
return self.acme_client
|
||||
|
||||
try:
|
||||
logger.info("Generating ACME account key...")
|
||||
# Generate account key
|
||||
self.account_key = self._generate_account_key()
|
||||
logger.info("Account key generated successfully")
|
||||
|
||||
logger.info(f"Connecting to ACME directory: {self.acme_directory}")
|
||||
# Create ACME client
|
||||
net = client.ClientNetwork(self.account_key, user_agent='letsencrypt-dns-lib/1.0')
|
||||
logger.info("Getting ACME directory...")
|
||||
directory_response = net.get(self.acme_directory)
|
||||
logger.info(f"Directory response status: {directory_response.status_code}")
|
||||
directory = messages.Directory.from_json(directory_response.json())
|
||||
logger.info("ACME directory loaded successfully")
|
||||
|
||||
self.acme_client = ClientV2(directory, net=net)
|
||||
logger.info("ACME client created successfully")
|
||||
|
||||
# Register account
|
||||
logger.info(f"Registering ACME account for email: {email}")
|
||||
try:
|
||||
registration = messages.NewRegistration.from_data(
|
||||
email=email,
|
||||
terms_of_service_agreed=accept_tos
|
||||
)
|
||||
logger.info("Sending account registration...")
|
||||
account = self.acme_client.new_account(registration)
|
||||
logger.info(f"ACME account registered: {account.uri}")
|
||||
|
||||
except errors.ConflictError as e:
|
||||
logger.info(f"Account already exists (ConflictError): {e}")
|
||||
# Account already exists
|
||||
account = self.acme_client.query_registration(messages.NewRegistration())
|
||||
logger.info("Using existing ACME account")
|
||||
except Exception as reg_e:
|
||||
logger.error(f"Account registration failed: {reg_e}")
|
||||
logger.error(f"Registration error type: {type(reg_e).__name__}")
|
||||
raise
|
||||
|
||||
return self.acme_client
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create ACME client: {e}")
|
||||
logger.error(f"Error type: {type(e).__name__}")
|
||||
import traceback
|
||||
logger.error(f"Full traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
def request_certificate(self, domains: List[str], email: str,
|
||||
key_size: int = 2048) -> Tuple[str, str]:
|
||||
"""
|
||||
Request certificate using DNS-01 challenge
|
||||
|
||||
Args:
|
||||
domains: List of domain names for certificate
|
||||
email: Email for ACME account registration
|
||||
key_size: RSA key size for certificate
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
logger.info(f"Requesting certificate for domains: {domains}")
|
||||
|
||||
try:
|
||||
# Create ACME client
|
||||
logger.info("Creating ACME client...")
|
||||
acme_client = self.create_acme_client(email)
|
||||
logger.info("ACME client created successfully")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed to create ACME client: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
try:
|
||||
# Generate private key for certificate
|
||||
logger.info(f"Generating {key_size}-bit RSA private key...")
|
||||
private_key = rsa.generate_private_key(
|
||||
public_exponent=65537,
|
||||
key_size=key_size
|
||||
)
|
||||
logger.info("Private key generated successfully")
|
||||
|
||||
# Create CSR
|
||||
logger.info(f"Creating CSR for domains: {domains}")
|
||||
csr_obj = x509.CertificateSigningRequestBuilder().subject_name(
|
||||
x509.Name([
|
||||
x509.NameAttribute(NameOID.COMMON_NAME, domains[0])
|
||||
])
|
||||
).add_extension(
|
||||
x509.SubjectAlternativeName([
|
||||
x509.DNSName(domain) for domain in domains
|
||||
]),
|
||||
critical=False
|
||||
).sign(private_key, hashes.SHA256())
|
||||
|
||||
# Convert CSR to PEM format for ACME
|
||||
csr_pem = csr_obj.public_bytes(serialization.Encoding.PEM)
|
||||
logger.info("CSR created successfully")
|
||||
|
||||
# Request certificate
|
||||
logger.info("Requesting certificate order from ACME...")
|
||||
order = acme_client.new_order(csr_pem)
|
||||
logger.info(f"Created ACME order: {order.uri}")
|
||||
except Exception as e:
|
||||
logger.error(f"Failed during CSR/order creation: {e}")
|
||||
import traceback
|
||||
logger.error(f"Traceback: {traceback.format_exc()}")
|
||||
raise
|
||||
|
||||
# Process challenges - collect all challenges first, then create DNS records
|
||||
dns_records = []
|
||||
challenges_to_answer = []
|
||||
|
||||
try:
|
||||
# First pass: collect all challenges and create DNS records
|
||||
for authorization in order.authorizations:
|
||||
domain = authorization.body.identifier.value
|
||||
logger.info(f"Processing authorization for: {domain}")
|
||||
|
||||
# Find DNS-01 challenge
|
||||
dns_challenge = None
|
||||
for challenge in authorization.body.challenges:
|
||||
if isinstance(challenge.chall, challenges.DNS01):
|
||||
dns_challenge = challenge
|
||||
break
|
||||
|
||||
if not dns_challenge:
|
||||
raise ValueError(f"No DNS-01 challenge found for {domain}")
|
||||
|
||||
# Calculate challenge response
|
||||
response, validation = dns_challenge.response_and_validation(acme_client.net.key)
|
||||
|
||||
# For wildcard domains, use base domain for DNS record
|
||||
if domain.startswith('*.'):
|
||||
dns_domain = domain[2:] # Remove *. prefix
|
||||
else:
|
||||
dns_domain = domain
|
||||
|
||||
# Create DNS record
|
||||
record_name = f"_acme-challenge.{dns_domain}"
|
||||
|
||||
# Check if we already created this DNS record
|
||||
existing_record = None
|
||||
for existing_domain, existing_id, existing_validation in dns_records:
|
||||
if existing_domain == dns_domain:
|
||||
existing_record = (existing_domain, existing_id, existing_validation)
|
||||
break
|
||||
|
||||
if existing_record:
|
||||
logger.info(f"DNS record already exists for {dns_domain}, reusing...")
|
||||
record_id = existing_record[1]
|
||||
# Verify the validation value matches
|
||||
if existing_record[2] != validation:
|
||||
logger.warning(f"Validation values differ for {dns_domain}! This may cause issues.")
|
||||
else:
|
||||
logger.info(f"Creating DNS record for {dns_domain}...")
|
||||
record_id = self._create_dns_record(dns_domain, record_name, validation)
|
||||
dns_records.append((dns_domain, record_id, validation))
|
||||
|
||||
# Store challenge to answer later
|
||||
challenges_to_answer.append((dns_challenge, response, domain, dns_domain))
|
||||
|
||||
# Wait for DNS propagation once for all records
|
||||
if dns_records:
|
||||
logger.info(f"Waiting for DNS propagation for {len(dns_records)} DNS records...")
|
||||
for dns_domain, record_id, validation in dns_records:
|
||||
record_name = f"_acme-challenge.{dns_domain}"
|
||||
self._wait_for_dns_propagation(record_name, validation)
|
||||
|
||||
# Second pass: answer all challenges
|
||||
for dns_challenge, response, domain, dns_domain in challenges_to_answer:
|
||||
logger.info(f"Responding to DNS challenge for {domain}...")
|
||||
challenge_response = acme_client.answer_challenge(dns_challenge, response)
|
||||
logger.info(f"Challenge response sent for {domain}")
|
||||
|
||||
# Finalize order
|
||||
logger.info("Finalizing certificate order...")
|
||||
order = acme_client.poll_and_finalize(order)
|
||||
|
||||
# Get certificate
|
||||
certificate_pem = order.fullchain_pem
|
||||
private_key_pem = private_key.private_bytes(
|
||||
encoding=serialization.Encoding.PEM,
|
||||
format=serialization.PrivateFormat.PKCS8,
|
||||
encryption_algorithm=serialization.NoEncryption()
|
||||
).decode('utf-8')
|
||||
|
||||
logger.info("Certificate obtained successfully!")
|
||||
return certificate_pem, private_key_pem
|
||||
|
||||
finally:
|
||||
# Clean up DNS records
|
||||
for dns_domain, record_id, validation in dns_records:
|
||||
try:
|
||||
self._delete_dns_record(dns_domain, record_id)
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to cleanup DNS record for {dns_domain}: {e}")
|
||||
|
||||
def get_certificate(domains: List[str], email: str, cloudflare_token: str,
|
||||
provider: str = 'letsencrypt', staging: bool = False) -> Tuple[str, str]:
|
||||
"""
|
||||
Simple function to get Let's Encrypt/ZeroSSL certificate
|
||||
|
||||
Args:
|
||||
domains: List of domains for certificate
|
||||
email: Email for ACME registration
|
||||
cloudflare_token: Cloudflare API token
|
||||
provider: 'letsencrypt' or 'zerossl'
|
||||
staging: Use staging environment (for testing)
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
# Select ACME directory
|
||||
acme_dns = AcmeDnsChallenge(cloudflare_token)
|
||||
|
||||
if provider == 'letsencrypt':
|
||||
if staging:
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt_staging']
|
||||
else:
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['letsencrypt']
|
||||
elif provider == 'zerossl':
|
||||
acme_dns.acme_directory = acme_dns.acme_directories['zerossl']
|
||||
else:
|
||||
raise ValueError("Provider must be 'letsencrypt' or 'zerossl'")
|
||||
|
||||
return acme_dns.request_certificate(domains, email)
|
||||
|
||||
def get_certificate_for_domain(domain: str, email: str, cloudflare_token: str,
|
||||
include_wildcard: bool = False, **kwargs) -> Tuple[str, str]:
|
||||
"""
|
||||
Helper function to get certificate for single domain (compatible with Cloudflare cert lib)
|
||||
|
||||
Args:
|
||||
domain: Primary domain
|
||||
email: Email for ACME registration
|
||||
cloudflare_token: Cloudflare API token
|
||||
include_wildcard: Include wildcard subdomain
|
||||
**kwargs: Additional arguments (provider, staging)
|
||||
|
||||
Returns:
|
||||
Tuple of (certificate_pem, private_key_pem)
|
||||
"""
|
||||
domains = [domain]
|
||||
if include_wildcard:
|
||||
domains.append(f"*.{domain}")
|
||||
|
||||
return get_certificate(domains, email, cloudflare_token, **kwargs)
|
||||
|
||||
if __name__ == "__main__":
|
||||
# Example usage
|
||||
import sys
|
||||
|
||||
if len(sys.argv) != 4:
|
||||
print("Usage: python letsencrypt_dns.py <domain> <email> <cloudflare_token>")
|
||||
sys.exit(1)
|
||||
|
||||
domain, email, token = sys.argv[1:4]
|
||||
|
||||
try:
|
||||
cert_pem, key_pem = get_certificate_for_domain(
|
||||
domain=domain,
|
||||
email=email,
|
||||
cloudflare_token=token,
|
||||
include_wildcard=True,
|
||||
staging=True # Use staging for testing
|
||||
)
|
||||
|
||||
print(f"Certificate obtained for {domain}")
|
||||
print(f"Certificate length: {len(cert_pem)} bytes")
|
||||
print(f"Private key length: {len(key_pem)} bytes")
|
||||
|
||||
# Save to files
|
||||
with open(f"{domain}.crt", 'w') as f:
|
||||
f.write(cert_pem)
|
||||
with open(f"{domain}.key", 'w') as f:
|
||||
f.write(key_pem)
|
||||
|
||||
print(f"Saved: {domain}.crt, {domain}.key")
|
||||
|
||||
except Exception as e:
|
||||
print(f"Error: {e}")
|
||||
sys.exit(1)
|
Reference in New Issue
Block a user