Autarch/services/setec-manager/docs/custom-provider-guide.md
2026-03-12 20:51:38 -07:00

30 KiB

Custom Hosting Provider Guide

This guide walks you through creating a new hosting provider integration for Setec Manager. By the end, you will have a provider package that auto-registers with the system and can be used through the same unified API as the built-in Hostinger provider.


Prerequisites

  • Go 1.25+ (matching the project's go.mod)
  • Familiarity with the Go interface pattern and HTTP client programming
  • An API key or credentials for the hosting provider you are integrating
  • A checkout of the setec-manager repository

Project Structure

Provider implementations live under internal/hosting/<provider_name>/. Each provider is its own Go package.

internal/hosting/
  provider.go              -- Provider interface + model types + registry
  store.go                 -- ProviderConfig, ProviderConfigStore
  config.go                -- Legacy config store
  hostinger/               -- Built-in Hostinger provider
    client.go              --   HTTP client, auth, retry logic
    dns.go                 --   DNS record operations
  myprovider/              -- Your new provider (create this)
    provider.go            --   init() registration + interface methods
    client.go              --   HTTP client for the provider's API
    dns.go                 --   (optional) DNS-specific logic
    domains.go             --   (optional) Domain-specific logic
    vms.go                 --   (optional) VPS-specific logic

You can organize files however you like within the package; the only requirement is that the package calls hosting.Register(...) in an init() function.


The Provider Interface

The Provider interface is defined in internal/hosting/provider.go. Every provider must implement all methods. Methods that your provider does not support should return ErrNotSupported.

type Provider interface {
    // Identity
    Name() string
    DisplayName() string

    // Configuration
    Configure(config map[string]string) error
    TestConnection() error

    // DNS
    ListDNSRecords(domain string) ([]DNSRecord, error)
    CreateDNSRecord(domain string, record DNSRecord) error
    UpdateDNSRecords(domain string, records []DNSRecord, overwrite bool) error
    DeleteDNSRecord(domain string, recordName, recordType string) error
    ResetDNSRecords(domain string) error

    // Domains
    ListDomains() ([]Domain, error)
    GetDomain(domain string) (*Domain, error)
    CheckDomainAvailability(domains []string) ([]DomainAvailability, error)
    PurchaseDomain(req DomainPurchaseRequest) (*Domain, error)
    SetNameservers(domain string, nameservers []string) error
    EnableDomainLock(domain string) error
    DisableDomainLock(domain string) error
    EnablePrivacyProtection(domain string) error
    DisablePrivacyProtection(domain string) error

    // VMs / VPS
    ListVMs() ([]VM, error)
    GetVM(id string) (*VM, error)
    CreateVM(req VMCreateRequest) (*VM, error)
    ListDataCenters() ([]DataCenter, error)
    ListSSHKeys() ([]SSHKey, error)
    AddSSHKey(name, publicKey string) (*SSHKey, error)
    DeleteSSHKey(id string) error

    // Billing
    ListSubscriptions() ([]Subscription, error)
    GetCatalog() ([]CatalogItem, error)
}

Method Reference

Identity Methods

Method Parameters Returns Description
Name() - string Short machine-readable name (lowercase, no spaces). Used as the registry key and in API URLs. Example: "hostinger", "cloudflare".
DisplayName() - string Human-readable name shown in the UI. Example: "Hostinger", "Cloudflare".

Configuration Methods

Method Parameters Returns Description
Configure(config) map[string]string -- key-value config pairs. Common keys: "api_key", "api_secret", "base_url". error Called when a user saves credentials. Store them in struct fields. Validate format but do not make API calls.
TestConnection() - error Make a lightweight API call (e.g., list domains) to verify credentials are valid. Return nil on success.

DNS Methods

Method Parameters Returns Description
ListDNSRecords(domain) domain string -- the FQDN ([]DNSRecord, error) Return all DNS records for the zone.
CreateDNSRecord(domain, record) domain string, record DNSRecord error Add a single record without affecting existing records.
UpdateDNSRecords(domain, records, overwrite) domain string, records []DNSRecord, overwrite bool error Batch update. If overwrite is true, replace all records; otherwise merge.
DeleteDNSRecord(domain, recordName, recordType) domain string, recordName string (subdomain or @), recordType string (e.g. "A") error Delete matching records.
ResetDNSRecords(domain) domain string error Reset the zone to provider defaults.

Domain Methods

Method Parameters Returns Description
ListDomains() - ([]Domain, error) Return all domains on the account.
GetDomain(domain) domain string (*Domain, error) Return details for a single domain.
CheckDomainAvailability(domains) domains []string ([]DomainAvailability, error) Check if domains are available for registration and return pricing.
PurchaseDomain(req) req DomainPurchaseRequest (*Domain, error) Register a new domain.
SetNameservers(domain, nameservers) domain string, nameservers []string error Update the authoritative nameservers.
EnableDomainLock(domain) domain string error Enable registrar lock (transfer protection).
DisableDomainLock(domain) domain string error Disable registrar lock.
EnablePrivacyProtection(domain) domain string error Enable WHOIS privacy.
DisablePrivacyProtection(domain) domain string error Disable WHOIS privacy.

VM / VPS Methods

Method Parameters Returns Description
ListVMs() - ([]VM, error) Return all VPS instances on the account.
GetVM(id) id string (*VM, error) Return details for a single VM.
CreateVM(req) req VMCreateRequest (*VM, error) Provision a new VPS instance.
ListDataCenters() - ([]DataCenter, error) Return available regions/data centers.
ListSSHKeys() - ([]SSHKey, error) Return all stored SSH public keys.
AddSSHKey(name, publicKey) name string, publicKey string (*SSHKey, error) Upload a new SSH public key.
DeleteSSHKey(id) id string error Remove an SSH key.

Billing Methods

Method Parameters Returns Description
ListSubscriptions() - ([]Subscription, error) Return all active subscriptions.
GetCatalog() - ([]CatalogItem, error) Return purchasable products and plans.

Type Reference

All model types are defined in internal/hosting/provider.go.

DNSRecord

Field Type JSON Description
ID string id Provider-assigned identifier. May be synthesized (e.g., name/type/priority). Optional on create.
Type string type Record type: A, AAAA, CNAME, MX, TXT, NS, SRV, CAA.
Name string name Subdomain label or @ for the zone apex.
Content string content Record value (IP address, hostname, text, etc.).
TTL int ttl Time-to-live in seconds.
Priority int priority Priority value for MX and SRV records. Zero for other types.

Domain

Field Type JSON Description
Name string name Fully qualified domain name.
Registrar string registrar Registrar name (optional).
Status string status Registration status (e.g., "active", "expired", "pending").
ExpiresAt time.Time expires_at Expiration date.
AutoRenew bool auto_renew Whether automatic renewal is enabled.
Locked bool locked Whether transfer lock is enabled.
PrivacyProtection bool privacy_protection Whether WHOIS privacy is enabled.
Nameservers []string nameservers Current authoritative nameservers.

DomainAvailability

Field Type JSON Description
Domain string domain The queried domain name.
Available bool available Whether the domain is available for registration.
Price float64 price Purchase price (zero if unavailable).
Currency string currency Currency code (e.g., "USD").

DomainPurchaseRequest

Field Type JSON Description
Domain string domain Domain to purchase.
Period int period Registration period in years.
AutoRenew bool auto_renew Enable auto-renewal.
Privacy bool privacy_protection Enable WHOIS privacy.
PaymentID string payment_method_id Payment method identifier (optional, provider-specific).

VM

Field Type JSON Description
ID string id Provider-assigned VM identifier.
Name string name Human-readable VM name / hostname.
Status string status Current state: "running", "stopped", "creating", "error".
Plan string plan Plan/tier identifier.
Region string region Data center / region identifier.
IPv4 string ipv4 Public IPv4 address (optional).
IPv6 string ipv6 Public IPv6 address (optional).
OS string os Operating system template name (optional).
CPUs int cpus Number of virtual CPUs.
MemoryMB int memory_mb RAM in megabytes.
DiskGB int disk_gb Disk size in gigabytes.
BandwidthGB int bandwidth_gb Monthly bandwidth allowance in gigabytes.
CreatedAt time.Time created_at Creation timestamp.
Labels map[string]string labels Arbitrary key-value labels (optional).

VMCreateRequest

Field Type JSON Description
Plan string plan Plan/tier identifier from the catalog.
DataCenterID string data_center_id Target data center from ListDataCenters().
Template string template OS template identifier.
Password string password Root/admin password for the VM.
Hostname string hostname Desired hostname.
SSHKeyID string ssh_key_id SSH key to install (optional).
PaymentID string payment_method_id Payment method identifier (optional).

DataCenter

Field Type JSON Description
ID string id Unique identifier used in VMCreateRequest.
Name string name Short name (e.g., "US East").
Location string location City or locality.
Country string country ISO country code.

SSHKey

Field Type JSON Description
ID string id Provider-assigned key identifier.
Name string name User-assigned label.
Fingerprint string fingerprint Key fingerprint (e.g., "SHA256:...").
PublicKey string public_key Full public key string.

Subscription

Field Type JSON Description
ID string id Subscription identifier.
Name string name Product name.
Status string status Status: "active", "cancelled", "expired".
Plan string plan Plan identifier.
Price float64 price Recurring price.
Currency string currency Currency code.
RenewsAt time.Time renews_at Next renewal date.
CreatedAt time.Time created_at Subscription start date.

CatalogItem

Field Type JSON Description
ID string id Product/plan identifier.
Name string name Product name.
Category string category Category: "vps", "hosting", "domain", etc.
PriceCents int price_cents Price in cents (e.g., 1199 = $11.99).
Currency string currency Currency code.
Period string period Billing period: "monthly", "yearly".
Description string description Human-readable description (optional).

ProviderConfig

Stored in internal/hosting/store.go. This is the credential record persisted to disk.

Field Type JSON Description
Provider string provider Provider name (must match Provider.Name()).
APIKey string api_key Primary API key or bearer token.
APISecret string api_secret Secondary secret (optional, provider-specific).
Extra map[string]string extra Additional provider-specific config values.
Connected bool connected Whether the last TestConnection() succeeded.

Implementing the Interface

Step 1: Create the Package

mkdir -p internal/hosting/myprovider

Step 2: Implement the Provider

Create internal/hosting/myprovider/provider.go:

package myprovider

import (
    "errors"
    "fmt"
    "net/http"
    "time"

    "setec-manager/internal/hosting"
)

// ErrNotSupported is returned by methods this provider does not implement.
var ErrNotSupported = errors.New("myprovider: operation not supported")

// Provider implements hosting.Provider for the MyProvider service.
type Provider struct {
    client  *http.Client
    apiKey  string
    baseURL string
}

// init registers this provider with the hosting registry.
// This runs automatically when the package is imported.
func init() {
    hosting.Register(&Provider{
        client: &http.Client{
            Timeout: 30 * time.Second,
        },
        baseURL: "https://api.myprovider.com",
    })
}

// ── Identity ────────────────────────────────────────────────────────

func (p *Provider) Name() string        { return "myprovider" }
func (p *Provider) DisplayName() string { return "My Provider" }

// ── Configuration ───────────────────────────────────────────────────

func (p *Provider) Configure(config map[string]string) error {
    key, ok := config["api_key"]
    if !ok || key == "" {
        return fmt.Errorf("myprovider: api_key is required")
    }
    p.apiKey = key

    if baseURL, ok := config["base_url"]; ok && baseURL != "" {
        p.baseURL = baseURL
    }
    return nil
}

func (p *Provider) TestConnection() error {
    // Make a lightweight API call to verify credentials.
    // For example, list domains or get account info.
    _, err := p.ListDomains()
    return err
}

// ── DNS ─────────────────────────────────────────────────────────────

func (p *Provider) ListDNSRecords(domain string) ([]hosting.DNSRecord, error) {
    // TODO: Implement API call to list DNS records
    return nil, ErrNotSupported
}

func (p *Provider) CreateDNSRecord(domain string, record hosting.DNSRecord) error {
    return ErrNotSupported
}

func (p *Provider) UpdateDNSRecords(domain string, records []hosting.DNSRecord, overwrite bool) error {
    return ErrNotSupported
}

func (p *Provider) DeleteDNSRecord(domain string, recordName, recordType string) error {
    return ErrNotSupported
}

func (p *Provider) ResetDNSRecords(domain string) error {
    return ErrNotSupported
}

// ── Domains ─────────────────────────────────────────────────────────

func (p *Provider) ListDomains() ([]hosting.Domain, error) {
    return nil, ErrNotSupported
}

func (p *Provider) GetDomain(domain string) (*hosting.Domain, error) {
    return nil, ErrNotSupported
}

func (p *Provider) CheckDomainAvailability(domains []string) ([]hosting.DomainAvailability, error) {
    return nil, ErrNotSupported
}

func (p *Provider) PurchaseDomain(req hosting.DomainPurchaseRequest) (*hosting.Domain, error) {
    return nil, ErrNotSupported
}

func (p *Provider) SetNameservers(domain string, nameservers []string) error {
    return ErrNotSupported
}

func (p *Provider) EnableDomainLock(domain string) error  { return ErrNotSupported }
func (p *Provider) DisableDomainLock(domain string) error { return ErrNotSupported }
func (p *Provider) EnablePrivacyProtection(domain string) error  { return ErrNotSupported }
func (p *Provider) DisablePrivacyProtection(domain string) error { return ErrNotSupported }

// ── VMs / VPS ───────────────────────────────────────────────────────

func (p *Provider) ListVMs() ([]hosting.VM, error)                          { return nil, ErrNotSupported }
func (p *Provider) GetVM(id string) (*hosting.VM, error)                    { return nil, ErrNotSupported }
func (p *Provider) CreateVM(req hosting.VMCreateRequest) (*hosting.VM, error) { return nil, ErrNotSupported }
func (p *Provider) ListDataCenters() ([]hosting.DataCenter, error)          { return nil, ErrNotSupported }
func (p *Provider) ListSSHKeys() ([]hosting.SSHKey, error)                  { return nil, ErrNotSupported }
func (p *Provider) AddSSHKey(name, publicKey string) (*hosting.SSHKey, error) { return nil, ErrNotSupported }
func (p *Provider) DeleteSSHKey(id string) error                            { return ErrNotSupported }

// ── Billing ─────────────────────────────────────────────────────────

func (p *Provider) ListSubscriptions() ([]hosting.Subscription, error) { return nil, ErrNotSupported }
func (p *Provider) GetCatalog() ([]hosting.CatalogItem, error)         { return nil, ErrNotSupported }

Registration

Registration happens automatically via Go's init() mechanism. When the main binary imports the provider package (even as a side-effect import), the init() function runs and calls hosting.Register().

In cmd/main.go (or wherever the binary entry point is), add a blank import:

import (
    // Register hosting providers
    _ "setec-manager/internal/hosting/hostinger"
    _ "setec-manager/internal/hosting/myprovider"
)

The hosting.Register() function stores the provider instance in a global map[string]Provider protected by a sync.RWMutex:

// From internal/hosting/provider.go
func Register(p Provider) {
    registryMu.Lock()
    defer registryMu.Unlock()
    registry[p.Name()] = p
}

After registration, the provider is accessible via hosting.Get("myprovider") and appears in hosting.List().


Configuration Storage

When a user configures your provider (via the UI or API), the system:

  1. Calls provider.Configure(map[string]string{"api_key": "..."}) to set credentials in memory.
  2. Calls provider.TestConnection() to verify the credentials work.
  3. Saves a ProviderConfig to disk via ProviderConfigStore.Save().

The config file is written to <config_dir>/<provider_name>.json with 0600 permissions:

{
  "provider": "myprovider",
  "api_key": "sk-abc123...",
  "api_secret": "",
  "extra": {
    "base_url": "https://api.myprovider.com/v2"
  },
  "connected": true
}

On startup, ProviderConfigStore.loadAll() reads all JSON files from the config directory, and for each one that matches a registered provider, calls Configure() to restore credentials.


Error Handling

The ErrNotSupported Pattern

Define a sentinel error in your provider package:

var ErrNotSupported = errors.New("myprovider: operation not supported")

Return this error from any interface method your provider does not implement. The HTTP handler layer checks for this error and returns HTTP 501 (Not Implemented) to the client.

API Errors

For errors from the upstream provider API, return a descriptive error with context:

return fmt.Errorf("myprovider: list domains: %w", err)

Rate Limiting

If the provider has rate limits, handle them inside your client. See the Hostinger implementation in internal/hosting/hostinger/client.go for a reference pattern:

  1. Check for HTTP 429 responses.
  2. Read the Retry-After header.
  3. Sleep and retry (up to a maximum number of retries).
  4. Return a clear error if retries are exhausted.
if resp.StatusCode == http.StatusTooManyRequests {
    retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
    if attempt < maxRetries {
        time.Sleep(retryAfter)
        continue
    }
    return fmt.Errorf("myprovider: rate limited after %d retries", maxRetries)
}

Testing

Unit Tests

Create internal/hosting/myprovider/provider_test.go:

package myprovider

import (
    "testing"

    "setec-manager/internal/hosting"
)

func TestProviderImplementsInterface(t *testing.T) {
    var _ hosting.Provider = (*Provider)(nil)
}

func TestName(t *testing.T) {
    p := &Provider{}
    if p.Name() != "myprovider" {
        t.Errorf("expected name 'myprovider', got %q", p.Name())
    }
}

func TestConfigure(t *testing.T) {
    p := &Provider{}
    err := p.Configure(map[string]string{})
    if err == nil {
        t.Error("expected error when api_key is missing")
    }

    err = p.Configure(map[string]string{"api_key": "test-key"})
    if err != nil {
        t.Errorf("unexpected error: %v", err)
    }
    if p.apiKey != "test-key" {
        t.Errorf("expected apiKey 'test-key', got %q", p.apiKey)
    }
}

func TestUnsupportedMethodsReturnError(t *testing.T) {
    p := &Provider{}

    _, err := p.ListVMs()
    if err != ErrNotSupported {
        t.Errorf("ListVMs: expected ErrNotSupported, got %v", err)
    }

    _, err = p.GetCatalog()
    if err != ErrNotSupported {
        t.Errorf("GetCatalog: expected ErrNotSupported, got %v", err)
    }
}

Integration Tests

For integration tests against the real API, use build tags to prevent them from running in CI:

//go:build integration

package myprovider

import (
    "os"
    "testing"
)

func TestListDomainsIntegration(t *testing.T) {
    key := os.Getenv("MYPROVIDER_API_KEY")
    if key == "" {
        t.Skip("MYPROVIDER_API_KEY not set")
    }

    p := &Provider{}
    p.Configure(map[string]string{"api_key": key})

    domains, err := p.ListDomains()
    if err != nil {
        t.Fatalf("ListDomains failed: %v", err)
    }
    t.Logf("Found %d domains", len(domains))
}

Run integration tests:

go test -tags=integration ./internal/hosting/myprovider/ -v

Registration Test

Verify that importing the package registers the provider:

package myprovider_test

import (
    "testing"

    "setec-manager/internal/hosting"
    _ "setec-manager/internal/hosting/myprovider"
)

func TestRegistration(t *testing.T) {
    p, err := hosting.Get("myprovider")
    if err != nil {
        t.Fatalf("provider not registered: %v", err)
    }
    if p.DisplayName() == "" {
        t.Error("DisplayName is empty")
    }
}

Example: Skeleton Provider (DNS Only)

This is a complete, minimal provider that implements only DNS management. All other methods return ErrNotSupported. You can copy this file and fill in the DNS methods with real API calls.

package dnsonlyprovider

import (
    "encoding/json"
    "errors"
    "fmt"
    "io"
    "net/http"
    "time"

    "setec-manager/internal/hosting"
)

var ErrNotSupported = errors.New("dnsonlyprovider: operation not supported")

type Provider struct {
    client  *http.Client
    apiKey  string
    baseURL string
}

func init() {
    hosting.Register(&Provider{
        client:  &http.Client{Timeout: 30 * time.Second},
        baseURL: "https://api.dns-only.example.com/v1",
    })
}

func (p *Provider) Name() string        { return "dnsonlyprovider" }
func (p *Provider) DisplayName() string { return "DNS-Only Provider" }

func (p *Provider) Configure(config map[string]string) error {
    key, ok := config["api_key"]
    if !ok || key == "" {
        return fmt.Errorf("dnsonlyprovider: api_key is required")
    }
    p.apiKey = key
    return nil
}

func (p *Provider) TestConnection() error {
    // Try listing zones as a health check.
    req, _ := http.NewRequest("GET", p.baseURL+"/zones", nil)
    req.Header.Set("Authorization", "Bearer "+p.apiKey)
    resp, err := p.client.Do(req)
    if err != nil {
        return fmt.Errorf("dnsonlyprovider: connection failed: %w", err)
    }
    defer resp.Body.Close()
    if resp.StatusCode != http.StatusOK {
        body, _ := io.ReadAll(resp.Body)
        return fmt.Errorf("dnsonlyprovider: API returned %d: %s", resp.StatusCode, body)
    }
    return nil
}

// ── DNS (implemented) ───────────────────────────────────────────────

func (p *Provider) ListDNSRecords(domain string) ([]hosting.DNSRecord, error) {
    req, _ := http.NewRequest("GET", fmt.Sprintf("%s/zones/%s/records", p.baseURL, domain), nil)
    req.Header.Set("Authorization", "Bearer "+p.apiKey)

    resp, err := p.client.Do(req)
    if err != nil {
        return nil, fmt.Errorf("dnsonlyprovider: list records: %w", err)
    }
    defer resp.Body.Close()

    var records []hosting.DNSRecord
    if err := json.NewDecoder(resp.Body).Decode(&records); err != nil {
        return nil, fmt.Errorf("dnsonlyprovider: parse records: %w", err)
    }
    return records, nil
}

func (p *Provider) CreateDNSRecord(domain string, record hosting.DNSRecord) error {
    // Implementation: POST to /zones/{domain}/records
    return ErrNotSupported // replace with real implementation
}

func (p *Provider) UpdateDNSRecords(domain string, records []hosting.DNSRecord, overwrite bool) error {
    // Implementation: PUT to /zones/{domain}/records
    return ErrNotSupported // replace with real implementation
}

func (p *Provider) DeleteDNSRecord(domain string, recordName, recordType string) error {
    // Implementation: DELETE /zones/{domain}/records?name=...&type=...
    return ErrNotSupported // replace with real implementation
}

func (p *Provider) ResetDNSRecords(domain string) error {
    return ErrNotSupported
}

// ── Everything else: not supported ──────────────────────────────────

func (p *Provider) ListDomains() ([]hosting.Domain, error)                                    { return nil, ErrNotSupported }
func (p *Provider) GetDomain(domain string) (*hosting.Domain, error)                          { return nil, ErrNotSupported }
func (p *Provider) CheckDomainAvailability(domains []string) ([]hosting.DomainAvailability, error) { return nil, ErrNotSupported }
func (p *Provider) PurchaseDomain(req hosting.DomainPurchaseRequest) (*hosting.Domain, error)  { return nil, ErrNotSupported }
func (p *Provider) SetNameservers(domain string, nameservers []string) error                   { return ErrNotSupported }
func (p *Provider) EnableDomainLock(domain string) error                                       { return ErrNotSupported }
func (p *Provider) DisableDomainLock(domain string) error                                      { return ErrNotSupported }
func (p *Provider) EnablePrivacyProtection(domain string) error                                { return ErrNotSupported }
func (p *Provider) DisablePrivacyProtection(domain string) error                               { return ErrNotSupported }
func (p *Provider) ListVMs() ([]hosting.VM, error)                                             { return nil, ErrNotSupported }
func (p *Provider) GetVM(id string) (*hosting.VM, error)                                       { return nil, ErrNotSupported }
func (p *Provider) CreateVM(req hosting.VMCreateRequest) (*hosting.VM, error)                  { return nil, ErrNotSupported }
func (p *Provider) ListDataCenters() ([]hosting.DataCenter, error)                             { return nil, ErrNotSupported }
func (p *Provider) ListSSHKeys() ([]hosting.SSHKey, error)                                     { return nil, ErrNotSupported }
func (p *Provider) AddSSHKey(name, publicKey string) (*hosting.SSHKey, error)                  { return nil, ErrNotSupported }
func (p *Provider) DeleteSSHKey(id string) error                                               { return ErrNotSupported }
func (p *Provider) ListSubscriptions() ([]hosting.Subscription, error)                         { return nil, ErrNotSupported }
func (p *Provider) GetCatalog() ([]hosting.CatalogItem, error)                                 { return nil, ErrNotSupported }

Example: Full Provider Structure

For a provider that implements all capabilities, organize the code across multiple files:

internal/hosting/fullprovider/
  provider.go    -- init(), Name(), DisplayName(), Configure(), TestConnection()
  client.go      -- HTTP client with auth, retry, rate-limit handling
  dns.go         -- ListDNSRecords, CreateDNSRecord, UpdateDNSRecords, DeleteDNSRecord, ResetDNSRecords
  domains.go     -- ListDomains, GetDomain, CheckDomainAvailability, PurchaseDomain, nameserver/lock/privacy methods
  vms.go         -- ListVMs, GetVM, CreateVM, ListDataCenters
  ssh.go         -- ListSSHKeys, AddSSHKey, DeleteSSHKey
  billing.go     -- ListSubscriptions, GetCatalog
  types.go       -- Provider-specific API request/response types

Each file focuses on a single capability area. The client.go file provides a shared doRequest() method (similar to the Hostinger client) that handles authentication headers, JSON marshaling, error parsing, and retry logic.

Key Patterns from the Hostinger Implementation

  1. Separate API types from generic types. Define provider-specific request/response structs (e.g., hostingerDNSRecord) and conversion functions (toGenericDNSRecord, toHostingerDNSRecord).

  2. Validate before mutating. The Hostinger DNS implementation calls a /validate endpoint before applying updates. If your provider offers similar validation, use it.

  3. Synthesize IDs when the API does not provide them. Hostinger does not return record IDs in zone listings, so the client synthesizes them from name/type/priority.

  4. Handle rate limits transparently. The client retries on HTTP 429 with exponential back-off, capping at 60 seconds per retry and 3 retries total. This keeps rate-limit handling invisible to the caller.