No One Can Stop Me Now

This commit is contained in:
DigiJ
2026-03-13 23:48:47 -07:00
parent 4d3570781e
commit 1a138a2bd0
428 changed files with 519668 additions and 259 deletions

View File

@@ -0,0 +1,107 @@
package hosting
import (
"encoding/json"
"fmt"
"os"
"path/filepath"
"strings"
)
// ProviderConfigStore manages saved provider configurations on disk.
// Each provider's config is stored as a separate JSON file with restrictive
// permissions (0600) since the files contain API keys.
type ProviderConfigStore struct {
configDir string
}
// NewConfigStore creates a new store rooted at configDir. The directory is
// created on first write if it does not already exist.
func NewConfigStore(configDir string) *ProviderConfigStore {
return &ProviderConfigStore{configDir: configDir}
}
// configPath returns the file path for a provider's config file.
func (s *ProviderConfigStore) configPath(providerName string) string {
return filepath.Join(s.configDir, providerName+".json")
}
// ensureDir creates the config directory if it does not exist.
func (s *ProviderConfigStore) ensureDir() error {
return os.MkdirAll(s.configDir, 0700)
}
// Save writes a provider configuration to disk. It overwrites any existing
// config for the same provider.
func (s *ProviderConfigStore) Save(providerName string, cfg ProviderConfig) error {
if providerName == "" {
return fmt.Errorf("hosting: provider name must not be empty")
}
if err := s.ensureDir(); err != nil {
return fmt.Errorf("hosting: create config dir: %w", err)
}
data, err := json.MarshalIndent(cfg, "", " ")
if err != nil {
return fmt.Errorf("hosting: marshal config for %s: %w", providerName, err)
}
path := s.configPath(providerName)
if err := os.WriteFile(path, data, 0600); err != nil {
return fmt.Errorf("hosting: write config for %s: %w", providerName, err)
}
return nil
}
// Load reads a provider configuration from disk.
func (s *ProviderConfigStore) Load(providerName string) (*ProviderConfig, error) {
path := s.configPath(providerName)
data, err := os.ReadFile(path)
if err != nil {
if os.IsNotExist(err) {
return nil, fmt.Errorf("hosting: no config found for provider %q", providerName)
}
return nil, fmt.Errorf("hosting: read config for %s: %w", providerName, err)
}
var cfg ProviderConfig
if err := json.Unmarshal(data, &cfg); err != nil {
return nil, fmt.Errorf("hosting: parse config for %s: %w", providerName, err)
}
return &cfg, nil
}
// Delete removes a provider's saved configuration.
func (s *ProviderConfigStore) Delete(providerName string) error {
path := s.configPath(providerName)
if err := os.Remove(path); err != nil {
if os.IsNotExist(err) {
return nil // already gone
}
return fmt.Errorf("hosting: delete config for %s: %w", providerName, err)
}
return nil
}
// ListConfigured returns the names of all providers that have saved configs.
func (s *ProviderConfigStore) ListConfigured() ([]string, error) {
entries, err := os.ReadDir(s.configDir)
if err != nil {
if os.IsNotExist(err) {
return nil, nil // no directory means no configs
}
return nil, fmt.Errorf("hosting: list configs: %w", err)
}
var names []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.HasSuffix(name, ".json") {
names = append(names, strings.TrimSuffix(name, ".json"))
}
}
return names, nil
}

View File

