Initial code commit
This commit is contained in:
172
internal/providers/hetzner/client.go
Normal file
172
internal/providers/hetzner/client.go
Normal file
@@ -0,0 +1,172 @@
|
||||
package hetzner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-resty/resty/v2"
|
||||
)
|
||||
|
||||
const (
|
||||
hetznerAPIBaseURL = "https://api.hetzner.cloud/v1"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
apiKey string
|
||||
resty *resty.Client
|
||||
}
|
||||
|
||||
type Server struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Status string `json:"status"`
|
||||
PublicNet PublicNet `json:"public_net"`
|
||||
}
|
||||
|
||||
type PublicNet struct {
|
||||
IPv4 IPv4Info `json:"ipv4"`
|
||||
}
|
||||
|
||||
type IPv4Info struct {
|
||||
IP string `json:"ip"`
|
||||
}
|
||||
|
||||
type ServerResponse struct {
|
||||
Server Server `json:"server"`
|
||||
}
|
||||
|
||||
type ServersResponse struct {
|
||||
Servers []Server `json:"servers"`
|
||||
}
|
||||
|
||||
type CreateServerRequest struct {
|
||||
Name string `json:"name"`
|
||||
ServerType string `json:"server_type"`
|
||||
Image string `json:"image"`
|
||||
Location string `json:"location,omitempty"`
|
||||
SSHKeys []int `json:"ssh_keys,omitempty"`
|
||||
UserData string `json:"user_data,omitempty"`
|
||||
Networks []int `json:"networks,omitempty"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
func NewClient(apiKey string) *Client {
|
||||
client := resty.New()
|
||||
client.SetBaseURL(hetznerAPIBaseURL)
|
||||
client.SetHeader("Authorization", fmt.Sprintf("Bearer %s", apiKey))
|
||||
client.SetTimeout(30 * time.Second)
|
||||
|
||||
return &Client{
|
||||
apiKey: apiKey,
|
||||
resty: client,
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) CreateServer(req CreateServerRequest) (*Server, error) {
|
||||
var resp ServerResponse
|
||||
|
||||
httpResp, err := c.resty.R().
|
||||
SetBody(req).
|
||||
SetResult(&resp).
|
||||
Post("/servers")
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create server: %w", err)
|
||||
}
|
||||
|
||||
if httpResp.IsError() {
|
||||
return nil, fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
|
||||
}
|
||||
|
||||
return &resp.Server, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetServer(serverID int) (*Server, error) {
|
||||
var resp ServerResponse
|
||||
|
||||
httpResp, err := c.resty.R().
|
||||
SetResult(&resp).
|
||||
Get(fmt.Sprintf("/servers/%d", serverID))
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get server: %w", err)
|
||||
}
|
||||
|
||||
if httpResp.IsError() {
|
||||
return nil, fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
|
||||
}
|
||||
|
||||
return &resp.Server, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteServer(serverID int) error {
|
||||
httpResp, err := c.resty.R().
|
||||
Delete(fmt.Sprintf("/servers/%d", serverID))
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete server: %w", err)
|
||||
}
|
||||
|
||||
if httpResp.IsError() {
|
||||
return fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (c *Client) WaitForServerReady(serverID int, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
ticker := time.NewTicker(5 * time.Second)
|
||||
defer ticker.Stop()
|
||||
|
||||
for {
|
||||
select {
|
||||
case <-ticker.C:
|
||||
server, err := c.GetServer(serverID)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check server status: %w", err)
|
||||
}
|
||||
|
||||
if server.Status == "running" {
|
||||
return nil
|
||||
}
|
||||
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timeout waiting for server to be ready (current status: %s)", server.Status)
|
||||
}
|
||||
case <-time.After(timeout):
|
||||
return fmt.Errorf("timeout waiting for server to be ready")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
func (c *Client) GetServerIP(serverID int) (string, error) {
|
||||
server, err := c.GetServer(serverID)
|
||||
if err != nil {
|
||||
return "", err
|
||||
}
|
||||
|
||||
if server.PublicNet.IPv4.IP == "" {
|
||||
return "", fmt.Errorf("server has no public IP address")
|
||||
}
|
||||
|
||||
return server.PublicNet.IPv4.IP, nil
|
||||
}
|
||||
|
||||
func (c *Client) ListServers() ([]Server, error) {
|
||||
var resp ServersResponse
|
||||
|
||||
httpResp, err := c.resty.R().
|
||||
SetResult(&resp).
|
||||
Get("/servers")
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list servers: %w", err)
|
||||
}
|
||||
|
||||
if httpResp.IsError() {
|
||||
return nil, fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
|
||||
}
|
||||
|
||||
return resp.Servers, nil
|
||||
}
|
||||
76
internal/providers/hetzner/server.go
Normal file
76
internal/providers/hetzner/server.go
Normal file
@@ -0,0 +1,76 @@
|
||||
package hetzner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
type ServerInfo struct {
|
||||
ID int
|
||||
Name string
|
||||
Status string
|
||||
PublicIP string
|
||||
Region string
|
||||
Type string
|
||||
}
|
||||
|
||||
func (c *Client) CreateServerWithConfig(name, serverType, location, image, userData string, sshKeyIDs []int) (*ServerInfo, error) {
|
||||
if location == "" {
|
||||
return nil, fmt.Errorf("location is required")
|
||||
}
|
||||
|
||||
validLocations := map[string]bool{
|
||||
"fsn1": true, // Falkenstein, Germany
|
||||
"nbg1": true, // Nuremberg, Germany
|
||||
"hel1": true, // Helsinki, Finland
|
||||
"ash": true, // Ashburn, Virginia
|
||||
"hil": true, // Hillsboro, Oregon
|
||||
"sin": true, // Singapore
|
||||
}
|
||||
|
||||
if !validLocations[location] {
|
||||
return nil, fmt.Errorf("invalid location: %s (valid locations: fsn1, nbg1, hel1, ash, hil, sin)", location)
|
||||
}
|
||||
|
||||
req := CreateServerRequest{
|
||||
Name: name,
|
||||
ServerType: serverType,
|
||||
Image: image,
|
||||
Location: location,
|
||||
UserData: userData,
|
||||
SSHKeys: sshKeyIDs,
|
||||
Labels: map[string]string{
|
||||
"managed-by": "sslh-lab",
|
||||
"created-at": time.Now().Format("2006-01-02T15-04-05"),
|
||||
},
|
||||
}
|
||||
|
||||
server, err := c.CreateServer(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create server: %w", err)
|
||||
}
|
||||
|
||||
serverInfo := &ServerInfo{
|
||||
ID: server.ID,
|
||||
Name: server.Name,
|
||||
Status: server.Status,
|
||||
Region: location,
|
||||
Type: serverType,
|
||||
}
|
||||
|
||||
if server.PublicNet.IPv4.IP != "" {
|
||||
serverInfo.PublicIP = server.PublicNet.IPv4.IP
|
||||
} else {
|
||||
if err := c.WaitForServerReady(server.ID, 10*time.Minute); err != nil {
|
||||
return nil, fmt.Errorf("server created but failed to become ready: %w", err)
|
||||
}
|
||||
|
||||
ip, err := c.GetServerIP(server.ID)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to get server IP: %w", err)
|
||||
}
|
||||
serverInfo.PublicIP = ip
|
||||
}
|
||||
|
||||
return serverInfo, nil
|
||||
}
|
||||
107
internal/providers/hetzner/ssh_keys.go
Normal file
107
internal/providers/hetzner/ssh_keys.go
Normal file
@@ -0,0 +1,107 @@
|
||||
package hetzner
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
)
|
||||
|
||||
type SSHKey struct {
|
||||
ID int `json:"id"`
|
||||
Name string `json:"name"`
|
||||
Fingerprint string `json:"fingerprint"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
type SSHKeyResponse struct {
|
||||
SSHKey SSHKey `json:"ssh_key"`
|
||||
}
|
||||
|
||||
type SSHKeysResponse struct {
|
||||
SSHKeys []SSHKey `json:"ssh_keys"`
|
||||
}
|
||||
|
||||
type CreateSSHKeyRequest struct {
|
||||
Name string `json:"name"`
|
||||
PublicKey string `json:"public_key"`
|
||||
Labels map[string]string `json:"labels,omitempty"`
|
||||
}
|
||||
|
||||
func (c *Client) CreateSSHKey(name, publicKey string) (*SSHKey, error) {
|
||||
req := CreateSSHKeyRequest{
|
||||
Name: name,
|
||||
PublicKey: publicKey,
|
||||
Labels: map[string]string{
|
||||
"managed-by": "sslh-lab",
|
||||
},
|
||||
}
|
||||
|
||||
var resp SSHKeyResponse
|
||||
|
||||
httpResp, err := c.resty.R().
|
||||
SetBody(req).
|
||||
SetResult(&resp).
|
||||
Post("/ssh_keys")
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create SSH key: %w", err)
|
||||
}
|
||||
|
||||
if httpResp.IsError() {
|
||||
return nil, fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
|
||||
}
|
||||
|
||||
return &resp.SSHKey, nil
|
||||
}
|
||||
|
||||
func (c *Client) GetSSHKeyByName(name string) (*SSHKey, error) {
|
||||
var resp SSHKeysResponse
|
||||
|
||||
httpResp, err := c.resty.R().
|
||||
SetResult(&resp).
|
||||
Get("/ssh_keys")
|
||||
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to list SSH keys: %w", err)
|
||||
}
|
||||
|
||||
if httpResp.IsError() {
|
||||
return nil, fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
|
||||
}
|
||||
|
||||
for _, key := range resp.SSHKeys {
|
||||
if key.Name == name {
|
||||
return &key, nil
|
||||
}
|
||||
}
|
||||
|
||||
return nil, fmt.Errorf("SSH key with name '%s' not found", name)
|
||||
}
|
||||
|
||||
func (c *Client) GetOrCreateSSHKey(name, publicKey string) (int, error) {
|
||||
existingKey, err := c.GetSSHKeyByName(name)
|
||||
if err == nil {
|
||||
return existingKey.ID, nil
|
||||
}
|
||||
|
||||
newKey, err := c.CreateSSHKey(name, publicKey)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("failed to create SSH key: %w", err)
|
||||
}
|
||||
|
||||
return newKey.ID, nil
|
||||
}
|
||||
|
||||
func (c *Client) DeleteSSHKey(keyID int) error {
|
||||
httpResp, err := c.resty.R().
|
||||
Delete(fmt.Sprintf("/ssh_keys/%d", keyID))
|
||||
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to delete SSH key: %w", err)
|
||||
}
|
||||
|
||||
if httpResp.IsError() {
|
||||
return fmt.Errorf("API error: %s - %s", httpResp.Status(), string(httpResp.Body()))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
216
internal/providers/letsencrypt/client.go
Normal file
216
internal/providers/letsencrypt/client.go
Normal file
@@ -0,0 +1,216 @@
|
||||
package letsencrypt
|
||||
|
||||
import (
|
||||
"crypto"
|
||||
"crypto/rand"
|
||||
"crypto/rsa"
|
||||
"crypto/x509"
|
||||
"encoding/json"
|
||||
"encoding/pem"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/certificate"
|
||||
"github.com/go-acme/lego/v4/certcrypto"
|
||||
"github.com/go-acme/lego/v4/lego"
|
||||
"github.com/go-acme/lego/v4/registration"
|
||||
)
|
||||
|
||||
type Client struct {
|
||||
user *User
|
||||
client *lego.Client
|
||||
certDir string
|
||||
email string
|
||||
dnsProvider DNSProvider
|
||||
}
|
||||
|
||||
type User struct {
|
||||
Email string
|
||||
Registration *registration.Resource
|
||||
key crypto.PrivateKey
|
||||
}
|
||||
|
||||
func (u *User) GetEmail() string {
|
||||
return u.Email
|
||||
}
|
||||
|
||||
func (u *User) GetRegistration() *registration.Resource {
|
||||
return u.Registration
|
||||
}
|
||||
|
||||
func (u *User) GetPrivateKey() crypto.PrivateKey {
|
||||
return u.key
|
||||
}
|
||||
|
||||
type DNSProvider interface {
|
||||
Present(domain, token, keyAuth string) error
|
||||
CleanUp(domain, token, keyAuth string) error
|
||||
}
|
||||
|
||||
type CertificateInfo struct {
|
||||
CertificatePath string
|
||||
PrivateKeyPath string
|
||||
FullChainPath string
|
||||
Domain string
|
||||
ExpiresAt time.Time
|
||||
}
|
||||
|
||||
func NewClient(email, certDir string, dnsProvider DNSProvider) (*Client, error) {
|
||||
if err := os.MkdirAll(certDir, 0700); err != nil {
|
||||
return nil, fmt.Errorf("failed to create cert directory: %w", err)
|
||||
}
|
||||
|
||||
privateKey, err := rsa.GenerateKey(rand.Reader, 2048)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to generate private key: %w", err)
|
||||
}
|
||||
|
||||
user := &User{
|
||||
Email: email,
|
||||
key: privateKey,
|
||||
}
|
||||
|
||||
config := lego.NewConfig(user)
|
||||
config.CADirURL = lego.LEDirectoryProduction
|
||||
config.Certificate.KeyType = certcrypto.RSA2048
|
||||
config.HTTPClient.Timeout = 30 * time.Second
|
||||
|
||||
client, err := lego.NewClient(config)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to create lego client: %w", err)
|
||||
}
|
||||
|
||||
provider := &namecheapDNSProvider{dnsProvider: dnsProvider}
|
||||
client.Challenge.SetDNS01Provider(provider)
|
||||
|
||||
accountPath := filepath.Join(certDir, "account.json")
|
||||
var reg *registration.Resource
|
||||
if _, err := os.Stat(accountPath); err == nil {
|
||||
accountData, err := os.ReadFile(accountPath)
|
||||
if err == nil {
|
||||
if err := json.Unmarshal(accountData, ®); err == nil && reg != nil {
|
||||
user.Registration = reg
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if user.Registration == nil {
|
||||
reg, err := client.Registration.Register(registration.RegisterOptions{TermsOfServiceAgreed: true})
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to register with Let's Encrypt: %w", err)
|
||||
}
|
||||
user.Registration = reg
|
||||
|
||||
accountData, _ := json.Marshal(reg)
|
||||
os.WriteFile(accountPath, accountData, 0600)
|
||||
}
|
||||
|
||||
return &Client{
|
||||
user: user,
|
||||
client: client,
|
||||
certDir: certDir,
|
||||
email: email,
|
||||
dnsProvider: dnsProvider,
|
||||
}, nil
|
||||
}
|
||||
|
||||
type namecheapDNSProvider struct {
|
||||
dnsProvider DNSProvider
|
||||
}
|
||||
|
||||
func (p *namecheapDNSProvider) Present(domain, token, keyAuth string) error {
|
||||
return p.dnsProvider.Present(domain, token, keyAuth)
|
||||
}
|
||||
|
||||
func (p *namecheapDNSProvider) CleanUp(domain, token, keyAuth string) error {
|
||||
return p.dnsProvider.CleanUp(domain, token, keyAuth)
|
||||
}
|
||||
|
||||
func (c *Client) ObtainCertificate(domain string, sanDomains []string) (*CertificateInfo, error) {
|
||||
domains := append([]string{domain}, sanDomains...)
|
||||
|
||||
request := certificate.ObtainRequest{
|
||||
Domains: domains,
|
||||
Bundle: true,
|
||||
}
|
||||
|
||||
certificates, err := c.client.Certificate.Obtain(request)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to obtain certificate: %w", err)
|
||||
}
|
||||
|
||||
certPath := filepath.Join(c.certDir, fmt.Sprintf("%s.crt", domain))
|
||||
keyPath := filepath.Join(c.certDir, fmt.Sprintf("%s.key", domain))
|
||||
fullChainPath := filepath.Join(c.certDir, fmt.Sprintf("%s-fullchain.crt", domain))
|
||||
|
||||
if err := os.WriteFile(certPath, certificates.Certificate, 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to write certificate: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(keyPath, certificates.PrivateKey, 0600); err != nil {
|
||||
return nil, fmt.Errorf("failed to write private key: %w", err)
|
||||
}
|
||||
|
||||
fullChain := append(certificates.Certificate, certificates.IssuerCertificate...)
|
||||
if err := os.WriteFile(fullChainPath, fullChain, 0644); err != nil {
|
||||
return nil, fmt.Errorf("failed to write full chain: %w", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certificates.Certificate)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode certificate")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
|
||||
return &CertificateInfo{
|
||||
CertificatePath: certPath,
|
||||
PrivateKeyPath: keyPath,
|
||||
FullChainPath: fullChainPath,
|
||||
Domain: domain,
|
||||
ExpiresAt: cert.NotAfter,
|
||||
}, nil
|
||||
}
|
||||
|
||||
func (c *Client) RevokeCertificate(certPath string) error {
|
||||
certBytes, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to read certificate: %w", err)
|
||||
}
|
||||
|
||||
reason := uint(4)
|
||||
return c.client.Certificate.RevokeWithReason(certBytes, &reason)
|
||||
}
|
||||
|
||||
func GetCertificateInfo(certPath string) (*CertificateInfo, error) {
|
||||
certBytes, err := os.ReadFile(certPath)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to read certificate: %w", err)
|
||||
}
|
||||
|
||||
block, _ := pem.Decode(certBytes)
|
||||
if block == nil {
|
||||
return nil, fmt.Errorf("failed to decode certificate")
|
||||
}
|
||||
|
||||
cert, err := x509.ParseCertificate(block.Bytes)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("failed to parse certificate: %w", err)
|
||||
}
|
||||
|
||||
keyPath := certPath[:len(certPath)-4] + ".key"
|
||||
fullChainPath := certPath[:len(certPath)-4] + "-fullchain.crt"
|
||||
|
||||
return &CertificateInfo{
|
||||
CertificatePath: certPath,
|
||||
PrivateKeyPath: keyPath,
|
||||
FullChainPath: fullChainPath,
|
||||
Domain: cert.Subject.CommonName,
|
||||
ExpiresAt: cert.NotAfter,
|
||||
}, nil
|
||||
}
|
||||
94
internal/providers/letsencrypt/namecheap_dns.go
Normal file
94
internal/providers/letsencrypt/namecheap_dns.go
Normal file
@@ -0,0 +1,94 @@
|
||||
package letsencrypt
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
|
||||
"github.com/go-acme/lego/v4/challenge/dns01"
|
||||
"sslh-multiplex-lab/internal/providers/namecheap"
|
||||
)
|
||||
|
||||
type NamecheapDNSProvider struct {
|
||||
namecheapClient *namecheap.Client
|
||||
domain string
|
||||
txtRecords map[string]string
|
||||
}
|
||||
|
||||
func NewNamecheapDNSProvider(namecheapClient *namecheap.Client, domain string) *NamecheapDNSProvider {
|
||||
return &NamecheapDNSProvider{
|
||||
namecheapClient: namecheapClient,
|
||||
domain: domain,
|
||||
txtRecords: make(map[string]string),
|
||||
}
|
||||
}
|
||||
|
||||
func (p *NamecheapDNSProvider) Present(domain, token, keyAuth string) error {
|
||||
fqdn, value := dns01.GetRecord(domain, keyAuth)
|
||||
|
||||
subdomain := extractSubdomain(fqdn, p.domain)
|
||||
if subdomain == "" {
|
||||
return fmt.Errorf("failed to extract subdomain from %s for domain %s", fqdn, p.domain)
|
||||
}
|
||||
|
||||
p.txtRecords[subdomain] = value
|
||||
|
||||
_, err := p.namecheapClient.CreateOrUpdateDNSRecord(p.domain, subdomain, "TXT", value, 300)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to create TXT record for %s: %w", subdomain, err)
|
||||
}
|
||||
|
||||
time.Sleep(10 * time.Second)
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func (p *NamecheapDNSProvider) CleanUp(domain, token, keyAuth string) error {
|
||||
fqdn, _ := dns01.GetRecord(domain, keyAuth)
|
||||
|
||||
subdomain := extractSubdomain(fqdn, p.domain)
|
||||
if subdomain == "" {
|
||||
return nil
|
||||
}
|
||||
|
||||
records, err := p.namecheapClient.ListDNSRecords(p.domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to list DNS records: %w", err)
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
if record.Name == subdomain && record.Type == "TXT" {
|
||||
if err := p.namecheapClient.DeleteDNSRecord(p.domain, record.ID); err != nil {
|
||||
return fmt.Errorf("failed to delete TXT record for %s: %w", subdomain, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
delete(p.txtRecords, subdomain)
|
||||
return nil
|
||||
}
|
||||
|
||||
func extractSubdomain(fqdn, domain string) string {
|
||||
if len(fqdn) <= len(domain) {
|
||||
return ""
|
||||
}
|
||||
|
||||
suffix := "." + domain
|
||||
if !endsWith(fqdn, suffix) {
|
||||
return ""
|
||||
}
|
||||
|
||||
subdomain := fqdn[:len(fqdn)-len(suffix)]
|
||||
if subdomain == "_acme-challenge" {
|
||||
return "_acme-challenge"
|
||||
}
|
||||
|
||||
if len(subdomain) > len("_acme-challenge.") && subdomain[:len("_acme-challenge.")] == "_acme-challenge." {
|
||||
return subdomain
|
||||
}
|
||||
|
||||
return ""
|
||||
}
|
||||
|
||||
func endsWith(s, suffix string) bool {
|
||||
return len(s) >= len(suffix) && s[len(s)-len(suffix):] == suffix
|
||||
}
|
||||
567
internal/providers/namecheap/client.go
Normal file
567
internal/providers/namecheap/client.go
Normal file
@@ -0,0 +1,567 @@
|
||||
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
|
||||
}
|
||||
34
internal/providers/namecheap/dns.go
Normal file
34
internal/providers/namecheap/dns.go
Normal file
@@ -0,0 +1,34 @@
|
||||
package namecheap
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"time"
|
||||
)
|
||||
|
||||
func (c *Client) WaitForDNSPropagation(domain, subdomain, expectedIP string, timeout time.Duration) error {
|
||||
deadline := time.Now().Add(timeout)
|
||||
backoff := 5 * time.Second
|
||||
maxBackoff := 80 * time.Second
|
||||
|
||||
for {
|
||||
if time.Now().After(deadline) {
|
||||
return fmt.Errorf("timeout waiting for DNS propagation")
|
||||
}
|
||||
|
||||
records, err := c.ListDNSRecords(domain)
|
||||
if err != nil {
|
||||
return fmt.Errorf("failed to check DNS records: %w", err)
|
||||
}
|
||||
|
||||
for _, record := range records {
|
||||
if record.Name == subdomain && record.Address == expectedIP {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
time.Sleep(backoff)
|
||||
if backoff < maxBackoff {
|
||||
backoff *= 2
|
||||
}
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user