1+ #!/usr/bin/env python3
2+
3+ import os
4+ import requests
5+ import xml .etree .ElementTree as ET
6+ from typing import Dict , List , Optional
7+ from .base import DNSProvider , DNSRecord , CAARecord , RecordType
8+
9+
10+ class NamecheapDNSProvider (DNSProvider ):
11+ """Namecheap DNS provider implementation."""
12+
13+ DETECT_ENV = "NAMECHEAP_API_KEY"
14+ CERTBOT_PLUGIN = "dns-namecheap"
15+ CERTBOT_PACKAGE = "certbot-dns-namecheap"
16+ CERTBOT_PROPAGATION_SECONDS = 120
17+ CERTBOT_CREDENTIALS_FILE = "~/.namecheap/namecheap.ini"
18+
19+ def __init__ (self ):
20+ """Initialize the Namecheap DNS provider."""
21+ super ().__init__ ()
22+ self .username = os .environ .get ("NAMECHEAP_USERNAME" )
23+ self .api_key = os .environ .get ("NAMECHEAP_API_KEY" )
24+ self .client_ip = os .environ .get ("NAMECHEAP_CLIENT_IP" , "127.0.0.1" )
25+ self .sandbox = os .environ .get ("NAMECHEAP_SANDBOX" , "false" ).lower () == "true"
26+
27+ if not self .username or not self .api_key :
28+ raise ValueError ("NAMECHEAP_USERNAME and NAMECHEAP_API_KEY are required" )
29+
30+ if self .sandbox :
31+ self .base_url = "https://api.sandbox.namecheap.com/xml.response"
32+ else :
33+ self .base_url = "https://api.namecheap.com/xml.response"
34+
35+ def setup_certbot_credentials (self ) -> bool :
36+ """Setup credentials file for certbot."""
37+ try :
38+ cred_dir = os .path .expanduser ("~/.namecheap" )
39+ os .makedirs (cred_dir , exist_ok = True )
40+
41+ cred_file = os .path .join (cred_dir , "namecheap.ini" )
42+ with open (cred_file , "w" ) as f :
43+ f .write (f"dns_namecheap_username = { self .username } \n " )
44+ f .write (f"dns_namecheap_api_key = { self .api_key } \n " )
45+
46+ os .chmod (cred_file , 0o600 )
47+ return True
48+ except Exception as e :
49+ print (f"Error setting up certbot credentials: { e } " )
50+ return False
51+
52+ def _make_request (self , command : str , ** params ) -> Dict :
53+ """Make a request to the Namecheap API with error handling."""
54+ # Base parameters required for all Namecheap API calls
55+ request_params = {
56+ "ApiUser" : self .username ,
57+ "ApiKey" : self .api_key ,
58+ "UserName" : self .username ,
59+ "ClientIp" : self .client_ip ,
60+ "Command" : command
61+ }
62+
63+ # Add additional parameters
64+ request_params .update (params )
65+
66+ try :
67+ response = requests .post (self .base_url , data = request_params )
68+ response .raise_for_status ()
69+
70+ # Parse XML response
71+ root = ET .fromstring (response .content )
72+
73+ # Check for API errors
74+ errors = root .find ('.//{https://api.namecheap.com/xml.response}Errors' )
75+ if errors is not None and len (errors ) > 0 :
76+ error_messages = []
77+ for error in errors :
78+ error_messages .append (f"Code: { error .get ('Number' )} , Message: { error .text } " )
79+ error_msg = "\n " .join (error_messages )
80+ print (f"Namecheap API Error: { error_msg } " )
81+ return {"success" : False , "errors" : error_messages }
82+
83+ # Check response status
84+ status = root .get ('Status' )
85+ if status != 'OK' :
86+ print (f"Namecheap API Response Status: { status } " )
87+ return {"success" : False , "errors" : [{"message" : f"API returned status: { status } " }]}
88+
89+ return {"success" : True , "result" : root }
90+
91+ except requests .exceptions .RequestException as e :
92+ print (f"Namecheap API Request Error: { str (e )} " )
93+ return {"success" : False , "errors" : [{"message" : str (e )}]}
94+ except ET .ParseError as e :
95+ print (f"Namecheap API XML Parse Error: { str (e )} " )
96+ return {"success" : False , "errors" : [{"message" : f"XML Parse Error: { str (e )} " }]}
97+ except Exception as e :
98+ print (f"Namecheap API Unexpected Error: { str (e )} " )
99+ return {"success" : False , "errors" : [{"message" : str (e )}]}
100+
101+ def _get_domain_info (self , domain : str ) -> Optional [tuple ]:
102+ """Extract SLD and TLD from domain."""
103+ parts = domain .split ('.' )
104+ if len (parts ) < 2 :
105+ return None
106+
107+ # For Namecheap, we need the registered domain name
108+ # This is a simplified approach - assumes the domain is the last two parts
109+ sld = parts [- 2 ]
110+ tld = '.' .join (parts [- 1 :])
111+
112+ return sld , tld
113+
114+ def get_dns_records (
115+ self , name : str , record_type : Optional [RecordType ] = None
116+ ) -> List [DNSRecord ]:
117+ """Get DNS records for a domain."""
118+ domain_info = self ._get_domain_info (name )
119+ if not domain_info :
120+ print (f"Could not determine domain info from { name } " )
121+ return []
122+
123+ sld , tld = domain_info
124+ print (f"Getting DNS records for { name } (SLD: { sld } , TLD: { tld } )" )
125+
126+ result = self ._make_request (
127+ "namecheap.domains.dns.getHosts" ,
128+ SLD = sld ,
129+ TLD = tld
130+ )
131+
132+ if not result .get ("success" , False ):
133+ return []
134+
135+ # Parse the host records from XML response
136+ records = []
137+ host_elements = result ["result" ].findall ('.//{https://api.namecheap.com/xml.response}host' )
138+
139+ for host in host_elements :
140+ record_name = host .get ("Name" )
141+ record_type_str = host .get ("Type" )
142+
143+ # Skip if record type doesn't match
144+ if record_type and record_type_str != record_type .value :
145+ continue
146+
147+ # Skip if name doesn't match (considering @ for root domain)
148+ if record_name == "@" :
149+ record_name = sld + "." + tld
150+ elif not record_name .endswith ("." + sld + "." + tld ):
151+ record_name = record_name + "." + sld + "." + tld
152+
153+ # Create DNS record
154+ record = DNSRecord (
155+ id = host .get ("HostId" ),
156+ name = record_name ,
157+ type = RecordType (record_type_str ),
158+ content = host .get ("Address" ),
159+ ttl = int (host .get ("TTL" , "1800" )),
160+ proxied = False ,
161+ priority = int (host .get ("MXPref" , "10" )) if host .get ("MXPref" ) else None
162+ )
163+
164+ # Add CAA-specific data
165+ if record_type_str == "CAA" :
166+ # Parse CAA record content (format: flags tag value)
167+ content = host .get ("Address" , "" )
168+ parts = content .split (" " , 2 )
169+ if len (parts ) >= 3 :
170+ record .data = {
171+ "flags" : int (parts [0 ]),
172+ "tag" : parts [1 ],
173+ "value" : parts [2 ]
174+ }
175+
176+ records .append (record )
177+
178+ return records
179+
180+ def create_dns_record (self , record : DNSRecord ) -> bool :
181+ """Create a DNS record."""
182+ domain_info = self ._get_domain_info (record .name )
183+ if not domain_info :
184+ print (f"Could not determine domain info from { record .name } " )
185+ return False
186+
187+ sld , tld = domain_info
188+
189+ # Get existing records
190+ existing_records = self .get_dns_records (record .name )
191+
192+ # Extract hostname from domain
193+ if record .name == sld + "." + tld :
194+ hostname = "@"
195+ else :
196+ hostname = record .name .replace ("." + sld + "." + tld , "" )
197+
198+ # Remove existing records of the same type and name
199+ filtered_records = [
200+ r for r in existing_records
201+ if not (r .name == record .name and r .type == record .type )
202+ ]
203+
204+ # Add new record
205+ new_record = {
206+ "HostName" : hostname ,
207+ "RecordType" : record .type .value ,
208+ "Address" : record .content ,
209+ "TTL" : str (record .ttl )
210+ }
211+
212+ if record .type == RecordType .MX and record .priority :
213+ new_record ["MXPref" ] = str (record .priority )
214+
215+ filtered_records .append (new_record )
216+
217+ # Set all records
218+ return self ._set_dns_records (sld , tld , filtered_records )
219+
220+ def delete_dns_record (self , record_id : str , domain : str ) -> bool :
221+ """Delete a DNS record."""
222+ # Namecheap doesn't support individual record deletion
223+ # We need to get all records, remove the one with the matching ID, and set them all
224+ domain_info = self ._get_domain_info (domain )
225+ if not domain_info :
226+ return False
227+
228+ sld , tld = domain_info
229+ existing_records = self .get_dns_records (domain )
230+
231+ # Remove the record with the matching ID
232+ filtered_records = [r for r in existing_records if r .id != record_id ]
233+
234+ return self ._set_dns_records (sld , tld , filtered_records )
235+
236+ def create_caa_record (self , caa_record : CAARecord ) -> bool :
237+ """Create a CAA record."""
238+ # Namecheap doesn't support CAA records through their API currently
239+ # This is a limitation of their API
240+ print (f"Warning: Namecheap API does not currently support CAA records" )
241+ print (f"You need to manually add CAA record for { caa_record .name } " )
242+ return True # Return True to not break the workflow
243+
244+ def _set_dns_records (self , sld : str , tld : str , records : List [Dict ]) -> bool :
245+ """Set DNS records for a domain."""
246+ # Prepare host records parameters
247+ params = {
248+ "SLD" : sld ,
249+ "TLD" : tld
250+ }
251+
252+ # Add host records to parameters
253+ for i , record in enumerate (records , 1 ):
254+ params [f"HostName{ i } " ] = record .get ("HostName" , "@" )
255+ params [f"RecordType{ i } " ] = record .get ("RecordType" , "A" )
256+ params [f"Address{ i } " ] = record .get ("Address" , "" )
257+ params [f"TTL{ i } " ] = record .get ("TTL" , "1800" )
258+
259+ # Add MXPref for MX records
260+ if record .get ("RecordType" ) == "MX" :
261+ params [f"MXPref{ i } " ] = record .get ("MXPref" , "10" )
262+
263+ print (f"Setting DNS records for { sld } .{ tld } " )
264+ result = self ._make_request ("namecheap.domains.dns.setHosts" , ** params )
265+
266+ return result .get ("success" , False )
267+
268+ def set_alias_record (
269+ self ,
270+ name : str ,
271+ content : str ,
272+ ttl : int = 60 ,
273+ proxied : bool = False ,
274+ ) -> bool :
275+ """Set an alias record using CNAME."""
276+ return self .set_cname_record (name , content , ttl , proxied )
0 commit comments