feat: traefik-selectel-dns — A-записи Selectel по роутам Traefik + CI сборка образа
Co-Authored-By: Claude Sonnet 5 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,308 @@
|
||||
// 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
|
||||
}
|
||||
Reference in New Issue
Block a user