@@ -0,0 +1,127 @@
package hostinger
import (
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"setec-manager/internal/hosting"
)
// hostingerSubscription is the Hostinger API representation of a subscription.
type hostingerSubscription struct {
ID int `json:"id"`
Name string `json:"name"`
Status string `json:"status"`
RenewalDate string `json:"renewal_date"`
Price struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
} `json:"price"`
}
// hostingerCatalogItem is the Hostinger API representation of a catalog item.
type hostingerCatalogItem struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Price float64 `json:"price"`
Currency string `json:"currency"`
Features map[string]string `json:"features,omitempty"`
}
// hostingerPaymentMethod is the Hostinger API representation of a payment method.
type hostingerPaymentMethod struct {
ID int `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Last4 string `json:"last4"`
ExpMonth int `json:"exp_month"`
ExpYear int `json:"exp_year"`
Default bool `json:"default"`
}
// PaymentMethod is the exported type for payment method information.
type PaymentMethod struct {
ID string `json:"id"`
Type string `json:"type"`
Name string `json:"name"`
Last4 string `json:"last4"`
ExpMonth int `json:"exp_month"`
ExpYear int `json:"exp_year"`
Default bool `json:"default"`
}
// ListSubscriptions retrieves all billing subscriptions.
func (c *Client) ListSubscriptions() ([]hosting.Subscription, error) {
var subs []hostingerSubscription
if err := c.doRequest(http.MethodGet, "/api/billing/v1/subscriptions", nil, &subs); err != nil {
return nil, fmt.Errorf("list subscriptions: %w", err)
}
result := make([]hosting.Subscription, 0, len(subs))
for _, s := range subs {
renewsAt, _ := time.Parse(time.RFC3339, s.RenewalDate)
result = append(result, hosting.Subscription{
ID: strconv.Itoa(s.ID),
Name: s.Name,
Status: s.Status,
RenewsAt: renewsAt,
Price: s.Price.Amount,
Currency: s.Price.Currency,
})
}
return result, nil
}
// GetCatalog retrieves the product catalog, optionally filtered by category.
// If category is empty, all catalog items are returned.
func (c *Client) GetCatalog(category string) ([]hosting.CatalogItem, error) {
path := "/api/billing/v1/catalog"
if category != "" {
path += "?" + url.Values{"category": {category}}.Encode()
}
var items []hostingerCatalogItem
if err := c.doRequest(http.MethodGet, path, nil, &items); err != nil {
return nil, fmt.Errorf("get catalog: %w", err)
}
result := make([]hosting.CatalogItem, 0, len(items))
for _, item := range items {
result = append(result, hosting.CatalogItem{
ID: item.ID,
Name: item.Name,
Category: item.Category,
Price: item.Price,
Currency: item.Currency,
Features: item.Features,
})
}
return result, nil
}
// ListPaymentMethods retrieves all payment methods on the account.
// This is a Hostinger-specific method not part of the generic Provider interface.
func (c *Client) ListPaymentMethods() ([]PaymentMethod, error) {
var methods []hostingerPaymentMethod
if err := c.doRequest(http.MethodGet, "/api/billing/v1/payment-methods", nil, &methods); err != nil {
return nil, fmt.Errorf("list payment methods: %w", err)
}
result := make([]PaymentMethod, 0, len(methods))
for _, m := range methods {
result = append(result, PaymentMethod{
ID: strconv.Itoa(m.ID),
Type: m.Type,
Name: m.Name,
Last4: m.Last4,
ExpMonth: m.ExpMonth,
ExpYear: m.ExpYear,
Default: m.Default,
})
}
return result, nil
}

View File

@@ -0,0 +1,172 @@
package hostinger
import (
"bytes"
"encoding/json"
"fmt"
"io"
"net/http"
"strconv"
"time"
"setec-manager/internal/hosting"
)
const (
defaultBaseURL = "https://developers.hostinger.com"
maxRetries = 3
)
// APIError represents an error response from the Hostinger API.
type APIError struct {
StatusCode int `json:"-"`
Message string `json:"error"`
CorrelationID string `json:"correlation_id,omitempty"`
}
func (e *APIError) Error() string {
if e.CorrelationID != "" {
return fmt.Sprintf("hostinger API error %d: %s (correlation_id: %s)", e.StatusCode, e.Message, e.CorrelationID)
}
return fmt.Sprintf("hostinger API error %d: %s", e.StatusCode, e.Message)
}
// Client is the Hostinger API client. It implements hosting.Provider.
type Client struct {
apiToken string
httpClient *http.Client
baseURL string
}
// Compile-time check that Client implements hosting.Provider.
var _ hosting.Provider = (*Client)(nil)
// New creates a new Hostinger API client with the given bearer token.
func New(token string) *Client {
return &Client{
apiToken: token,
httpClient: &http.Client{
Timeout: 30 * time.Second,
},
baseURL: defaultBaseURL,
}
}
// Name returns the provider identifier.
func (c *Client) Name() string { return "hostinger" }
// DisplayName returns the human-readable provider name.
func (c *Client) DisplayName() string { return "Hostinger" }
// Configure applies the given configuration to the client.
func (c *Client) Configure(cfg hosting.ProviderConfig) error {
if cfg.APIKey == "" {
return fmt.Errorf("hostinger: API key is required")
}
c.apiToken = cfg.APIKey
if cfg.BaseURL != "" {
c.baseURL = cfg.BaseURL
}
return nil
}
// TestConnection verifies the API token by making a lightweight API call.
func (c *Client) TestConnection() error {
_, err := c.ListDomains()
return err
}
// doRequest executes an HTTP request against the Hostinger API.
// body may be nil for requests with no body. result may be nil if the
// response body should be discarded.
func (c *Client) doRequest(method, path string, body interface{}, result interface{}) error {
url := c.baseURL + path
var rawBody []byte
if body != nil {
var err error
rawBody, err = json.Marshal(body)
if err != nil {
return fmt.Errorf("marshal request body: %w", err)
}
}
var lastErr error
for attempt := 0; attempt <= maxRetries; attempt++ {
var bodyReader io.Reader
if rawBody != nil {
bodyReader = bytes.NewReader(rawBody)
}
req, err := http.NewRequest(method, url, bodyReader)
if err != nil {
return fmt.Errorf("create request: %w", err)
}
req.Header.Set("Authorization", "Bearer "+c.apiToken)
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Accept", "application/json")
resp, err := c.httpClient.Do(req)
if err != nil {
lastErr = fmt.Errorf("execute request: %w", err)
continue
}
respBody, err := io.ReadAll(resp.Body)
resp.Body.Close()
if err != nil {
return fmt.Errorf("read response body: %w", err)
}
// Handle rate limiting with retry.
if resp.StatusCode == http.StatusTooManyRequests {
if attempt < maxRetries {
retryAfter := parseRetryAfter(resp.Header.Get("Retry-After"))
time.Sleep(retryAfter)
lastErr = &APIError{StatusCode: 429, Message: "rate limited"}
continue
}
return &APIError{StatusCode: 429, Message: "rate limited after retries"}
}
// Handle error responses.
if resp.StatusCode >= 400 {
apiErr := &APIError{StatusCode: resp.StatusCode}
if jsonErr := json.Unmarshal(respBody, apiErr); jsonErr != nil {
apiErr.Message = string(respBody)
}
return apiErr
}
// Parse successful response.
if result != nil && len(respBody) > 0 {
if err := json.Unmarshal(respBody, result); err != nil {
return fmt.Errorf("unmarshal response: %w", err)
}
}
return nil
}
return lastErr
}
// parseRetryAfter parses the Retry-After header value.
// Returns a default of 1 second if the header is missing or unparseable.
func parseRetryAfter(value string) time.Duration {
if value == "" {
return time.Second
}
seconds, err := strconv.Atoi(value)
if err != nil {
return time.Second
}
if seconds <= 0 {
return time.Second
}
if seconds > 60 {
seconds = 60
}
return time.Duration(seconds) * time.Second
}

View File

@@ -0,0 +1,136 @@
package hostinger
import (
"fmt"
"net/http"
"net/url"
"setec-manager/internal/hosting"
)
// hostingerDNSRecord is the Hostinger API representation of a DNS record.
type hostingerDNSRecord struct {
Type string `json:"type"`
Name string `json:"name"`
Content string `json:"content"`
TTL int `json:"ttl"`
Priority *int `json:"priority,omitempty"`
}
// hostingerDNSUpdateRequest is the request body for updating DNS records.
type hostingerDNSUpdateRequest struct {
Records []hostingerDNSRecord `json:"records"`
Overwrite bool `json:"overwrite"`
}
// hostingerDNSValidateRequest is the request body for validating DNS records.
type hostingerDNSValidateRequest struct {
Records []hostingerDNSRecord `json:"records"`
}
// ListDNSRecords retrieves all DNS records for the given domain.
func (c *Client) ListDNSRecords(domain string) ([]hosting.DNSRecord, error) {
path := fmt.Sprintf("/api/dns/v1/zones/%s", url.PathEscape(domain))
var apiRecords []hostingerDNSRecord
if err := c.doRequest(http.MethodGet, path, nil, &apiRecords); err != nil {
return nil, fmt.Errorf("list DNS records for %s: %w", domain, err)
}
records := make([]hosting.DNSRecord, 0, len(apiRecords))
for _, r := range apiRecords {
records = append(records, toGenericDNSRecord(r))
}
return records, nil
}
// UpdateDNSRecords updates DNS records for the given domain.
// If overwrite is true, existing records are replaced entirely.
func (c *Client) UpdateDNSRecords(domain string, records []hosting.DNSRecord, overwrite bool) error {
path := fmt.Sprintf("/api/dns/v1/zones/%s", url.PathEscape(domain))
hostingerRecords := make([]hostingerDNSRecord, 0, len(records))
for _, r := range records {
hostingerRecords = append(hostingerRecords, toHostingerDNSRecord(r))
}
// Validate first.
validatePath := fmt.Sprintf("/api/dns/v1/zones/%s/validate", url.PathEscape(domain))
validateReq := hostingerDNSValidateRequest{Records: hostingerRecords}
if err := c.doRequest(http.MethodPost, validatePath, validateReq, nil); err != nil {
return fmt.Errorf("validate DNS records for %s: %w", domain, err)
}
req := hostingerDNSUpdateRequest{
Records: hostingerRecords,
Overwrite: overwrite,
}
if err := c.doRequest(http.MethodPut, path, req, nil); err != nil {
return fmt.Errorf("update DNS records for %s: %w", domain, err)
}
return nil
}
// CreateDNSRecord adds a single DNS record to the domain without overwriting.
func (c *Client) CreateDNSRecord(domain string, record hosting.DNSRecord) error {
return c.UpdateDNSRecords(domain, []hosting.DNSRecord{record}, false)
}
// DeleteDNSRecord removes DNS records matching the given filter.
func (c *Client) DeleteDNSRecord(domain string, filter hosting.DNSRecordFilter) error {
path := fmt.Sprintf("/api/dns/v1/zones/%s", url.PathEscape(domain))
params := url.Values{}
if filter.Name != "" {
params.Set("name", filter.Name)
}
if filter.Type != "" {
params.Set("type", filter.Type)
}
if len(params) > 0 {
path += "?" + params.Encode()
}
if err := c.doRequest(http.MethodDelete, path, nil, nil); err != nil {
return fmt.Errorf("delete DNS record %s/%s for %s: %w", filter.Name, filter.Type, domain, err)
}
return nil
}
// ResetDNSRecords resets the domain's DNS zone to default records.
func (c *Client) ResetDNSRecords(domain string) error {
path := fmt.Sprintf("/api/dns/v1/zones/%s/reset", url.PathEscape(domain))
if err := c.doRequest(http.MethodPost, path, nil, nil); err != nil {
return fmt.Errorf("reset DNS records for %s: %w", domain, err)
}
return nil
}
// toGenericDNSRecord converts a Hostinger DNS record to the generic type.
func toGenericDNSRecord(r hostingerDNSRecord) hosting.DNSRecord {
rec := hosting.DNSRecord{
Type: r.Type,
Name: r.Name,
Content: r.Content,
TTL: r.TTL,
}
if r.Priority != nil {
rec.Priority = *r.Priority
}
return rec
}
// toHostingerDNSRecord converts a generic DNS record to the Hostinger format.
func toHostingerDNSRecord(r hosting.DNSRecord) hostingerDNSRecord {
rec := hostingerDNSRecord{
Type: r.Type,
Name: r.Name,
Content: r.Content,
TTL: r.TTL,
}
if r.Priority != 0 {
p := r.Priority
rec.Priority = &p
}
return rec
}

View File

@@ -0,0 +1,218 @@
package hostinger
import (
"fmt"
"net/http"
"net/url"
"strings"
"time"
"setec-manager/internal/hosting"
)
// hostingerDomain is the Hostinger API representation of a domain.
type hostingerDomain struct {
Domain string `json:"domain"`
Status string `json:"status"`
ExpirationDate string `json:"expiration_date"`
AutoRenew bool `json:"auto_renew"`
DomainLock bool `json:"domain_lock"`
PrivacyProtection bool `json:"privacy_protection"`
Nameservers []string `json:"nameservers"`
}
// hostingerDomainList wraps the list response.
type hostingerDomainList struct {
Domains []hostingerDomain `json:"domains"`
}
// hostingerAvailabilityRequest is the check-availability request body.
type hostingerAvailabilityRequest struct {
Domains []string `json:"domains"`
}
// hostingerAvailabilityResult is a single domain availability result.
type hostingerAvailabilityResult struct {
Domain string `json:"domain"`
Available bool `json:"available"`
Price *struct {
Amount float64 `json:"amount"`
Currency string `json:"currency"`
} `json:"price,omitempty"`
}
// hostingerPurchaseRequest is the domain purchase request body.
type hostingerPurchaseRequest struct {
Domain string `json:"domain"`
Period int `json:"period"`
AutoRenew bool `json:"auto_renew"`
Privacy bool `json:"privacy_protection"`
PaymentMethodID string `json:"payment_method_id,omitempty"`
}
// hostingerNameserversRequest is the body for updating nameservers.
type hostingerNameserversRequest struct {
Nameservers []string `json:"nameservers"`
}
// ListDomains retrieves all domains in the account portfolio.
func (c *Client) ListDomains() ([]hosting.Domain, error) {
var list hostingerDomainList
if err := c.doRequest(http.MethodGet, "/api/domains/v1/portfolio", nil, &list); err != nil {
return nil, fmt.Errorf("list domains: %w", err)
}
domains := make([]hosting.Domain, 0, len(list.Domains))
for _, d := range list.Domains {
domains = append(domains, toSummaryDomain(d))
}
return domains, nil
}
// GetDomain retrieves details for a specific domain.
func (c *Client) GetDomain(domain string) (*hosting.DomainDetail, error) {
path := fmt.Sprintf("/api/domains/v1/portfolio/%s", url.PathEscape(domain))
var d hostingerDomain
if err := c.doRequest(http.MethodGet, path, nil, &d); err != nil {
return nil, fmt.Errorf("get domain %s: %w", domain, err)
}
result := toDetailDomain(d)
return &result, nil
}
// CheckDomainAvailability checks whether the given domain is available for
// registration across the specified TLDs. If tlds is empty, the domain string
// is checked as-is.
func (c *Client) CheckDomainAvailability(domain string, tlds []string) ([]hosting.DomainAvailability, error) {
// Build the list of fully qualified domain names to check.
var domains []string
if len(tlds) == 0 {
domains = []string{domain}
} else {
for _, tld := range tlds {
tld = strings.TrimPrefix(tld, ".")
domains = append(domains, domain+"."+tld)
}
}
req := hostingerAvailabilityRequest{Domains: domains}
var results []hostingerAvailabilityResult
if err := c.doRequest(http.MethodPost, "/api/domains/v1/availability", req, &results); err != nil {
return nil, fmt.Errorf("check domain availability: %w", err)
}
avail := make([]hosting.DomainAvailability, 0, len(results))
for _, r := range results {
da := hosting.DomainAvailability{
Domain: r.Domain,
Available: r.Available,
}
// Extract TLD from the domain name.
if idx := strings.Index(r.Domain, "."); idx >= 0 {
da.TLD = r.Domain[idx+1:]
}
if r.Price != nil {
da.Price = r.Price.Amount
da.Currency = r.Price.Currency
}
avail = append(avail, da)
}
return avail, nil
}
// PurchaseDomain registers a new domain.
func (c *Client) PurchaseDomain(req hosting.DomainPurchaseRequest) (*hosting.OrderResult, error) {
body := hostingerPurchaseRequest{
Domain: req.Domain,
Period: req.Years,
AutoRenew: req.AutoRenew,
Privacy: req.Privacy,
PaymentMethodID: req.PaymentMethod,
}
var d hostingerDomain
if err := c.doRequest(http.MethodPost, "/api/domains/v1/portfolio", body, &d); err != nil {
return nil, fmt.Errorf("purchase domain %s: %w", req.Domain, err)
}
return &hosting.OrderResult{
OrderID: d.Domain,
Status: "completed",
Message: fmt.Sprintf("domain %s registered", d.Domain),
}, nil
}
// SetNameservers updates the nameservers for a domain.
func (c *Client) SetNameservers(domain string, nameservers []string) error {
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/nameservers", url.PathEscape(domain))
body := hostingerNameserversRequest{Nameservers: nameservers}
if err := c.doRequest(http.MethodPut, path, body, nil); err != nil {
return fmt.Errorf("set nameservers for %s: %w", domain, err)
}
return nil
}
// EnableDomainLock enables the registrar lock for a domain.
func (c *Client) EnableDomainLock(domain string) error {
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/domain-lock", url.PathEscape(domain))
if err := c.doRequest(http.MethodPut, path, nil, nil); err != nil {
return fmt.Errorf("enable domain lock for %s: %w", domain, err)
}
return nil
}
// DisableDomainLock disables the registrar lock for a domain.
func (c *Client) DisableDomainLock(domain string) error {
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/domain-lock", url.PathEscape(domain))
if err := c.doRequest(http.MethodDelete, path, nil, nil); err != nil {
return fmt.Errorf("disable domain lock for %s: %w", domain, err)
}
return nil
}
// EnablePrivacyProtection enables WHOIS privacy protection for a domain.
func (c *Client) EnablePrivacyProtection(domain string) error {
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/privacy-protection", url.PathEscape(domain))
if err := c.doRequest(http.MethodPut, path, nil, nil); err != nil {
return fmt.Errorf("enable privacy protection for %s: %w", domain, err)
}
return nil
}
// DisablePrivacyProtection disables WHOIS privacy protection for a domain.
func (c *Client) DisablePrivacyProtection(domain string) error {
path := fmt.Sprintf("/api/domains/v1/portfolio/%s/privacy-protection", url.PathEscape(domain))
if err := c.doRequest(http.MethodDelete, path, nil, nil); err != nil {
return fmt.Errorf("disable privacy protection for %s: %w", domain, err)
}
return nil
}
// toSummaryDomain converts a Hostinger domain to the summary Domain type.
func toSummaryDomain(d hostingerDomain) hosting.Domain {
expires, _ := time.Parse(time.RFC3339, d.ExpirationDate)
return hosting.Domain{
Name: d.Domain,
Status: d.Status,
ExpiresAt: expires,
}
}
// toDetailDomain converts a Hostinger domain to the full DomainDetail type.
func toDetailDomain(d hostingerDomain) hosting.DomainDetail {
expires, _ := time.Parse(time.RFC3339, d.ExpirationDate)
return hosting.DomainDetail{
Name: d.Domain,
Status: d.Status,
Registrar: "hostinger",
ExpiresAt: expires,
AutoRenew: d.AutoRenew,
Locked: d.DomainLock,
PrivacyProtection: d.PrivacyProtection,
Nameservers: d.Nameservers,
}
}

View File

@@ -0,0 +1,7 @@
package hostinger
import "setec-manager/internal/hosting"
func init() {
hosting.Register(New(""))
}

View File

@@ -0,0 +1,219 @@
package hostinger
import (
"fmt"
"net/http"
"net/url"
"strconv"
"time"
"setec-manager/internal/hosting"
)
// hostingerVM is the Hostinger API representation of a virtual machine.
type hostingerVM struct {
ID int `json:"id"`
Hostname string `json:"hostname"`
Status string `json:"status"`
Plan string `json:"plan"`
DataCenter string `json:"data_center"`
IPv4 string `json:"ipv4"`
IPv6 string `json:"ipv6"`
OS string `json:"os"`
CPUs int `json:"cpus"`
RAMMB int `json:"ram_mb"`
DiskGB int `json:"disk_gb"`
CreatedAt string `json:"created_at"`
}
// hostingerDataCenter is the Hostinger API representation of a data center.
type hostingerDataCenter struct {
ID int `json:"id"`
Name string `json:"name"`
Location string `json:"location"`
Country string `json:"country"`
}
// hostingerSSHKey is the Hostinger API representation of an SSH key.
type hostingerSSHKey struct {
ID int `json:"id"`
Name string `json:"name"`
PublicKey string `json:"public_key"`
CreatedAt string `json:"created_at"`
}
// hostingerCreateVMRequest is the request body for creating a VM.
type hostingerCreateVMRequest struct {
Hostname string `json:"hostname"`
Plan string `json:"plan"`
DataCenterID int `json:"data_center_id"`
OS string `json:"template"`
Password string `json:"password,omitempty"`
SSHKeyID *int `json:"ssh_key_id,omitempty"`
}
// hostingerCreateVMResponse is the response from the VM creation endpoint.
type hostingerCreateVMResponse struct {
OrderID string `json:"order_id"`
Status string `json:"status"`
Message string `json:"message"`
}
// hostingerAddSSHKeyRequest is the request body for adding an SSH key.
type hostingerAddSSHKeyRequest struct {
Name string `json:"name"`
PublicKey string `json:"public_key"`
}
// ListVMs retrieves all virtual machines in the account.
func (c *Client) ListVMs() ([]hosting.VirtualMachine, error) {
var vms []hostingerVM
if err := c.doRequest(http.MethodGet, "/api/vps/v1/virtual-machines", nil, &vms); err != nil {
return nil, fmt.Errorf("list VMs: %w", err)
}
result := make([]hosting.VirtualMachine, 0, len(vms))
for _, vm := range vms {
result = append(result, toGenericVM(vm))
}
return result, nil
}
// GetVM retrieves a specific virtual machine by ID.
func (c *Client) GetVM(id string) (*hosting.VirtualMachine, error) {
path := fmt.Sprintf("/api/vps/v1/virtual-machines/%s", url.PathEscape(id))
var vm hostingerVM
if err := c.doRequest(http.MethodGet, path, nil, &vm); err != nil {
return nil, fmt.Errorf("get VM %s: %w", id, err)
}
result := toGenericVM(vm)
return &result, nil
}
// CreateVM provisions a new virtual machine.
func (c *Client) CreateVM(req hosting.VMCreateRequest) (*hosting.OrderResult, error) {
body := hostingerCreateVMRequest{
Hostname: req.Hostname,
Plan: req.Plan,
OS: req.OS,
Password: req.Password,
}
// Parse data center ID from string to int for the Hostinger API.
dcID, err := strconv.Atoi(req.DataCenterID)
if err != nil {
return nil, fmt.Errorf("invalid data center ID %q: must be numeric", req.DataCenterID)
}
body.DataCenterID = dcID
// Parse SSH key ID if provided.
if req.SSHKeyID != "" {
keyID, err := strconv.Atoi(req.SSHKeyID)
if err != nil {
return nil, fmt.Errorf("invalid SSH key ID %q: must be numeric", req.SSHKeyID)
}
body.SSHKeyID = &keyID
}
var resp hostingerCreateVMResponse
if err := c.doRequest(http.MethodPost, "/api/vps/v1/virtual-machines", body, &resp); err != nil {
return nil, fmt.Errorf("create VM: %w", err)
}
return &hosting.OrderResult{
OrderID: resp.OrderID,
Status: resp.Status,
Message: resp.Message,
}, nil
}
// ListDataCenters retrieves all available data centers.
func (c *Client) ListDataCenters() ([]hosting.DataCenter, error) {
var dcs []hostingerDataCenter
if err := c.doRequest(http.MethodGet, "/api/vps/v1/data-centers", nil, &dcs); err != nil {
return nil, fmt.Errorf("list data centers: %w", err)
}
result := make([]hosting.DataCenter, 0, len(dcs))
for _, dc := range dcs {
result = append(result, hosting.DataCenter{
ID: strconv.Itoa(dc.ID),
Name: dc.Name,
Location: dc.Location,
Country: dc.Country,
})
}
return result, nil
}
// ListSSHKeys retrieves all SSH keys in the account.
func (c *Client) ListSSHKeys() ([]hosting.SSHKey, error) {
var keys []hostingerSSHKey
if err := c.doRequest(http.MethodGet, "/api/vps/v1/public-keys", nil, &keys); err != nil {
return nil, fmt.Errorf("list SSH keys: %w", err)
}
result := make([]hosting.SSHKey, 0, len(keys))
for _, k := range keys {
created, _ := time.Parse(time.RFC3339, k.CreatedAt)
result = append(result, hosting.SSHKey{
ID: strconv.Itoa(k.ID),
Name: k.Name,
PublicKey: k.PublicKey,
CreatedAt: created,
})
}
return result, nil
}
// AddSSHKey uploads a new SSH public key.
func (c *Client) AddSSHKey(name, publicKey string) (*hosting.SSHKey, error) {
body := hostingerAddSSHKeyRequest{
Name: name,
PublicKey: publicKey,
}
var key hostingerSSHKey
if err := c.doRequest(http.MethodPost, "/api/vps/v1/public-keys", body, &key); err != nil {
return nil, fmt.Errorf("add SSH key: %w", err)
}
created, _ := time.Parse(time.RFC3339, key.CreatedAt)
return &hosting.SSHKey{
ID: strconv.Itoa(key.ID),
Name: key.Name,
PublicKey: key.PublicKey,
CreatedAt: created,
}, nil
}
// DeleteSSHKey removes an SSH key by ID.
func (c *Client) DeleteSSHKey(id string) error {
path := fmt.Sprintf("/api/vps/v1/public-keys/%s", url.PathEscape(id))
if err := c.doRequest(http.MethodDelete, path, nil, nil); err != nil {
return fmt.Errorf("delete SSH key %s: %w", id, err)
}
return nil
}
// toGenericVM converts a Hostinger VM to the generic VirtualMachine type.
func toGenericVM(vm hostingerVM) hosting.VirtualMachine {
created, _ := time.Parse(time.RFC3339, vm.CreatedAt)
return hosting.VirtualMachine{
ID: strconv.Itoa(vm.ID),
Hostname: vm.Hostname,
IPAddress: vm.IPv4,
IPv6: vm.IPv6,
Status: vm.Status,
Plan: vm.Plan,
DataCenter: vm.DataCenter,
OS: vm.OS,
CPUs: vm.CPUs,
RAMBytes: int64(vm.RAMMB) * 1024 * 1024,
DiskBytes: int64(vm.DiskGB) * 1024 * 1024 * 1024,
CreatedAt: created,
}
}

View File

@@ -0,0 +1,287 @@
package hosting
import (
"errors"
"sort"
"sync"
"time"
)
// ErrNotSupported is returned when a provider does not support a given operation.
var ErrNotSupported = errors.New("operation not supported by this provider")
// Provider is the interface all hosting service integrations must implement.
// Not all providers support all features -- methods should return ErrNotSupported
// for unsupported operations.
type Provider interface {
// Name returns the provider identifier (e.g. "hostinger", "digitalocean").
Name() string
// DisplayName returns a human-readable provider name.
DisplayName() string
// --- Authentication ---
// Configure applies the given configuration to the provider.
Configure(cfg ProviderConfig) error
// TestConnection verifies that the provider credentials are valid.
TestConnection() error
// --- DNS Management ---
// ListDNSRecords returns all DNS records for a domain.
ListDNSRecords(domain string) ([]DNSRecord, error)
// CreateDNSRecord adds a single DNS record to a domain.
CreateDNSRecord(domain string, record DNSRecord) error
// UpdateDNSRecords replaces DNS records for a domain. If overwrite is true,
// all existing records are removed first.
UpdateDNSRecords(domain string, records []DNSRecord, overwrite bool) error
// DeleteDNSRecord removes DNS records matching the filter.
DeleteDNSRecord(domain string, filter DNSRecordFilter) error
// ResetDNSRecords restores the default DNS records for a domain.
ResetDNSRecords(domain string) error
// --- Domain Management ---
// ListDomains returns all domains on the account.
ListDomains() ([]Domain, error)
// GetDomain returns detailed information about a single domain.
GetDomain(domain string) (*DomainDetail, error)
// CheckDomainAvailability checks registration availability across TLDs.
CheckDomainAvailability(domain string, tlds []string) ([]DomainAvailability, error)
// PurchaseDomain registers a new domain.
PurchaseDomain(req DomainPurchaseRequest) (*OrderResult, error)
// SetNameservers configures the nameservers for a domain.
SetNameservers(domain string, nameservers []string) error
// EnableDomainLock enables the registrar lock on a domain.
EnableDomainLock(domain string) error
// DisableDomainLock disables the registrar lock on a domain.
DisableDomainLock(domain string) error
// EnablePrivacyProtection enables WHOIS privacy for a domain.
EnablePrivacyProtection(domain string) error
// DisablePrivacyProtection disables WHOIS privacy for a domain.
DisablePrivacyProtection(domain string) error
// --- VPS Management ---
// ListVMs returns all virtual machines on the account.
ListVMs() ([]VirtualMachine, error)
// GetVM returns details for a single virtual machine.
GetVM(id string) (*VirtualMachine, error)
// CreateVM provisions a new virtual machine.
CreateVM(req VMCreateRequest) (*OrderResult, error)
// ListDataCenters returns available data center locations.
ListDataCenters() ([]DataCenter, error)
// --- SSH Keys ---
// ListSSHKeys returns all SSH keys on the account.
ListSSHKeys() ([]SSHKey, error)
// AddSSHKey uploads a new SSH public key.
AddSSHKey(name, publicKey string) (*SSHKey, error)
// DeleteSSHKey removes an SSH key by ID.
DeleteSSHKey(id string) error
// --- Billing ---
// ListSubscriptions returns all active subscriptions.
ListSubscriptions() ([]Subscription, error)
// GetCatalog returns available products in a category.
GetCatalog(category string) ([]CatalogItem, error)
}
// ---------------------------------------------------------------------------
// Model types
// ---------------------------------------------------------------------------
// ProviderConfig holds the credentials and settings needed to connect to a
// hosting provider.
type ProviderConfig struct {
APIKey string `json:"api_key"`
APISecret string `json:"api_secret,omitempty"`
BaseURL string `json:"base_url,omitempty"`
Extra map[string]string `json:"extra,omitempty"`
}
// DNSRecord represents a single DNS record.
type DNSRecord struct {
Name string `json:"name"`
Type string `json:"type"` // A, AAAA, CNAME, MX, TXT, etc.
Content string `json:"content"`
TTL int `json:"ttl,omitempty"` // seconds; 0 means provider default
Priority int `json:"priority,omitempty"` // used by MX, SRV
}
// DNSRecordFilter identifies DNS records to match for deletion or lookup.
type DNSRecordFilter struct {
Name string `json:"name,omitempty"`
Type string `json:"type,omitempty"`
}
// Domain is a summary of a domain on the account.
type Domain struct {
Name string `json:"name"`
Status string `json:"status"`
ExpiresAt time.Time `json:"expires_at"`
}
// DomainDetail contains full information about a domain registration.
type DomainDetail struct {
Name string `json:"name"`
Status string `json:"status"`
Registrar string `json:"registrar"`
RegisteredAt time.Time `json:"registered_at"`
ExpiresAt time.Time `json:"expires_at"`
AutoRenew bool `json:"auto_renew"`
Locked bool `json:"locked"`
PrivacyProtection bool `json:"privacy_protection"`
Nameservers []string `json:"nameservers"`
}
// DomainAvailability reports whether a domain + TLD combination can be
// registered and its price.
type DomainAvailability struct {
Domain string `json:"domain"`
TLD string `json:"tld"`
Available bool `json:"available"`
Price float64 `json:"price"` // in the provider's default currency
Currency string `json:"currency"`
}
// DomainPurchaseRequest contains everything needed to register a domain.
type DomainPurchaseRequest struct {
Domain string `json:"domain"`
Years int `json:"years"`
AutoRenew bool `json:"auto_renew"`
Privacy bool `json:"privacy"`
PaymentMethod string `json:"payment_method,omitempty"`
}
// OrderResult is returned after a purchase or provisioning request.
type OrderResult struct {
OrderID string `json:"order_id"`
Status string `json:"status"` // e.g. "pending", "completed", "failed"
Message string `json:"message,omitempty"`
}
// VirtualMachine represents a VPS instance.
type VirtualMachine struct {
ID string `json:"id"`
Hostname string `json:"hostname"`
IPAddress string `json:"ip_address"`
IPv6 string `json:"ipv6,omitempty"`
Status string `json:"status"` // running, stopped, provisioning, etc.
Plan string `json:"plan"`
DataCenter string `json:"data_center"`
OS string `json:"os"`
CPUs int `json:"cpus"`
RAMBytes int64 `json:"ram_bytes"`
DiskBytes int64 `json:"disk_bytes"`
CreatedAt time.Time `json:"created_at"`
}
// VMCreateRequest contains everything needed to provision a new VPS.
type VMCreateRequest struct {
Hostname string `json:"hostname"`
Plan string `json:"plan"`
DataCenterID string `json:"data_center_id"`
OS string `json:"os"`
SSHKeyID string `json:"ssh_key_id,omitempty"`
Password string `json:"password,omitempty"`
}
// DataCenter represents a physical hosting location.
type DataCenter struct {
ID string `json:"id"`
Name string `json:"name"`
Location string `json:"location"` // city or region
Country string `json:"country"`
}
// SSHKey is a stored SSH public key.
type SSHKey struct {
ID string `json:"id"`
Name string `json:"name"`
PublicKey string `json:"public_key"`
CreatedAt time.Time `json:"created_at"`
}
// Subscription represents a billing subscription.
type Subscription struct {
ID string `json:"id"`
Name string `json:"name"`
Status string `json:"status"` // active, cancelled, expired
RenewsAt time.Time `json:"renews_at"`
Price float64 `json:"price"`
Currency string `json:"currency"`
}
// CatalogItem is a purchasable product or plan.
type CatalogItem struct {
ID string `json:"id"`
Name string `json:"name"`
Category string `json:"category"`
Price float64 `json:"price"`
Currency string `json:"currency"`
Features map[string]string `json:"features,omitempty"`
}
// ---------------------------------------------------------------------------
// Provider registry
// ---------------------------------------------------------------------------
var (
mu sync.RWMutex
providers = map[string]Provider{}
)
// Register adds a provider to the global registry. It panics if a provider
// with the same name is already registered.
func Register(p Provider) {
mu.Lock()
defer mu.Unlock()
name := p.Name()
if _, exists := providers[name]; exists {
panic("hosting: provider already registered: " + name)
}
providers[name] = p
}
// Get returns a registered provider by name.
func Get(name string) (Provider, bool) {
mu.RLock()
defer mu.RUnlock()
p, ok := providers[name]
return p, ok
}
// List returns the names of all registered providers, sorted alphabetically.
func List() []string {
mu.RLock()
defer mu.RUnlock()
names := make([]string, 0, len(providers))
for name := range providers {
names = append(names, name)
}
sort.Strings(names)
return names
}