No One Can Stop Me Now
This commit is contained in:
127
services/setec-manager/internal/hosting/hostinger/billing.go
Normal file
127
services/setec-manager/internal/hosting/hostinger/billing.go
Normal 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
|
||||
}
|
||||
172
services/setec-manager/internal/hosting/hostinger/client.go
Normal file
172
services/setec-manager/internal/hosting/hostinger/client.go
Normal 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
|
||||
}
|
||||
136
services/setec-manager/internal/hosting/hostinger/dns.go
Normal file
136
services/setec-manager/internal/hosting/hostinger/dns.go
Normal 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
|
||||
}
|
||||
218
services/setec-manager/internal/hosting/hostinger/domains.go
Normal file
218
services/setec-manager/internal/hosting/hostinger/domains.go
Normal 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,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
package hostinger
|
||||
|
||||
import "setec-manager/internal/hosting"
|
||||
|
||||
func init() {
|
||||
hosting.Register(New(""))
|
||||
}
|
||||
219
services/setec-manager/internal/hosting/hostinger/vps.go
Normal file
219
services/setec-manager/internal/hosting/hostinger/vps.go
Normal 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,
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user