Files
sslh-multiplex-lab/internal/providers/namecheap/client.go
2026-01-29 00:03:02 +00:00

568 lines
14 KiB
Go

package namecheap
import (
"encoding/xml"
"fmt"
"net/url"
"strconv"
"strings"
"time"
"github.com/go-resty/resty/v2"
)
const (
namecheapAPIBaseURL = "https://api.namecheap.com/xml.response"
)
type Client struct {
apiKey string
apiUser string
clientIP string
resty *resty.Client
}
type Domain struct {
Name string
Nameservers []string
IsUsingNamecheapDNS bool
}
type DNSRecord struct {
ID string
Type string
Name string
Address string
TTL int
MXPref int
}
type DomainListResponse struct {
Domains []Domain
}
type DNSHostListResponse struct {
Records []DNSRecord
}
func NewClient(apiKey, apiUser, clientIP string) *Client {
client := resty.New()
client.SetBaseURL(namecheapAPIBaseURL)
client.SetTimeout(30 * time.Second)
return &Client{
apiKey: apiKey,
apiUser: apiUser,
clientIP: clientIP,
resty: client,
}
}
func (c *Client) buildQueryParams(command string, params map[string]string) url.Values {
query := url.Values{}
query.Set("ApiUser", c.apiUser)
query.Set("ApiKey", c.apiKey)
query.Set("UserName", c.apiUser)
query.Set("ClientIp", c.clientIP)
query.Set("Command", command)
for k, v := range params {
query.Set(k, v)
}
return query
}
func (c *Client) ListDomains() ([]Domain, error) {
query := c.buildQueryParams("namecheap.domains.getList", map[string]string{})
resp, err := c.resty.R().
SetQueryParamsFromValues(query).
Get("")
if err != nil {
return nil, fmt.Errorf("failed to list domains: %w", err)
}
if resp.IsError() {
return nil, fmt.Errorf("API error: %s - %s", resp.Status(), string(resp.Body()))
}
var domains []Domain
if err := parseDomainListResponse(resp.Body(), &domains); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return domains, nil
}
func (c *Client) ListDNSRecords(domain string) ([]DNSRecord, error) {
query := c.buildQueryParams("namecheap.domains.dns.getHosts", map[string]string{
"SLD": extractSLD(domain),
"TLD": extractTLD(domain),
})
resp, err := c.resty.R().
SetQueryParamsFromValues(query).
Get("")
if err != nil {
return nil, fmt.Errorf("failed to list DNS records: %w", err)
}
if resp.IsError() {
bodyStr := string(resp.Body())
if strings.Contains(bodyStr, "2030288") || strings.Contains(bodyStr, "not using proper DNS servers") {
return nil, fmt.Errorf("domain %s is not using Namecheap DNS servers. The domain must use Namecheap's BasicDNS, PremiumDNS, or FreeDNS nameservers to manage DNS records via API. Please change the nameservers in your Namecheap account (Domain List > Manage > Nameservers) to 'Namecheap BasicDNS' or 'Namecheap PremiumDNS'", domain)
}
return nil, fmt.Errorf("API error: %s - %s", resp.Status(), bodyStr)
}
var records []DNSRecord
if err := parseDNSHostListResponse(resp.Body(), &records); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return records, nil
}
func (c *Client) CreateOrUpdateDNSRecord(domain, subdomain, recordType, value string, ttl int) (created bool, err error) {
sld := extractSLD(domain)
tld := extractTLD(domain)
records, err := c.ListDNSRecords(domain)
if err != nil {
return false, fmt.Errorf("failed to get existing records: %w", err)
}
recordExists := false
var updatedRecords []DNSRecord
for _, record := range records {
if record.Name == subdomain && record.Type == recordType {
recordExists = true
updatedRecords = append(updatedRecords, DNSRecord{
ID: record.ID,
Type: recordType,
Name: subdomain,
Address: value,
TTL: ttl,
MXPref: record.MXPref,
})
} else {
updatedRecords = append(updatedRecords, record)
}
}
if !recordExists {
updatedRecords = append(updatedRecords, DNSRecord{
ID: fmt.Sprintf("%d", len(records)+1),
Type: recordType,
Name: subdomain,
Address: value,
TTL: ttl,
MXPref: 10,
})
}
query := c.buildQueryParams("namecheap.domains.dns.setHosts", map[string]string{
"SLD": sld,
"TLD": tld,
})
for i, record := range updatedRecords {
idx := i + 1
query.Set(fmt.Sprintf("HostName%d", idx), record.Name)
query.Set(fmt.Sprintf("RecordType%d", idx), record.Type)
query.Set(fmt.Sprintf("Address%d", idx), record.Address)
query.Set(fmt.Sprintf("TTL%d", idx), fmt.Sprintf("%d", record.TTL))
if record.Type == "MX" {
query.Set(fmt.Sprintf("MXPref%d", idx), fmt.Sprintf("%d", record.MXPref))
}
}
resp, err := c.resty.R().
SetQueryParamsFromValues(query).
Get("")
if err != nil {
return false, fmt.Errorf("failed to set DNS record: %w", err)
}
if resp.IsError() {
return false, fmt.Errorf("API error: %s - %s", resp.Status(), string(resp.Body()))
}
return !recordExists, nil
}
func (c *Client) CreateDNSRecord(domain, subdomain, recordType, value string, ttl int) error {
_, err := c.CreateOrUpdateDNSRecord(domain, subdomain, recordType, value, ttl)
return err
}
func (c *Client) DeleteDNSRecord(domain, recordID string) error {
records, err := c.ListDNSRecords(domain)
if err != nil {
return fmt.Errorf("failed to get existing records: %w", err)
}
var filtered []DNSRecord
for _, record := range records {
if record.ID != recordID {
filtered = append(filtered, record)
}
}
sld := extractSLD(domain)
tld := extractTLD(domain)
query := c.buildQueryParams("namecheap.domains.dns.setHosts", map[string]string{
"SLD": sld,
"TLD": tld,
})
for i, record := range filtered {
query.Set(fmt.Sprintf("HostName%d", i+1), record.Name)
query.Set(fmt.Sprintf("RecordType%d", i+1), record.Type)
query.Set(fmt.Sprintf("Address%d", i+1), record.Address)
query.Set(fmt.Sprintf("TTL%d", i+1), fmt.Sprintf("%d", record.TTL))
}
resp, err := c.resty.R().
SetQueryParamsFromValues(query).
Get("")
if err != nil {
return fmt.Errorf("failed to delete DNS record: %w", err)
}
if resp.IsError() {
return fmt.Errorf("API error: %s - %s", resp.Status(), string(resp.Body()))
}
return nil
}
func (c *Client) CheckExistingRecords(domain string) (bool, []DNSRecord, error) {
records, err := c.ListDNSRecords(domain)
if err != nil {
return false, nil, err
}
return len(records) > 0, records, nil
}
func (c *Client) GetDomainInfo(domain string) (*Domain, error) {
query := c.buildQueryParams("namecheap.domains.getInfo", map[string]string{
"DomainName": domain,
})
resp, err := c.resty.R().
SetQueryParamsFromValues(query).
Get("")
if err != nil {
return nil, fmt.Errorf("failed to get domain info: %w", err)
}
if resp.IsError() {
return nil, fmt.Errorf("API error: %s - %s", resp.Status(), string(resp.Body()))
}
var domainInfo Domain
if err := parseDomainInfoResponse(resp.Body(), &domainInfo); err != nil {
return nil, fmt.Errorf("failed to parse response: %w", err)
}
return &domainInfo, nil
}
func (c *Client) IsDomainUsingNamecheapDNS(domain string) (bool, error) {
domainInfo, err := c.GetDomainInfo(domain)
if err != nil {
return false, err
}
return domainInfo.IsUsingNamecheapDNS, nil
}
func (c *Client) SetDomainToNamecheapDNS(domain string) error {
sld := extractSLD(domain)
tld := extractTLD(domain)
if sld == "" || tld == "" {
return fmt.Errorf("invalid domain format: %s (could not extract SLD/TLD)", domain)
}
query := c.buildQueryParams("namecheap.domains.dns.setDefault", map[string]string{
"SLD": sld,
"TLD": tld,
})
resp, err := c.resty.R().
SetQueryParamsFromValues(query).
Get("")
if err != nil {
return fmt.Errorf("failed to set domain to Namecheap DNS: %w", err)
}
bodyStr := string(resp.Body())
if resp.IsError() {
return fmt.Errorf("API error: %s - %s", resp.Status(), bodyStr)
}
var result domainDNSSetDefaultResult
if err := parseDNSSetDefaultResponse(resp.Body(), &result); err != nil {
return fmt.Errorf("failed to parse response: %w\nAPI response: %s", err, bodyStr)
}
if !result.Updated {
return fmt.Errorf("API returned Updated=false for domain %s\nAPI response: %s", domain, bodyStr)
}
return nil
}
func extractSLD(domain string) string {
parts := splitDomain(domain)
if len(parts) >= 2 {
return parts[len(parts)-2]
}
return ""
}
func extractTLD(domain string) string {
parts := splitDomain(domain)
if len(parts) >= 1 {
return parts[len(parts)-1]
}
return ""
}
func splitDomain(domain string) []string {
var parts []string
start := 0
for i, char := range domain {
if char == '.' {
if i > start {
parts = append(parts, domain[start:i])
}
start = i + 1
}
}
if start < len(domain) {
parts = append(parts, domain[start:])
}
return parts
}
type apiResponse struct {
XMLName xml.Name `xml:"ApiResponse"`
Status string `xml:"Status,attr"`
Errors []apiError `xml:"Errors>Error"`
CommandResponse commandResponse `xml:"CommandResponse"`
}
type apiError struct {
Number string `xml:"Number,attr"`
Text string `xml:",chardata"`
}
type commandResponse struct {
DomainGetListResult domainGetListResult `xml:"DomainGetListResult"`
DomainDNSSetHostsResult domainDNSSetHostsResult `xml:"DomainDNSSetHostsResult"`
DomainDNSGetHostsResult domainDNSGetHostsResult `xml:"DomainDNSGetHostsResult"`
DomainGetInfoResult domainGetInfoResult `xml:"DomainGetInfoResult"`
DomainDNSSetDefaultResult domainDNSSetDefaultResult `xml:"DomainDNSSetDefaultResult"`
}
type domainGetListResult struct {
Domains []domainXML `xml:"Domain"`
}
type domainXML struct {
Name string `xml:"Name"`
}
type domainGetInfoResult struct {
DomainName string `xml:"DomainName,attr"`
IsUsingNamecheapDNS bool `xml:"IsUsingNamecheapDNS,attr"`
Nameservers []nameserverXML `xml:"Nameservers>Nameserver"`
}
type nameserverXML struct {
Name string `xml:",chardata"`
}
type domainDNSSetDefaultResult struct {
Domain string `xml:"Domain,attr"`
Updated bool `xml:"Updated,attr"`
}
type domainDNSGetHostsResult struct {
Hosts []hostXML `xml:"host"`
}
type domainDNSSetHostsResult struct {
IsSuccess bool `xml:"IsSuccess,attr"`
}
type hostXML struct {
Name string `xml:"Name,attr"`
Type string `xml:"Type,attr"`
Address string `xml:"Address,attr"`
TTL string `xml:"TTL,attr"`
MXPref string `xml:"MXPref,attr"`
}
func parseDomainListResponse(body []byte, domains *[]Domain) error {
var resp apiResponse
if err := xml.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("failed to unmarshal XML: %w", err)
}
if resp.Status != "OK" {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API returned error status: %s", errorMsgs)
}
if len(resp.Errors) > 0 {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API errors in response: %s", errorMsgs)
}
*domains = make([]Domain, 0, len(resp.CommandResponse.DomainGetListResult.Domains))
for _, domainXML := range resp.CommandResponse.DomainGetListResult.Domains {
*domains = append(*domains, Domain{
Name: domainXML.Name,
})
}
return nil
}
func parseDNSHostListResponse(body []byte, records *[]DNSRecord) error {
var resp apiResponse
if err := xml.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("failed to unmarshal XML: %w", err)
}
if resp.Status != "OK" {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API returned error status: %s", errorMsgs)
}
if len(resp.Errors) > 0 {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API errors in response: %s", errorMsgs)
}
*records = make([]DNSRecord, 0, len(resp.CommandResponse.DomainDNSGetHostsResult.Hosts))
for i, hostXML := range resp.CommandResponse.DomainDNSGetHostsResult.Hosts {
ttl := 1799
if hostXML.TTL != "" {
if parsedTTL, err := strconv.Atoi(hostXML.TTL); err == nil {
ttl = parsedTTL
}
}
mxPref := 10
if hostXML.MXPref != "" {
if parsedMXPref, err := strconv.Atoi(hostXML.MXPref); err == nil {
mxPref = parsedMXPref
}
}
*records = append(*records, DNSRecord{
ID: fmt.Sprintf("%d", i+1),
Type: hostXML.Type,
Name: hostXML.Name,
Address: hostXML.Address,
TTL: ttl,
MXPref: mxPref,
})
}
return nil
}
func parseDomainInfoResponse(body []byte, domain *Domain) error {
var resp apiResponse
if err := xml.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("failed to unmarshal XML: %w", err)
}
if resp.Status != "OK" {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API returned error status: %s", errorMsgs)
}
if len(resp.Errors) > 0 {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API errors in response: %s", errorMsgs)
}
result := resp.CommandResponse.DomainGetInfoResult
domain.Name = result.DomainName
domain.IsUsingNamecheapDNS = result.IsUsingNamecheapDNS
domain.Nameservers = make([]string, 0, len(result.Nameservers))
for _, ns := range result.Nameservers {
domain.Nameservers = append(domain.Nameservers, ns.Name)
}
return nil
}
func parseDNSSetDefaultResponse(body []byte, result *domainDNSSetDefaultResult) error {
var resp apiResponse
if err := xml.Unmarshal(body, &resp); err != nil {
return fmt.Errorf("failed to unmarshal XML: %w\nResponse body: %s", err, string(body))
}
if resp.Status != "OK" {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
if len(errorMsgs) == 0 {
errorMsgs = append(errorMsgs, "unknown error (no error details in response)")
}
return fmt.Errorf("API returned error status: %s", errorMsgs)
}
if len(resp.Errors) > 0 {
var errorMsgs []string
for _, err := range resp.Errors {
errorMsgs = append(errorMsgs, fmt.Sprintf("[%s] %s", err.Number, err.Text))
}
return fmt.Errorf("API errors in response: %s", errorMsgs)
}
if resp.CommandResponse.DomainDNSSetDefaultResult.Domain == "" {
return fmt.Errorf("API response missing DomainDNSSetDefaultResult\nResponse body: %s", string(body))
}
*result = resp.CommandResponse.DomainDNSSetDefaultResult
return nil
}