package services import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "time" ) const hostingerBaseURL = "https://developers.hostinger.com/api" // HostingerClient es el cliente HTTP para la API de Hostinger. type HostingerClient struct { token string httpClient *http.Client } // NewHostingerClient crea un cliente con el token Bearer. func NewHostingerClient(token string) *HostingerClient { return &HostingerClient{ token: token, httpClient: &http.Client{ Timeout: 10 * time.Second, }, } } // getRaw realiza una petición GET autenticada y devuelve el body crudo. // El contexto permite cancelar la llamada si el caller abandona la petición. func (c *HostingerClient) getRaw(ctx context.Context, path string) ([]byte, error) { req, err := http.NewRequestWithContext(ctx, http.MethodGet, hostingerBaseURL+path, nil) if err != nil { return nil, fmt.Errorf("hostinger: crear request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("hostinger: ejecutar request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("hostinger: leer body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("hostinger: status %d – %s", resp.StatusCode, string(body)) } return body, nil } // writeRaw envía una petición HTTP autenticada con método y body opcionales. func (c *HostingerClient) writeRaw(ctx context.Context, method, path string, payload interface{}) ([]byte, error) { var reqBody io.Reader if payload != nil { b, err := json.Marshal(payload) if err != nil { return nil, fmt.Errorf("hostinger: serializar body: %w", err) } reqBody = bytes.NewReader(b) } req, err := http.NewRequestWithContext(ctx, method, hostingerBaseURL+path, reqBody) if err != nil { return nil, fmt.Errorf("hostinger: crear request: %w", err) } req.Header.Set("Authorization", "Bearer "+c.token) req.Header.Set("Content-Type", "application/json") resp, err := c.httpClient.Do(req) if err != nil { return nil, fmt.Errorf("hostinger: ejecutar request: %w", err) } defer resp.Body.Close() body, err := io.ReadAll(resp.Body) if err != nil { return nil, fmt.Errorf("hostinger: leer body: %w", err) } if resp.StatusCode < 200 || resp.StatusCode >= 300 { return nil, fmt.Errorf("hostinger: status %d – %s", resp.StatusCode, string(body)) } return body, nil } // get realiza una petición GET autenticada y decodifica el cuerpo en dest. func (c *HostingerClient) get(path string, dest interface{}) error { body, err := c.getRaw(context.Background(), path) if err != nil { return err } if dest != nil { if err := json.Unmarshal(body, dest); err != nil { return fmt.Errorf("hostinger: decodificar respuesta: %w", err) } } return nil } // ─── Tipos de respuesta ────────────────────────────────────────────────────── // HostingerIPAddress representa una dirección IP de un VPS (IPv4 o IPv6). type HostingerIPAddress struct { ID int `json:"id"` Address string `json:"address"` PTR string `json:"ptr"` } // HostingerVPS representa una máquina virtual VPS. type HostingerVPS struct { ID int `json:"id"` Hostname string `json:"hostname"` State string `json:"state"` Plan string `json:"plan"` DataCenter string `json:"data_center"` CPU int `json:"cpus"` RAMBytes int64 `json:"memory"` DiskBytes int64 `json:"disk"` IPV4 []HostingerIPAddress `json:"ipv4"` IPV6 []HostingerIPAddress `json:"ipv6"` } // HostingerDomain representa un dominio en el portafolio. type HostingerDomain struct { Domain string `json:"domain"` Status string `json:"status"` ExpiresAt string `json:"expires_at"` RegisteredAt string `json:"registered_at"` } // HostingerDNSRecord representa un registro DNS aplanado para la vista. type HostingerDNSRecord struct { Type string `json:"type"` Name string `json:"name"` Content string `json:"content"` TTL int `json:"ttl"` } // HostingerNameServers contiene los nameservers de un dominio. type HostingerNameServers struct { NS1 string `json:"ns1"` NS2 string `json:"ns2"` NS3 string `json:"ns3"` NS4 string `json:"ns4"` } // HostingerDNSZoneInput es un grupo de registros para PUT /dns/v1/zones/{domain}. type HostingerDNSZoneInput struct { Name string `json:"name"` Type string `json:"type"` TTL int `json:"ttl,omitempty"` Records []HostingerDNSZoneValue `json:"records"` } // HostingerDNSZoneValue es el valor de contenido de un registro DNS. type HostingerDNSZoneValue struct { Content string `json:"content"` } // HostingerSubscription representa una suscripción de facturación. type HostingerSubscription struct { ID string `json:"id"` Name string `json:"name"` Status string `json:"status"` BillingPeriod int `json:"billing_period"` BillingPeriodUnit string `json:"billing_period_unit"` CurrencyCode string `json:"currency_code"` TotalPrice int `json:"total_price"` RenewalPrice int `json:"renewal_price"` IsAutoRenewed bool `json:"is_auto_renewed"` CreatedAt string `json:"created_at"` ExpiresAt string `json:"expires_at"` NextBillingAt string `json:"next_billing_at"` } // HostingerHosting representa una cuenta de hosting. type HostingerHosting struct { Domain string `json:"domain"` VhostType string `json:"vhost_type"` IsEnabled bool `json:"is_enabled"` Username string `json:"username"` ClientID int `json:"client_id"` OrderID int `json:"order_id"` CreatedAt string `json:"created_at"` RootDirectory string `json:"root_directory"` ParentDomain string `json:"parent_domain"` } // ─── Métodos de la API ─────────────────────────────────────────────────────── // GetVPSList obtiene la lista de VPS/VMs. func (c *HostingerClient) GetVPSList() ([]HostingerVPS, error) { body, err := c.getRaw(context.Background(), "/vps/v1/virtual-machines") if err != nil { return nil, err } var wrapped struct { Data []HostingerVPS `json:"data"` } if err := json.Unmarshal(body, &wrapped); err == nil && wrapped.Data != nil { return wrapped.Data, nil } var arr []HostingerVPS if err := json.Unmarshal(body, &arr); err != nil { return nil, fmt.Errorf("hostinger: no se pudo interpretar respuesta VPS: %w", err) } return arr, nil } // GetDomains obtiene el portafolio de dominios. func (c *HostingerClient) GetDomains() ([]HostingerDomain, error) { body, err := c.getRaw(context.Background(), "/domains/v1/portfolio") if err != nil { return nil, err } var wrapped struct { Data []HostingerDomain `json:"data"` } if err := json.Unmarshal(body, &wrapped); err == nil && wrapped.Data != nil { return wrapped.Data, nil } var arr []HostingerDomain if err := json.Unmarshal(body, &arr); err != nil { return nil, fmt.Errorf("hostinger: no se pudo interpretar respuesta dominios: %w", err) } return arr, nil } // hostingerDNSContent es el valor individual de un registro DNS. type hostingerDNSContent struct { Content string `json:"content"` IsDisabled bool `json:"is_disabled"` } // hostingerDNSEntry es un grupo de registros del mismo tipo/nombre desde la API. type hostingerDNSEntry struct { Name string `json:"name"` TTL int `json:"ttl"` Type string `json:"type"` Records []hostingerDNSContent `json:"records"` } // GetDNSRecords obtiene los registros DNS de un dominio y los aplana para la vista. // Endpoint: GET /dns/v1/zones/{domain} — array de {name, ttl, type, records:[{content}]} func (c *HostingerClient) GetDNSRecords(domain string) ([]HostingerDNSRecord, error) { path := fmt.Sprintf("/dns/v1/zones/%s", domain) body, err := c.getRaw(context.Background(), path) if err != nil { return nil, err } var entries []hostingerDNSEntry if err := json.Unmarshal(body, &entries); err != nil { return nil, fmt.Errorf("hostinger: no se pudo interpretar respuesta DNS: %w", err) } var result []HostingerDNSRecord for _, e := range entries { for _, r := range e.Records { result = append(result, HostingerDNSRecord{ Type: e.Type, Name: e.Name, Content: r.Content, TTL: e.TTL, }) } } if result == nil { result = []HostingerDNSRecord{} } return result, nil } // GetDomainNameServers obtiene los nameservers del dominio desde el portafolio. // Endpoint: GET /domains/v1/portfolio/{domain} func (c *HostingerClient) GetDomainNameServers(domain string) (*HostingerNameServers, error) { path := fmt.Sprintf("/domains/v1/portfolio/%s", domain) body, err := c.getRaw(context.Background(), path) if err != nil { return nil, err } var resp struct { NameServers HostingerNameServers `json:"name_servers"` } if err := json.Unmarshal(body, &resp); err != nil { return nil, fmt.Errorf("hostinger: no se pudo interpretar nameservers: %w", err) } return &resp.NameServers, nil } // GetOrders obtiene las suscripciones de facturación. func (c *HostingerClient) GetOrders() ([]HostingerSubscription, error) { body, err := c.getRaw(context.Background(), "/billing/v1/subscriptions") if err != nil { return nil, err } var wrapped struct { Data []HostingerSubscription `json:"data"` } if err := json.Unmarshal(body, &wrapped); err == nil && wrapped.Data != nil { return wrapped.Data, nil } var arr []HostingerSubscription if err := json.Unmarshal(body, &arr); err != nil { return nil, fmt.Errorf("hostinger: no se pudo interpretar respuesta subscriptions: %w", err) } return arr, nil } // ─── DNS: escritura ───────────────────────────────────────────────────────── // UpdateDNSZone agrega/actualiza registros en la zona DNS. // overwrite=true reemplaza registros del mismo tipo/nombre; false los agrega. func (c *HostingerClient) UpdateDNSZone(domain string, overwrite bool, zone []HostingerDNSZoneInput) error { path := fmt.Sprintf("/dns/v1/zones/%s", domain) _, err := c.writeRaw(context.Background(), http.MethodPut, path, map[string]interface{}{"overwrite": overwrite, "zone": zone}) return err } // ResetDNS restaura la zona DNS de un dominio a sus valores por defecto. func (c *HostingerClient) ResetDNS(domain string) error { path := fmt.Sprintf("/dns/v1/zones/%s/reset", domain) _, err := c.writeRaw(context.Background(), http.MethodPost, path, map[string]bool{"sync": true}) return err } // ─── Domains: escritura ────────────────────────────────────────────────────── // UpdateNameservers actualiza los nameservers de un dominio. func (c *HostingerClient) UpdateNameservers(domain, ns1, ns2, ns3, ns4 string) error { path := fmt.Sprintf("/domains/v1/portfolio/%s/nameservers", domain) _, err := c.writeRaw(context.Background(), http.MethodPut, path, map[string]string{"ns1": ns1, "ns2": ns2, "ns3": ns3, "ns4": ns4}) return err } // SetDomainLock activa (PUT) o desactiva (DELETE) el bloqueo del dominio. func (c *HostingerClient) SetDomainLock(domain string, enable bool) error { path := fmt.Sprintf("/domains/v1/portfolio/%s/domain-lock", domain) method := http.MethodPut if !enable { method = http.MethodDelete } _, err := c.writeRaw(context.Background(), method, path, nil) return err } // SetPrivacyProtection activa (PUT) o desactiva (DELETE) la protección WHOIS. func (c *HostingerClient) SetPrivacyProtection(domain string, enable bool) error { path := fmt.Sprintf("/domains/v1/portfolio/%s/privacy-protection", domain) method := http.MethodPut if !enable { method = http.MethodDelete } _, err := c.writeRaw(context.Background(), method, path, nil) return err } // ─── VPS: acciones ─────────────────────────────────────────────────────────── // VPSAction ejecuta una acción en una VM: start | stop | restart. func (c *HostingerClient) VPSAction(vmID int, action string) error { path := fmt.Sprintf("/vps/v1/virtual-machines/%d/%s", vmID, action) _, err := c.writeRaw(context.Background(), http.MethodPost, path, nil) return err } // SetVPSRootPassword cambia la contraseña root de una VM. func (c *HostingerClient) SetVPSRootPassword(vmID int, password string) error { path := fmt.Sprintf("/vps/v1/virtual-machines/%d/root-password", vmID) _, err := c.writeRaw(context.Background(), http.MethodPut, path, map[string]string{"password": password}) return err } // SetVPSHostname cambia el hostname de una VM. func (c *HostingerClient) SetVPSHostname(vmID int, hostname string) error { path := fmt.Sprintf("/vps/v1/virtual-machines/%d/hostname", vmID) _, err := c.writeRaw(context.Background(), http.MethodPut, path, map[string]string{"hostname": hostname}) return err } // GetVPSMetrics obtiene las métricas de uso de una VM. // dateFrom y dateTo deben estar en formato RFC3339, p. ej. "2025-01-01T00:00:00Z". func (c *HostingerClient) GetVPSMetrics(vmID int, dateFrom, dateTo string) (json.RawMessage, error) { path := fmt.Sprintf("/vps/v1/virtual-machines/%d/metrics?date_from=%s&date_to=%s", vmID, dateFrom, dateTo) body, err := c.getRaw(context.Background(), path) if err != nil { return nil, err } return json.RawMessage(body), nil } // GetVPSBackups obtiene los backups de una VM. func (c *HostingerClient) GetVPSBackups(vmID int) (json.RawMessage, error) { path := fmt.Sprintf("/vps/v1/virtual-machines/%d/backups", vmID) body, err := c.getRaw(context.Background(), path) if err != nil { return nil, err } return json.RawMessage(body), nil } // ─── Billing: acciones ─────────────────────────────────────────────────────── // ToggleAutoRenewal activa (true) o desactiva la autorenovación de una suscripción. func (c *HostingerClient) ToggleAutoRenewal(subscriptionID string, enable bool) (*HostingerSubscription, error) { var path, method string if enable { path = fmt.Sprintf("/billing/v1/subscriptions/%s/auto-renewal/enable", subscriptionID) method = http.MethodPatch } else { path = fmt.Sprintf("/billing/v1/subscriptions/%s/auto-renewal/disable", subscriptionID) method = http.MethodDelete } body, err := c.writeRaw(context.Background(), method, path, nil) if err != nil { return nil, err } var sub HostingerSubscription if err := json.Unmarshal(body, &sub); err != nil { return nil, fmt.Errorf("hostinger: no se pudo interpretar respuesta: %w", err) } return &sub, nil } // GetHostingAccounts obtiene las cuentas de hosting. // Endpoint: GET /hosting/v1/websites func (c *HostingerClient) GetHostingAccounts() ([]HostingerHosting, error) { body, err := c.getRaw(context.Background(), "/hosting/v1/websites") if err != nil { return nil, err } var wrapped struct { Data []HostingerHosting `json:"data"` } if err := json.Unmarshal(body, &wrapped); err == nil && wrapped.Data != nil { return wrapped.Data, nil } var arr []HostingerHosting if err := json.Unmarshal(body, &arr); err != nil { return nil, fmt.Errorf("hostinger: no se pudo interpretar respuesta hosting: %w", err) } return arr, nil }