// 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 }