477 lines
17 KiB
Go
477 lines
17 KiB
Go
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"`
|
||
SubscriptionID string `json:"subscription_id"`
|
||
}
|
||
|
||
// 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 ───────────────────────────────────────────────────────
|
||
|
||
// GetVPSByID obtiene una VM específica por su ID.
|
||
func (c *HostingerClient) GetVPSByID(vmID int) (*HostingerVPS, error) {
|
||
path := fmt.Sprintf("/vps/v1/virtual-machines/%d", vmID)
|
||
body, err := c.getRaw(context.Background(), path)
|
||
if err != nil {
|
||
return nil, err
|
||
}
|
||
var vps HostingerVPS
|
||
if err := json.Unmarshal(body, &vps); err != nil {
|
||
return nil, fmt.Errorf("hostinger: no se pudo interpretar respuesta VPS: %w", err)
|
||
}
|
||
return &vps, nil
|
||
}
|
||
|
||
// 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
|
||
}
|
||
|
||
// GetSubscriptionByID obtiene una suscripción de billing por su ID.
|
||
func (c *HostingerClient) GetSubscriptionByID(subID string) (*HostingerSubscription, error) {
|
||
path := fmt.Sprintf("/billing/v1/subscriptions/%s", subID)
|
||
body, err := c.getRaw(context.Background(), path)
|
||
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 suscripción: %w", err)
|
||
}
|
||
return &sub, 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
|
||
}
|