diff --git a/custom-domain/dstack-ingress/DNS_PROVIDERS.md b/custom-domain/dstack-ingress/DNS_PROVIDERS.md index 8a3f551..ee70c7e 100644 --- a/custom-domain/dstack-ingress/DNS_PROVIDERS.md +++ b/custom-domain/dstack-ingress/DNS_PROVIDERS.md @@ -6,6 +6,7 @@ This guide explains how to configure dstack-ingress to work with different DNS p - **Cloudflare** - The original and default provider - **Linode DNS** - For Linode-hosted domains +- **Namecheap** - For Namecheap-hosted domains ## Environment Variables @@ -15,7 +16,7 @@ This guide explains how to configure dstack-ingress to work with different DNS p - `GATEWAY_DOMAIN` - dstack gateway domain (e.g., `_.dstack-prod5.phala.network`) - `CERTBOT_EMAIL` - Email for Let's Encrypt registration - `TARGET_ENDPOINT` - Backend application endpoint to proxy to -- `DNS_PROVIDER` - DNS provider to use (`cloudflare`, `linode`) +- `DNS_PROVIDER` - DNS provider to use (`cloudflare`, `linode`, `namecheap`) ### Optional Variables @@ -53,7 +54,28 @@ LINODE_API_TOKEN=your-api-token - If resolution fails, it falls back to CNAME (but CAA records won't work on that subdomain) - This is a Linode-specific limitation not present in other providers -## Docker Compose Example +### Namecheap + +```bash +DNS_PROVIDER=namecheap +NAMECHEAP_USERNAME=your-username +NAMECHEAP_API_KEY=your-api-key +NAMECHEAP_CLIENT_IP=your-client-ip +``` + +**Required Credentials:** +- `NAMECHEAP_USERNAME` - Your Namecheap account username +- `NAMECHEAP_API_KEY` - Your Namecheap API key (from https://ap.www.namecheap.com/settings/tools/apiaccess/) +- `NAMECHEAP_CLIENT_IP` - The IP address of the node (required for Namecheap API authentication) + +**Important Notes for Namecheap:** +- Namecheap API requires node IP address for authentication, and you need add it to whitelist IP first. +- Namecheap doesn't support CAA records through their API currently +- The certbot plugin uses the format `certbot-dns-namecheap` package + +## Docker Compose Examples + +### Linode Example ```yaml version: '3.8' @@ -78,6 +100,33 @@ services: - ./evidences:/evidences ``` +### Namecheap Example + +```yaml +version: '3.8' + +services: + ingress: + image: dstack-ingress:latest + ports: + - "443:443" + environment: + # Common configuration + - DNS_PROVIDER=namecheap + - DOMAIN=app.example.com + - GATEWAY_DOMAIN=_.dstack-prod5.phala.network + - CERTBOT_EMAIL=admin@example.com + - TARGET_ENDPOINT=http://backend:8080 + + # Namecheap specific + - NAMECHEAP_USERNAME=your-username + - NAMECHEAP_API_KEY=your-api-key + - NAMECHEAP_CLIENT_IP=your-public-ip + volumes: + - ./letsencrypt:/etc/letsencrypt + - ./evidences:/evidences +``` + ## Migration from Cloudflare-only Setup If you're currently using the Cloudflare-only version: @@ -111,4 +160,10 @@ Ensure your API tokens/credentials have the necessary permissions listed above f ### Linode 1. Go to https://cloud.linode.com/profile/tokens 2. Create a Personal Access Token -3. Grant "Domains" Read/Write access \ No newline at end of file +3. Grant "Domains" Read/Write access + +### Namecheap +1. Go to https://ap.www.namecheap.com/settings/tools/api-access/ +2. Enable API access for your account +3. Note down your API key and username +4. Make sure your IP address is whitelisted in the API settings \ No newline at end of file diff --git a/custom-domain/dstack-ingress/Dockerfile b/custom-domain/dstack-ingress/Dockerfile index 4ea1b0d..9f49519 100644 --- a/custom-domain/dstack-ingress/Dockerfile +++ b/custom-domain/dstack-ingress/Dockerfile @@ -18,7 +18,6 @@ RUN set -e; \ done && \ apt-get update && \ apt-get install -y --no-install-recommends \ - certbot \ openssl \ bash \ python3-pip \ @@ -32,6 +31,17 @@ RUN set -e; \ RUN mkdir -p /etc/letsencrypt /var/www/certbot /usr/share/nginx/html +# Set up Python virtual environment and install certbot +RUN set -e; \ + python3 -m venv --system-site-packages /opt/app-venv && \ + . /opt/app-venv/bin/activate && \ + pip install --upgrade pip && \ + pip install certbot requests && \ + # Create symlinks for system-wide access + ln -sf /opt/app-venv/bin/certbot /usr/local/bin/certbot && \ + # Ensure the virtual environment is always activated for scripts + echo 'source /opt/app-venv/bin/activate' > /etc/profile.d/app-venv.sh + COPY ./scripts /scripts/ RUN chmod +x /scripts/*.sh /scripts/*.py ENV PATH="/scripts:$PATH" diff --git a/custom-domain/dstack-ingress/scripts/certman.py b/custom-domain/dstack-ingress/scripts/certman.py index d2eba1b..68809e8 100644 --- a/custom-domain/dstack-ingress/scripts/certman.py +++ b/custom-domain/dstack-ingress/scripts/certman.py @@ -1,15 +1,18 @@ #!/usr/bin/env python3 -from dns_providers import DNSProviderFactory import argparse import os import subprocess import sys +import pkg_resources from typing import List, Optional, Tuple # Add script directory to path to import dns_providers sys.path.insert(0, os.path.dirname(os.path.abspath(__file__))) +from .dns_providers.base import DNSProvider +from dns_providers import DNSProviderFactory + class CertManager: """Certificate management using DNS provider infrastructure.""" @@ -30,29 +33,218 @@ def install_plugin(self) -> bool: print(f"No certbot package defined for {self.provider_type}") return False + # First ensure certbot is installed in the current environment + self._ensure_certbot_in_env() + + # Check if plugin is already installed + try: + __import__(self.provider.CERTBOT_PLUGIN_MODULE) + print(f"Plugin {self.provider.CERTBOT_PACKAGE} is already installed") + return True + except ImportError: + pass # Plugin not installed, continue with installation + print(f"Installing certbot plugin: {self.provider.CERTBOT_PACKAGE}") - # Use virtual environment pip if available - pip_cmd = ["pip", "install", self.provider.CERTBOT_PACKAGE] + # Try multiple installation methods + install_methods = [] + + # Method 1: Use the same python executable that's running this script + install_methods.append([sys.executable, "-m", "pip", "install", self.provider.CERTBOT_PACKAGE]) + + # Method 2: Use virtual environment pip if available if "VIRTUAL_ENV" in os.environ: venv_pip = os.path.join(os.environ["VIRTUAL_ENV"], "bin", "pip") if os.path.exists(venv_pip): - pip_cmd[0] = venv_pip - + install_methods.append([venv_pip, "install", self.provider.CERTBOT_PACKAGE]) + + # Method 3: Use system pip + install_methods.append(["pip", "install", self.provider.CERTBOT_PACKAGE]) + + # Method 4: Use pip3 + install_methods.append(["pip3", "install", self.provider.CERTBOT_PACKAGE]) + + success = False + for i, pip_cmd in enumerate(install_methods): + print(f"Trying installation method {i+1}") + print(f"Command: {' '.join(pip_cmd)}") + try: + result = subprocess.run(pip_cmd, capture_output=True, text=True) + if result.returncode == 0: + print(f"Installation method {i+1} succeeded") + success = True + break + else: + print(f"Installation method {i+1} failed: {result.stderr}") + except Exception as e: + print(f"Installation method {i+1} exception: {e}") + + if not success: + print(f"All installation methods failed", file=sys.stderr) + return False + + print(f"Successfully installed {self.provider.CERTBOT_PACKAGE}") + + # Diagnostic information for troubleshooting try: - result = subprocess.run(pip_cmd, capture_output=True, text=True) - if result.returncode != 0: - print(f"Failed to install plugin: {result.stderr}", file=sys.stderr) - return False - print(f"Successfully installed {self.provider.CERTBOT_PACKAGE}") - return True + print(f"Installed to Python: {sys.executable}") + + # Show certbot command + certbot_cmd = self._get_certbot_command() + print(f"Using certbot: {' '.join(certbot_cmd)}") + + try: + dist = pkg_resources.get_distribution(self.provider.CERTBOT_PACKAGE) + print(f"Package version: {dist.version} at {dist.location}") + except pkg_resources.DistributionNotFound: + print("Warning: Package not found in current environment") + except Exception as diag_error: + print(f"Diagnostic error: {diag_error}") + + # Verify plugin installation + try: + __import__(self.provider.CERTBOT_PLUGIN_MODULE) + print(f"Plugin {self.provider.CERTBOT_PLUGIN} successfully imported") + + # Test if plugin is recognized by certbot + certbot_cmd = self._get_certbot_command() + test_cmd = certbot_cmd + ["plugins"] + test_result = subprocess.run(test_cmd, capture_output=True, text=True, timeout=10) + + if test_result.returncode == 0 and self.provider.CERTBOT_PLUGIN in test_result.stdout: + print(f"✓ Plugin {self.provider.CERTBOT_PLUGIN} is available in certbot") + return True + else: + print(f"Warning: {self.provider.CERTBOT_PLUGIN} plugin not found in certbot plugins list") + if test_result.stderr: + print(f"Plugin test stderr: {test_result.stderr}") + + # Debug plugin registration + self._debug_plugin_registration() + + # Try force reinstall to fix plugin registration + print("Attempting to fix plugin registration...") + try: + force_cmd = [sys.executable, "-m", "pip", "install", "--force-reinstall", + "--no-deps", self.provider.CERTBOT_PACKAGE] + print(f"Running: {' '.join(force_cmd)}") + force_result = subprocess.run(force_cmd, capture_output=True, text=True) + + if force_result.returncode == 0: + # Test again after reinstall + retest_cmd = certbot_cmd + ["plugins"] + retest_result = subprocess.run(retest_cmd, capture_output=True, text=True, timeout=10) + if retest_result.returncode == 0 and self.provider.CERTBOT_PLUGIN in retest_result.stdout: + print(f"✓ Plugin registration fixed after reinstall") + return True + else: + print(f"Plugin still not registered, may work anyway") + else: + print(f"Force reinstall failed: {force_result.stderr}") + except Exception as fix_error: + print(f"Plugin fix attempt failed: {fix_error}") + + # Continue anyway - may work in Docker environments + return True + except Exception as e: - print(f"Error installing plugin: {e}", file=sys.stderr) - return False + print(f"Plugin verification warning: {e}") + return True + def _ensure_certbot_in_env(self) -> None: + """Ensure certbot is installed in the current Python environment.""" + + # Try to import certbot to check if it's installed + try: + import certbot + print(f"✓ Certbot module available in current environment") + return + except ImportError: + print(f"Certbot module not found, installing...") + + # Install certbot if not available + try: + install_cmd = [sys.executable, "-m", "pip", "install", "certbot"] + print(f"Running: {' '.join(install_cmd)}") + result = subprocess.run(install_cmd, capture_output=True, text=True) + + if result.returncode == 0: + print(f"✓ Certbot installed successfully in current environment") + else: + print(f"Failed to install certbot: {result.stderr}") + # Continue anyway - may still work + except Exception as e: + print(f"Error installing certbot: {e}") + # Continue anyway - may still work + + def _get_certbot_command(self) -> List[str]: + """Get the correct certbot command that uses the same Python environment.""" + + # Always use certbot from the same Python environment + python_dir = os.path.dirname(sys.executable) + venv_certbot = os.path.join(python_dir, "certbot") + + if os.path.exists(venv_certbot): + cmd = [venv_certbot] + print(f"Using certbot from virtual environment: {venv_certbot}") + return cmd + + # If certbot doesn't exist in venv, this is an error condition + raise RuntimeError( + f"Certbot not found in virtual environment: {venv_certbot}. " + f"This indicates the environment setup failed. " + f"Python executable: {sys.executable}" + ) + + def _debug_plugin_registration(self) -> None: + """Debug why plugin is not being registered by certbot.""" + try: + import pkg_resources + print("=== Plugin Registration Debug ===") + + # Show which certbot we're using + certbot_cmd = self._get_certbot_command() + print(f"Using certbot: {' '.join(certbot_cmd)}") + + # Check entry points + try: + entry_points = list(pkg_resources.iter_entry_points('certbot.plugins')) + print(f"Found {len(entry_points)} certbot plugins:") + for ep in entry_points: + print(f" - {ep.name}: {ep.module_name}") + + # Look specifically for our plugin + plugin_eps = [ep for ep in entry_points if ep.name == self.provider.CERTBOT_PLUGIN] + if plugin_eps: + print(f"✓ Found {self.provider.CERTBOT_PLUGIN} entry point: {plugin_eps[0]}") + else: + print(f"✗ {self.provider.CERTBOT_PLUGIN} entry point not found") + except Exception as ep_error: + print(f"Entry point check failed: {ep_error}") + + # Check if certbot can import the plugin module + try: + imported_module = __import__(self.provider.CERTBOT_PLUGIN_MODULE) + print(f"✓ Plugin module can be imported") + + # Check if it has the right class + if hasattr(imported_module, 'Authenticator'): + print(f"✓ Authenticator class found") + else: + print(f"✗ Authenticator class not found") + except Exception as import_error: + print(f"✗ Plugin module import failed: {import_error}") + + print("=== End Debug ===") + except Exception as debug_error: + print(f"Debug failed: {debug_error}") + def setup_credentials(self) -> bool: """Setup credentials file for certbot using provider implementation.""" - return self.provider.setup_certbot_credentials() + result = self.provider.setup_certbot_credentials() + if not result: + print(f"Failed to setup credentials file for {self.provider_type}") + return result def _build_certbot_command(self, action: str, domain: str, email: str) -> List[str]: """Build certbot command using provider configuration.""" @@ -60,50 +252,80 @@ def _build_certbot_command(self, action: str, domain: str, email: str) -> List[s if not plugin: raise ValueError(f"No certbot plugin configured for {self.provider_type}") - propagation_seconds = self.provider.CERTBOT_PROPAGATION_SECONDS - - base_cmd = ["certbot", action] + # Use Python module execution to ensure same environment + certbot_cmd = self._get_certbot_command() + base_cmd = certbot_cmd + [action, "-a", plugin, "--non-interactive", "-v"] - # Add DNS plugin configuration - base_cmd.extend( - [ - f"--{plugin}", - f"--{plugin}-propagation-seconds", - str(propagation_seconds), - "--non-interactive", - ] - ) - - # Add credentials file if provider has one configured + # Add credentials file if configured if self.provider.CERTBOT_CREDENTIALS_FILE: - credentials_file = os.path.expanduser( - self.provider.CERTBOT_CREDENTIALS_FILE - ) + credentials_file = os.path.expanduser(self.provider.CERTBOT_CREDENTIALS_FILE) if os.path.exists(credentials_file): - base_cmd.extend([f"--{plugin}-credentials", credentials_file]) + base_cmd.extend([f"--{plugin}-credentials={credentials_file}"]) + else: + raise ValueError(f"Credentials file does not exist: {credentials_file}") if action == "certonly": - base_cmd.extend( - ["--email", email, "--agree-tos", "--no-eff-email", "-d", domain] - ) + base_cmd.extend(["--agree-tos", "--no-eff-email", "--email", email, "-d", domain]) + # Log command with masked email for debugging + masked_cmd = [arg if not (i > 0 and base_cmd[i-1] == "--email") else "" + for i, arg in enumerate(base_cmd)] + print(f"Executing: {' '.join(masked_cmd)}") + return base_cmd def obtain_certificate(self, domain: str, email: str) -> bool: """Obtain a new certificate for the domain.""" - print(f"Obtaining new certificate for {domain} using {self.provider_type}") + print(f"Obtaining certificate for {domain} using {self.provider_type}") + + # Ensure plugin is installed + if not self.install_plugin(): + print(f"Failed to install plugin for {self.provider_type}", file=sys.stderr) + return False + + # Validate credentials before proceeding + if not self.provider.validate_credentials(): + print(f"Failed to validate credentials for {self.provider_type}", file=sys.stderr) + return False + + # Setup credentials file + if not self.setup_credentials(): + print(f"Failed to setup credentials for {self.provider_type}", file=sys.stderr) + return False cmd = self._build_certbot_command("certonly", domain, email) try: - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - print(f"Certificate obtaining failed: {result.stderr}", file=sys.stderr) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + + if result.returncode == 0: + print(f"✓ Certificate obtained successfully for {domain}") + return True + else: + print(f"✗ Certificate obtaining failed (exit code: {result.returncode})") + + # Check for specific error patterns + error_output = result.stderr.strip() if result.stderr else "" + stdout_output = result.stdout.strip() if result.stdout else "" + + if "unrecognized arguments" in error_output: + print(f"Plugin arguments not recognized by certbot") + print(f"This suggests the plugin is not properly registered") + elif "DNS problem" in error_output or "DNS problem" in stdout_output: + print(f"DNS validation failed - check domain configuration") + elif "Rate limited" in error_output or "Rate limited" in stdout_output: + print(f"Rate limited by Let's Encrypt") + + if error_output: + print(f"stderr: {error_output}") + if stdout_output: + print(f"stdout: {stdout_output}") + return False - print(f"Certificate obtained successfully for {domain}") - return True - + except subprocess.TimeoutExpired: + print(f"Certbot command timed out after 300 seconds", file=sys.stderr) + return False except Exception as e: print(f"Error running certbot: {e}", file=sys.stderr) return False @@ -115,13 +337,40 @@ def renew_certificate(self, domain: str) -> Tuple[bool, bool]: (success, renewed): success status and whether renewal was actually performed """ print(f"Renewing certificate using {self.provider_type}") + + # Ensure plugin is installed + if not self.install_plugin(): + print(f"Failed to install plugin for renewal", file=sys.stderr) + return False, False cmd = self._build_certbot_command("renew", domain, "") try: - result = subprocess.run(cmd, capture_output=True, text=True) - if result.returncode != 0: - print(f"Certificate renewal failed: {result.stderr}", file=sys.stderr) + result = subprocess.run(cmd, capture_output=True, text=True, timeout=300) + + if result.returncode == 0: + print(f"✓ Certificate renewal completed") + return True, True + else: + error_output = result.stderr.strip() if result.stderr else "" + stdout_output = result.stdout.strip() if result.stdout else "" + + print(f"✗ Certificate renewal failed (exit code: {result.returncode})") + + # Check for specific error patterns + if "unrecognized arguments" in error_output: + print(f"Plugin arguments not recognized by certbot") + elif "No renewals were attempted" in stdout_output: + print(f"No certificates need renewal") + return True, False # Success but no renewal needed + elif "DNS problem" in error_output or "DNS problem" in stdout_output: + print(f"DNS validation failed during renewal") + + if error_output: + print(f"stderr: {error_output}") + if stdout_output: + print(f"stdout: {stdout_output}") + return False, False # Check if no renewals were needed diff --git a/custom-domain/dstack-ingress/scripts/dns_providers/base.py b/custom-domain/dstack-ingress/scripts/dns_providers/base.py index c9e5c24..608cf16 100644 --- a/custom-domain/dstack-ingress/scripts/dns_providers/base.py +++ b/custom-domain/dstack-ingress/scripts/dns_providers/base.py @@ -52,6 +52,7 @@ class DNSProvider(ABC): # Certbot configuration - override in subclasses CERTBOT_PLUGIN = "" + CERTBOT_PLUGIN_MODULE = "" CERTBOT_PACKAGE = "" CERTBOT_PROPAGATION_SECONDS = 120 CERTBOT_CREDENTIALS_FILE = "" # Path to credentials file @@ -64,6 +65,10 @@ def setup_certbot_credentials(self) -> bool: """Setup credentials file for certbot. Override in subclasses if needed.""" return True # Default: no setup needed + def validate_credentials(self) -> bool: + """Validate provider credentials. Override in subclasses if needed.""" + return True # Default: no validation needed + @classmethod def suitable(cls) -> bool: """Check if the current environment is suitable for this DNS provider.""" diff --git a/custom-domain/dstack-ingress/scripts/dns_providers/cloudflare.py b/custom-domain/dstack-ingress/scripts/dns_providers/cloudflare.py index d3ed099..ca1208b 100644 --- a/custom-domain/dstack-ingress/scripts/dns_providers/cloudflare.py +++ b/custom-domain/dstack-ingress/scripts/dns_providers/cloudflare.py @@ -15,6 +15,7 @@ class CloudflareDNSProvider(DNSProvider): # Certbot configuration CERTBOT_PLUGIN = "dns-cloudflare" + CERTBOT_PLUGIN_MODULE = "certbot_dns_cloudflare" CERTBOT_PACKAGE = "certbot-dns-cloudflare==4.0.0" CERTBOT_PROPAGATION_SECONDS = 120 CERTBOT_CREDENTIALS_FILE = "~/.cloudflare/cloudflare.ini" diff --git a/custom-domain/dstack-ingress/scripts/dns_providers/factory.py b/custom-domain/dstack-ingress/scripts/dns_providers/factory.py index 84743d7..85f7532 100644 --- a/custom-domain/dstack-ingress/scripts/dns_providers/factory.py +++ b/custom-domain/dstack-ingress/scripts/dns_providers/factory.py @@ -5,6 +5,7 @@ from .base import DNSProvider from .cloudflare import CloudflareDNSProvider from .linode import LinodeDNSProvider +from .namecheap import NamecheapDNSProvider class DNSProviderFactory: @@ -13,6 +14,7 @@ class DNSProviderFactory: PROVIDERS = { "cloudflare": CloudflareDNSProvider, "linode": LinodeDNSProvider, + "namecheap": NamecheapDNSProvider, } @classmethod @@ -65,4 +67,4 @@ def _detect_provider_type(cls) -> str: @classmethod def get_supported_providers(cls) -> list: """Get list of supported DNS providers.""" - return list(cls.PROVIDERS.keys()) + return list(cls.PROVIDERS.keys()) \ No newline at end of file diff --git a/custom-domain/dstack-ingress/scripts/dns_providers/linode.py b/custom-domain/dstack-ingress/scripts/dns_providers/linode.py index de264ad..a4c307c 100644 --- a/custom-domain/dstack-ingress/scripts/dns_providers/linode.py +++ b/custom-domain/dstack-ingress/scripts/dns_providers/linode.py @@ -16,6 +16,7 @@ class LinodeDNSProvider(DNSProvider): # Certbot configuration CERTBOT_PLUGIN = "dns-linode" + CERTBOT_PLUGIN_MODULE = "certbot_dns_linode" CERTBOT_PACKAGE = "certbot-dns-linode" CERTBOT_PROPAGATION_SECONDS = 300 CERTBOT_CREDENTIALS_FILE = "~/.linode/credentials.ini" diff --git a/custom-domain/dstack-ingress/scripts/dns_providers/namecheap.py b/custom-domain/dstack-ingress/scripts/dns_providers/namecheap.py new file mode 100644 index 0000000..cc5f216 --- /dev/null +++ b/custom-domain/dstack-ingress/scripts/dns_providers/namecheap.py @@ -0,0 +1,296 @@ +#!/usr/bin/env python3 + +import os +import requests +import xml.etree.ElementTree as ET +from typing import Dict, List, Optional +from .base import DNSProvider, DNSRecord, CAARecord, RecordType + + +class NamecheapDNSProvider(DNSProvider): + """Namecheap DNS provider implementation.""" + + DETECT_ENV = "NAMECHEAP_API_KEY" + CERTBOT_PLUGIN = "dns-namecheap" + CERTBOT_PLUGIN_MODULE = "certbot_dns_namecheap" + CERTBOT_PACKAGE = "certbot-dns-namecheap==1.0.0" + CERTBOT_PROPAGATION_SECONDS = 120 + CERTBOT_CREDENTIALS_FILE = "~/.namecheap/namecheap.ini" + + def __init__(self): + """Initialize the Namecheap DNS provider.""" + super().__init__() + self.username = os.environ.get("NAMECHEAP_USERNAME") + self.api_key = os.environ.get("NAMECHEAP_API_KEY") + self.client_ip = os.environ.get("NAMECHEAP_CLIENT_IP", "127.0.0.1") + self.sandbox = os.environ.get("NAMECHEAP_SANDBOX", "false").lower() == "true" + + if not self.username or not self.api_key: + raise ValueError("NAMECHEAP_USERNAME and NAMECHEAP_API_KEY are required") + + if self.sandbox: + self.base_url = "https://api.sandbox.namecheap.com/xml.response" + else: + self.base_url = "https://api.namecheap.com/xml.response" + + def setup_certbot_credentials(self) -> bool: + """Setup credentials file for certbot.""" + try: + cred_dir = os.path.expanduser("~/.namecheap") + os.makedirs(cred_dir, exist_ok=True) + + cred_file = os.path.join(cred_dir, "namecheap.ini") + with open(cred_file, "w") as f: + f.write(f"# Namecheap API credentials used by Certbot\n") + f.write(f"dns_namecheap_username={self.username}\n") + f.write(f"dns_namecheap_api_key={self.api_key}\n") + + os.chmod(cred_file, 0o600) + print(f"Credentials file created: {cred_file}") + return True + except Exception as e: + print(f"Error setting up credentials file: {e}") + return False + + def validate_credentials(self) -> bool: + """Validate Namecheap API credentials by testing API access.""" + print(f"Validating Namecheap API credentials...") + + try: + # Test API access with getBalances command + test_result = self._make_request("namecheap.users.getBalances") + if test_result.get("success", False): + print(f"✓ Namecheap API credentials are valid") + return True + else: + print(f"✗ Namecheap API validation failed: {test_result.get('errors', ['Unknown error'])}") + return False + except Exception as e: + print(f"Error validating Namecheap credentials: {e}") + return False + + def _make_request(self, command: str, **params) -> Dict: + """Make a request to the Namecheap API with error handling.""" + # Base parameters required for all Namecheap API calls + request_params = { + "ApiUser": self.username, + "ApiKey": self.api_key, + "UserName": self.username, + "ClientIp": self.client_ip, + "Command": command + } + + # Add additional parameters + request_params.update(params) + + try: + response = requests.post(self.base_url, data=request_params) + response.raise_for_status() + + # Parse XML response + root = ET.fromstring(response.content) + + # Check for API errors + errors = root.find('.//{https://api.namecheap.com/xml.response}Errors') + if errors is not None and len(errors) > 0: + error_messages = [] + for error in errors: + error_messages.append(f"Code: {error.get('Number')}, Message: {error.text}") + error_msg = "\n".join(error_messages) + print(f"Namecheap API Error: {error_msg}") + return {"success": False, "errors": error_messages} + + # Check response status + status = root.get('Status') + if status != 'OK': + print(f"Namecheap API Response Status: {status}") + return {"success": False, "errors": [{"message": f"API returned status: {status}"}]} + + return {"success": True, "result": root} + + except requests.exceptions.RequestException as e: + print(f"Namecheap API Request Error: {str(e)}") + return {"success": False, "errors": [{"message": str(e)}]} + except ET.ParseError as e: + print(f"Namecheap API XML Parse Error: {str(e)}") + return {"success": False, "errors": [{"message": f"XML Parse Error: {str(e)}"}]} + except Exception as e: + print(f"Namecheap API Unexpected Error: {str(e)}") + return {"success": False, "errors": [{"message": str(e)}]} + + def _get_domain_info(self, domain: str) -> Optional[tuple]: + """Extract SLD and TLD from domain.""" + parts = domain.split('.') + if len(parts) < 2: + return None + + # For Namecheap, we need the registered domain name + # This is a simplified approach - assumes the domain is the last two parts + sld = parts[-2] + tld = '.'.join(parts[-1:]) + + return sld, tld + + def get_dns_records( + self, name: str, record_type: Optional[RecordType] = None + ) -> List[DNSRecord]: + """Get DNS records for a domain.""" + domain_info = self._get_domain_info(name) + if not domain_info: + print(f"Could not determine domain info from {name}") + return [] + + sld, tld = domain_info + print(f"Getting DNS records for {name} (SLD: {sld}, TLD: {tld})") + + result = self._make_request( + "namecheap.domains.dns.getHosts", + SLD=sld, + TLD=tld + ) + + if not result.get("success", False): + return [] + + # Parse the host records from XML response + records = [] + host_elements = result["result"].findall('.//{https://api.namecheap.com/xml.response}host') + + for host in host_elements: + record_name = host.get("Name") + record_type_str = host.get("Type") + + # Skip if record type doesn't match + if record_type and record_type_str != record_type.value: + continue + + # Skip if name doesn't match (considering @ for root domain) + if record_name == "@": + record_name = sld + "." + tld + elif not record_name.endswith("." + sld + "." + tld): + record_name = record_name + "." + sld + "." + tld + + # Create DNS record + record = DNSRecord( + id=host.get("HostId"), + name=record_name, + type=RecordType(record_type_str), + content=host.get("Address"), + ttl=int(host.get("TTL", "1800")), + proxied=False, + priority=int(host.get("MXPref", "10")) if host.get("MXPref") else None + ) + + # Add CAA-specific data + if record_type_str == "CAA": + # Parse CAA record content (format: flags tag value) + content = host.get("Address", "") + parts = content.split(" ", 2) + if len(parts) >= 3: + record.data = { + "flags": int(parts[0]), + "tag": parts[1], + "value": parts[2] + } + + records.append(record) + + return records + + def create_dns_record(self, record: DNSRecord) -> bool: + """Create a DNS record.""" + domain_info = self._get_domain_info(record.name) + if not domain_info: + print(f"Could not determine domain info from {record.name}") + return False + + sld, tld = domain_info + + # Get existing records + existing_records = self.get_dns_records(record.name) + + # Extract hostname from domain + if record.name == sld + "." + tld: + hostname = "@" + else: + hostname = record.name.replace("." + sld + "." + tld, "") + + # Remove existing records of the same type and name + filtered_records = [ + r for r in existing_records + if not (r.name == record.name and r.type == record.type) + ] + + # Add new record + new_record = { + "HostName": hostname, + "RecordType": record.type.value, + "Address": record.content, + "TTL": str(record.ttl) + } + + if record.type == RecordType.MX and record.priority: + new_record["MXPref"] = str(record.priority) + + filtered_records.append(new_record) + + # Set all records + return self._set_dns_records(sld, tld, filtered_records) + + def delete_dns_record(self, record_id: str, domain: str) -> bool: + """Delete a DNS record.""" + # Namecheap doesn't support individual record deletion + # We need to get all records, remove the one with the matching ID, and set them all + domain_info = self._get_domain_info(domain) + if not domain_info: + return False + + sld, tld = domain_info + existing_records = self.get_dns_records(domain) + + # Remove the record with the matching ID + filtered_records = [r for r in existing_records if r.id != record_id] + + return self._set_dns_records(sld, tld, filtered_records) + + def create_caa_record(self, caa_record: CAARecord) -> bool: + """Create a CAA record.""" + # Namecheap doesn't support CAA records through their API currently + # This is a limitation of their API + print(f"Warning: Namecheap API does not currently support CAA records") + print(f"You need to manually add CAA record for {caa_record.name}") + return True # Return True to not break the workflow + + def _set_dns_records(self, sld: str, tld: str, records: List[Dict]) -> bool: + """Set DNS records for a domain.""" + # Prepare host records parameters + params = { + "SLD": sld, + "TLD": tld + } + + # Add host records to parameters + for i, record in enumerate(records, 1): + params[f"HostName{i}"] = record.get("HostName", "@") + params[f"RecordType{i}"] = record.get("RecordType", "A") + params[f"Address{i}"] = record.get("Address", "") + params[f"TTL{i}"] = record.get("TTL", "1800") + + # Add MXPref for MX records + if record.get("RecordType") == "MX": + params[f"MXPref{i}"] = record.get("MXPref", "10") + + print(f"Setting DNS records for {sld}.{tld}") + result = self._make_request("namecheap.domains.dns.setHosts", **params) + + return result.get("success", False) + + def set_alias_record( + self, + name: str, + content: str, + ttl: int = 60, + proxied: bool = False, + ) -> bool: + """Set an alias record using CNAME.""" + return self.set_cname_record(name, content, ttl, proxied) \ No newline at end of file diff --git a/custom-domain/dstack-ingress/scripts/entrypoint.sh b/custom-domain/dstack-ingress/scripts/entrypoint.sh index 17c671d..628dba6 100644 --- a/custom-domain/dstack-ingress/scripts/entrypoint.sh +++ b/custom-domain/dstack-ingress/scripts/entrypoint.sh @@ -5,18 +5,14 @@ set -e PORT=${PORT:-443} TXT_PREFIX=${TXT_PREFIX:-"_tapp-address"} -echo "Setting up Python environment" +echo "Setting up certbot environment" -setup_py_env() { - if [ ! -d "/opt/app-venv" ]; then - python3 -m venv --system-site-packages /opt/app-venv - fi +setup_certbot_env() { + # Activate the pre-built virtual environment source /opt/app-venv/bin/activate - pip install requests - # Use the unified certbot manager to install plugins and setup credentials - echo "Setting up certbot environment" + echo "Installing DNS plugins and setting up credentials" certman.py setup if [ $? -ne 0 ]; then echo "Error: Failed to setup certbot environment" @@ -156,8 +152,8 @@ bootstrap() { # Credentials are now handled by certman.py setup -# Setup Python environment and install dependencies first -setup_py_env +# Setup certbot environment (venv is already created in Dockerfile) +setup_certbot_env # Check if it's the first time the container is started if [ ! -f "/.bootstrapped" ]; then @@ -170,4 +166,4 @@ renewal-daemon.sh & setup_nginx_conf -exec "$@" +exec "$@" \ No newline at end of file diff --git a/custom-domain/dstack-ingress/scripts/renew-certificate.sh b/custom-domain/dstack-ingress/scripts/renew-certificate.sh index b8e412c..99110b4 100755 --- a/custom-domain/dstack-ingress/scripts/renew-certificate.sh +++ b/custom-domain/dstack-ingress/scripts/renew-certificate.sh @@ -48,4 +48,4 @@ if [ -f "/etc/letsencrypt/live/$DOMAIN/fullchain.pem" ]; then fi fi -exit 0 +exit 0 \ No newline at end of file