// Package cpanel implements a DNS provider for solving the DNS-01 challenge using CPanel. package cpanel import ( "context" "encoding/base64" "errors" "fmt" "net/http" "strings" "time" "github.com/go-acme/lego/v4/challenge/dns01" "github.com/go-acme/lego/v4/platform/config/env" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/cpanel" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/shared" "github.com/go-acme/lego/v4/providers/dns/cpanel/internal/whm" ) // Environment variables names. const ( envNamespace = "CPANEL_" EnvMode = envNamespace + "MODE" EnvUsername = envNamespace + "USERNAME" EnvToken = envNamespace + "TOKEN" EnvBaseURL = envNamespace + "BASE_URL" EnvNameserver = envNamespace + "NAMESERVER" EnvTTL = envNamespace + "TTL" EnvPropagationTimeout = envNamespace + "PROPAGATION_TIMEOUT" EnvPollingInterval = envNamespace + "POLLING_INTERVAL" EnvHTTPTimeout = envNamespace + "HTTP_TIMEOUT" ) type apiClient interface { FetchZoneInformation(ctx context.Context, domain string) ([]shared.ZoneRecord, error) AddRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) EditRecord(ctx context.Context, serial uint32, domain string, record shared.Record) (*shared.ZoneSerial, error) DeleteRecord(ctx context.Context, serial uint32, domain string, lineIndex int) (*shared.ZoneSerial, error) } // Config is used to configure the creation of the DNSProvider. type Config struct { Mode string Username string Token string BaseURL string Nameserver string TTL int PropagationTimeout time.Duration PollingInterval time.Duration HTTPClient *http.Client } // NewDefaultConfig returns a default configuration for the DNSProvider. func NewDefaultConfig() *Config { return &Config{ Mode: env.GetOrDefaultString(EnvMode, "cpanel"), TTL: env.GetOrDefaultInt(EnvTTL, 300), PropagationTimeout: env.GetOrDefaultSecond(EnvPropagationTimeout, dns01.DefaultPropagationTimeout), PollingInterval: env.GetOrDefaultSecond(EnvPollingInterval, dns01.DefaultPollingInterval), HTTPClient: &http.Client{ Timeout: env.GetOrDefaultSecond(EnvHTTPTimeout, 30*time.Second), }, } } // DNSProvider implements the challenge.Provider interface. type DNSProvider struct { config *Config client apiClient dnsClient *shared.DNSClient } // NewDNSProvider returns a DNSProvider instance configured for CPanel. // Credentials must be passed in the environment variables: // CPANEL_USERNAME, CPANEL_TOKEN, CPANEL_BASE_URL, CPANEL_NAMESERVER. func NewDNSProvider() (*DNSProvider, error) { values, err := env.Get(EnvUsername, EnvToken, EnvBaseURL, EnvNameserver) if err != nil { return nil, fmt.Errorf("cpanel: %w", err) } config := NewDefaultConfig() config.Username = values[EnvUsername] config.Token = values[EnvToken] config.BaseURL = values[EnvBaseURL] config.Nameserver = values[EnvNameserver] return NewDNSProviderConfig(config) } // NewDNSProviderConfig return a DNSProvider instance configured for CPanel. func NewDNSProviderConfig(config *Config) (*DNSProvider, error) { if config == nil { return nil, errors.New("cpanel: the configuration of the DNS provider is nil") } if config.Username == "" || config.Token == "" { return nil, errors.New("cpanel: some credentials information are missing") } if config.BaseURL == "" || config.Nameserver == "" { return nil, errors.New("cpanel: server information are missing") } client, err := createClient(config) if err != nil { return nil, fmt.Errorf("cpanel: create client error: %w", err) } return &DNSProvider{ config: config, client: client, dnsClient: shared.NewDNSClient(10 * time.Second), }, nil } // Timeout returns the timeout and interval to use when checking for DNS propagation. // Adjusting here to cope with spikes in propagation times. func (d *DNSProvider) Timeout() (timeout, interval time.Duration) { return d.config.PropagationTimeout, d.config.PollingInterval } // Present creates a TXT record to fulfill the dns-01 challenge. func (d *DNSProvider) Present(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) effectiveDomain := strings.TrimPrefix(info.EffectiveFQDN, "_acme-challenge.") soa, err := d.dnsClient.SOACall(effectiveDomain, d.config.Nameserver) if err != nil { return fmt.Errorf("cpanel[mode=%s]: could not find SOA for domain %q (%s) in %s: %w", d.config.Mode, domain, info.EffectiveFQDN, d.config.Nameserver, err) } zone := dns01.UnFqdn(soa.Hdr.Name) zoneInfo, err := d.client.FetchZoneInformation(ctx, zone) if err != nil { return fmt.Errorf("cpanel[mode=%s]: fetch zone information: %w", d.config.Mode, err) } serial, err := getZoneSerial(soa.Hdr.Name, zoneInfo) if err != nil { return fmt.Errorf("cpanel[mode=%s]: get zone serial: %w", d.config.Mode, err) } valueB64 := base64.StdEncoding.EncodeToString([]byte(info.Value)) var found bool var existingRecord shared.ZoneRecord for _, record := range zoneInfo { if contains(record.DataB64, valueB64) { existingRecord = record found = true break } } record := shared.Record{ DName: info.EffectiveFQDN, TTL: d.config.TTL, RecordType: "TXT", } // New record. if !found { record.Data = []string{info.Value} _, err = d.client.AddRecord(ctx, serial, zone, record) if err != nil { return fmt.Errorf("cpanel[mode=%s]: add record: %w", d.config.Mode, err) } return nil } // Update existing record. record.LineIndex = existingRecord.LineIndex for _, dataB64 := range existingRecord.DataB64 { data, errD := base64.StdEncoding.DecodeString(dataB64) if errD != nil { return fmt.Errorf("cpanel[mode=%s]: decode base64 record value: %w", d.config.Mode, errD) } record.Data = append(record.Data, string(data)) } record.Data = append(record.Data, info.Value) _, err = d.client.EditRecord(ctx, serial, zone, record) if err != nil { return fmt.Errorf("cpanel[mode=%s]: edit record: %w", d.config.Mode, err) } return nil } // CleanUp removes the TXT record matching the specified parameters. func (d *DNSProvider) CleanUp(domain, _, keyAuth string) error { ctx := context.Background() info := dns01.GetChallengeInfo(domain, keyAuth) soa, err := d.dnsClient.SOACall(strings.TrimPrefix(info.EffectiveFQDN, "_acme-challenge."), d.config.Nameserver) if err != nil { return fmt.Errorf("cpanel[mode=%s]: could not find SOA for domain %q (%s) in %s: %w", d.config.Mode, domain, info.EffectiveFQDN, d.config.Nameserver, err) } zone := dns01.UnFqdn(soa.Hdr.Name) zoneInfo, err := d.client.FetchZoneInformation(ctx, zone) if err != nil { return fmt.Errorf("cpanel[mode=%s]: fetch zone information: %w", d.config.Mode, err) } serial, err := getZoneSerial(soa.Hdr.Name, zoneInfo) if err != nil { return fmt.Errorf("cpanel[mode=%s]: get zone serial: %w", d.config.Mode, err) } valueB64 := base64.StdEncoding.EncodeToString([]byte(info.Value)) var found bool var existingRecord shared.ZoneRecord for _, record := range zoneInfo { if contains(record.DataB64, valueB64) { existingRecord = record found = true break } } if !found { return nil } var newData []string for _, dataB64 := range existingRecord.DataB64 { if dataB64 == valueB64 { continue } data, errD := base64.StdEncoding.DecodeString(dataB64) if errD != nil { return fmt.Errorf("cpanel[mode=%s]: decode base64 record value: %w", d.config.Mode, errD) } newData = append(newData, string(data)) } // Delete record. if len(newData) == 0 { _, err = d.client.DeleteRecord(ctx, serial, zone, existingRecord.LineIndex) if err != nil { return fmt.Errorf("cpanel[mode=%s]: delete record: %w", d.config.Mode, err) } return nil } // Remove one value. record := shared.Record{ DName: info.EffectiveFQDN, TTL: d.config.TTL, RecordType: "TXT", Data: newData, LineIndex: existingRecord.LineIndex, } _, err = d.client.EditRecord(ctx, serial, zone, record) if err != nil { return fmt.Errorf("cpanel[mode=%s]: edit record: %w", d.config.Mode, err) } return nil } func getZoneSerial(zoneFqdn string, zoneInfo []shared.ZoneRecord) (uint32, error) { nameB64 := base64.StdEncoding.EncodeToString([]byte(zoneFqdn)) for _, record := range zoneInfo { if record.Type != "record" || record.RecordType != "SOA" || record.DNameB64 != nameB64 { continue } // https://github.com/go-acme/lego/issues/1060#issuecomment-1925572386 // https://github.com/go-acme/lego/issues/1060#issuecomment-1925581832 data, err := base64.StdEncoding.DecodeString(record.DataB64[2]) if err != nil { return 0, fmt.Errorf("decode serial DNameB64: %w", err) } var newSerial uint32 _, err = fmt.Sscan(string(data), &newSerial) if err != nil { return 0, fmt.Errorf("decode serial DNameB64, invalid serial value %q: %w", string(data), err) } return newSerial, nil } return 0, errors.New("zone serial not found") } func createClient(config *Config) (apiClient, error) { switch strings.ToLower(config.Mode) { case "cpanel": client, err := cpanel.NewClient(config.BaseURL, config.Username, config.Token) if err != nil { return nil, fmt.Errorf("failed to create cPanel API client: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } return client, nil case "whm": client, err := whm.NewClient(config.BaseURL, config.Username, config.Token) if err != nil { return nil, fmt.Errorf("failed to create WHM API client: %w", err) } if config.HTTPClient != nil { client.HTTPClient = config.HTTPClient } return client, nil default: return nil, fmt.Errorf("unsupported mode: %q", config.Mode) } } func contains(values []string, value string) bool { for _, v := range values { if v == value { return true } } return false }