309 lines
9.2 KiB
Go
309 lines
9.2 KiB
Go
// Package selectel — клиент Selectel DNS API v2 (https://api.selectel.ru/domains/v2).
|
|
//
|
|
// Аутентификация: IAM-токен проекта в заголовке X-Auth-Token.
|
|
// Имена в API — FQDN с точкой в конце; клиент снаружи оперирует
|
|
// нормализованными именами (lower-case, без точки) и добавляет точку сам.
|
|
package selectel
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"net/http"
|
|
"net/url"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/realmanual/traefik-selectel/internal/httpx"
|
|
)
|
|
|
|
// DefaultBaseURL — DNS-хостинг v2 (единый для всех регионов).
|
|
const DefaultBaseURL = "https://api.selectel.ru/domains/v2"
|
|
|
|
const (
|
|
pageLimit = 1000
|
|
maxPages = 1000
|
|
zoneCacheTTL = 10 * time.Minute
|
|
)
|
|
|
|
// ErrZoneNotFound — зона не найдена в проекте.
|
|
var ErrZoneNotFound = errors.New("selectel: зона не найдена")
|
|
|
|
// Record — значение записи RRSet.
|
|
type Record struct {
|
|
Content string `json:"content"`
|
|
Disabled bool `json:"disabled"`
|
|
}
|
|
|
|
// RRSet — набор записей одного имени и типа.
|
|
type RRSet struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"` // нормализованное: lower-case, без точки в конце
|
|
Type string `json:"type"`
|
|
TTL int `json:"ttl"`
|
|
Records []Record `json:"records"`
|
|
Comment string `json:"comment"`
|
|
ManagedBy string `json:"managed_by,omitempty"`
|
|
}
|
|
|
|
// Client — клиент DNS API.
|
|
type Client struct {
|
|
baseURL string
|
|
tokens TokenSource
|
|
http *http.Client
|
|
retry httpx.Retrier
|
|
now func() time.Time
|
|
|
|
mu sync.Mutex
|
|
zones map[string]zoneEntry
|
|
}
|
|
|
|
type zoneEntry struct {
|
|
id string
|
|
expires time.Time
|
|
}
|
|
|
|
// NewClient создаёт клиент. baseURL пустой — DefaultBaseURL.
|
|
func NewClient(baseURL string, tokens TokenSource, timeout time.Duration) *Client {
|
|
if baseURL == "" {
|
|
baseURL = DefaultBaseURL
|
|
}
|
|
return &Client{
|
|
baseURL: strings.TrimRight(baseURL, "/"),
|
|
tokens: tokens,
|
|
http: &http.Client{Timeout: timeout},
|
|
retry: httpx.DefaultRetrier(),
|
|
now: time.Now,
|
|
zones: map[string]zoneEntry{},
|
|
}
|
|
}
|
|
|
|
// APIError — неуспешный ответ API.
|
|
type APIError struct {
|
|
Method string
|
|
Path string
|
|
Status int
|
|
Body string
|
|
}
|
|
|
|
func (e *APIError) Error() string {
|
|
return fmt.Sprintf("selectel: %s %s: статус %d: %s", e.Method, e.Path, e.Status, e.Body)
|
|
}
|
|
|
|
// do выполняет запрос с токеном; при 401 один раз сбрасывает токен и повторяет.
|
|
// Возвращает тело ответа (уже прочитанное) при 2xx.
|
|
func (c *Client) do(ctx context.Context, method, path string, query url.Values, payload any) ([]byte, error) {
|
|
var raw []byte
|
|
if payload != nil {
|
|
var err error
|
|
if raw, err = json.Marshal(payload); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
full := c.baseURL + path
|
|
if len(query) > 0 {
|
|
full += "?" + query.Encode()
|
|
}
|
|
for attempt := 0; attempt < 2; attempt++ {
|
|
token, err := c.tokens.Token(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
resp, err := c.retry.Do(ctx, c.http, func() (*http.Request, error) {
|
|
var body *bytes.Reader
|
|
if raw != nil {
|
|
body = bytes.NewReader(raw)
|
|
} else {
|
|
body = bytes.NewReader(nil)
|
|
}
|
|
req, err := http.NewRequest(method, full, body)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
req.Header.Set("X-Auth-Token", token)
|
|
req.Header.Set("Accept", "application/json")
|
|
if raw != nil {
|
|
req.Header.Set("Content-Type", "application/json")
|
|
}
|
|
return req, nil
|
|
})
|
|
if err != nil {
|
|
return nil, fmt.Errorf("selectel: %s %s: %w", method, path, err)
|
|
}
|
|
data, rerr := httpx.ReadBody(resp)
|
|
resp.Body.Close()
|
|
if rerr != nil {
|
|
return nil, fmt.Errorf("selectel: %s %s: чтение ответа: %w", method, path, rerr)
|
|
}
|
|
if resp.StatusCode == http.StatusUnauthorized && attempt == 0 {
|
|
c.tokens.Invalidate()
|
|
continue
|
|
}
|
|
if resp.StatusCode < 200 || resp.StatusCode > 299 {
|
|
return nil, &APIError{Method: method, Path: path, Status: resp.StatusCode, Body: httpx.Snippet(data, 300)}
|
|
}
|
|
return data, nil
|
|
}
|
|
return nil, &APIError{Method: method, Path: path, Status: http.StatusUnauthorized, Body: "токен отклонён"}
|
|
}
|
|
|
|
func normalize(name string) string {
|
|
return strings.ToLower(strings.TrimSuffix(strings.TrimSpace(name), "."))
|
|
}
|
|
|
|
func fqdn(name string) string { return normalize(name) + "." }
|
|
|
|
type listResponse[T any] struct {
|
|
Count int `json:"count"`
|
|
NextOffset int `json:"next_offset"`
|
|
Result []T `json:"result"`
|
|
}
|
|
|
|
type zoneDTO struct {
|
|
ID string `json:"id"`
|
|
Name string `json:"name"`
|
|
}
|
|
|
|
// zoneID возвращает id зоны по имени (с кэшем).
|
|
func (c *Client) zoneID(ctx context.Context, zone string, force bool) (string, error) {
|
|
zone = normalize(zone)
|
|
c.mu.Lock()
|
|
e, ok := c.zones[zone]
|
|
c.mu.Unlock()
|
|
if ok && !force && c.now().Before(e.expires) {
|
|
return e.id, nil
|
|
}
|
|
offset := 0
|
|
for page := 0; page < maxPages; page++ {
|
|
q := url.Values{}
|
|
q.Set("limit", strconv.Itoa(pageLimit))
|
|
q.Set("offset", strconv.Itoa(offset))
|
|
q.Set("filter", zone)
|
|
data, err := c.do(ctx, http.MethodGet, "/zones", q, nil)
|
|
if err != nil {
|
|
return "", err
|
|
}
|
|
var lr listResponse[zoneDTO]
|
|
if err := json.Unmarshal(data, &lr); err != nil {
|
|
return "", fmt.Errorf("selectel: разбор списка зон: %w", err)
|
|
}
|
|
for _, z := range lr.Result {
|
|
if normalize(z.Name) == zone {
|
|
c.mu.Lock()
|
|
c.zones[zone] = zoneEntry{id: z.ID, expires: c.now().Add(zoneCacheTTL)}
|
|
c.mu.Unlock()
|
|
return z.ID, nil
|
|
}
|
|
}
|
|
if len(lr.Result) < pageLimit {
|
|
break
|
|
}
|
|
offset += len(lr.Result)
|
|
}
|
|
return "", fmt.Errorf("%w: %s", ErrZoneNotFound, zone)
|
|
}
|
|
|
|
func (c *Client) forgetZone(zone string) {
|
|
c.mu.Lock()
|
|
delete(c.zones, normalize(zone))
|
|
c.mu.Unlock()
|
|
}
|
|
|
|
// withZone выполняет fn с id зоны; при 404 сбрасывает кэш и повторяет один раз.
|
|
func (c *Client) withZone(ctx context.Context, zone string, fn func(id string) error) error {
|
|
id, err := c.zoneID(ctx, zone, false)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
err = fn(id)
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {
|
|
c.forgetZone(zone)
|
|
if id, err = c.zoneID(ctx, zone, true); err != nil {
|
|
return err
|
|
}
|
|
return fn(id)
|
|
}
|
|
return err
|
|
}
|
|
|
|
// ListRRSets возвращает все RRSet зоны (все типы; фильтрация — на стороне вызывающего).
|
|
func (c *Client) ListRRSets(ctx context.Context, zone string) ([]RRSet, error) {
|
|
var out []RRSet
|
|
err := c.withZone(ctx, zone, func(id string) error {
|
|
out = out[:0]
|
|
offset := 0
|
|
for page := 0; page < maxPages; page++ {
|
|
q := url.Values{}
|
|
q.Set("limit", strconv.Itoa(pageLimit))
|
|
q.Set("offset", strconv.Itoa(offset))
|
|
data, err := c.do(ctx, http.MethodGet, "/zones/"+url.PathEscape(id)+"/rrset", q, nil)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
var lr listResponse[RRSet]
|
|
if err := json.Unmarshal(data, &lr); err != nil {
|
|
return fmt.Errorf("selectel: разбор списка rrset: %w", err)
|
|
}
|
|
for _, rr := range lr.Result {
|
|
rr.Name = normalize(rr.Name)
|
|
rr.Type = strings.ToUpper(rr.Type)
|
|
out = append(out, rr)
|
|
}
|
|
if len(lr.Result) < pageLimit {
|
|
return nil
|
|
}
|
|
offset += len(lr.Result)
|
|
}
|
|
return fmt.Errorf("selectel: слишком много страниц rrset")
|
|
})
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
return out, nil
|
|
}
|
|
|
|
type rrsetBody struct {
|
|
Name string `json:"name,omitempty"`
|
|
Type string `json:"type,omitempty"`
|
|
TTL int `json:"ttl"`
|
|
Records []Record `json:"records"`
|
|
Comment string `json:"comment"`
|
|
}
|
|
|
|
// CreateRRSet создаёт RRSet (name — без точки на конце, точка добавляется клиентом).
|
|
func (c *Client) CreateRRSet(ctx context.Context, zone string, rr RRSet) error {
|
|
body := rrsetBody{Name: fqdn(rr.Name), Type: strings.ToUpper(rr.Type), TTL: rr.TTL, Records: rr.Records, Comment: rr.Comment}
|
|
return c.withZone(ctx, zone, func(id string) error {
|
|
_, err := c.do(ctx, http.MethodPost, "/zones/"+url.PathEscape(id)+"/rrset", nil, body)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// UpdateRRSet обновляет ttl, records и comment существующего RRSet.
|
|
func (c *Client) UpdateRRSet(ctx context.Context, zone, rrsetID string, ttl int, records []Record, comment string) error {
|
|
body := rrsetBody{TTL: ttl, Records: records, Comment: comment}
|
|
return c.withZone(ctx, zone, func(id string) error {
|
|
_, err := c.do(ctx, http.MethodPatch, "/zones/"+url.PathEscape(id)+"/rrset/"+url.PathEscape(rrsetID), nil, body)
|
|
return err
|
|
})
|
|
}
|
|
|
|
// DeleteRRSet удаляет RRSet.
|
|
// Ответ 404 считается успехом (идемпотентность).
|
|
func (c *Client) DeleteRRSet(ctx context.Context, zone, rrsetID string) error {
|
|
err := c.withZone(ctx, zone, func(id string) error {
|
|
_, err := c.do(ctx, http.MethodDelete, "/zones/"+url.PathEscape(id)+"/rrset/"+url.PathEscape(rrsetID), nil, nil)
|
|
return err
|
|
})
|
|
var apiErr *APIError
|
|
if errors.As(err, &apiErr) && apiErr.Status == http.StatusNotFound {
|
|
return nil
|
|
}
|
|
return err
|
|
}
|