Files
traefik-selectel/internal/selectel/client.go
T
vasyanskandClaude Sonnet 5 273cd54415
Build / Tests (push) Successful in 13s
Build / Build image (push) Failing after 15s
Build / Notify on failure (push) Failing after 0s
feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
2026-09-21 21:50:07 +07:00

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
}