Files
dns-autoresolver/internal/provider/selectel/selectel.go
T
vasyansk 32107571d1 feat(selectel): project-scoped IAM auth with token cache; provider Validate
Selectel Cloud DNS v2 requires a project IAM token in X-Auth-Token, not the
raw service-user secret; the previous client sent the static secret directly
and got 401. The client now parses Credentials.Secret as a Creds JSON blob
(username/password/account_id/project_name), exchanges it for a token via
the Identity API (POST /identity/v3/auth/tokens), and caches the token in
memory per-account until 5 minutes before expiry. ListZones/GetRecords/
ApplyChanges send the cached IAM token instead of the raw secret.

provider.Provider gains a Validate(ctx, Credentials) method so a bad account
can be rejected via trial login at creation time; all Provider fakes across
provider/registry/api/service test packages implement it as a no-op stub for
now (Task 2 will make api's mock configurable).

Security: the service-user password is folded into the token cache key via
SHA-256 (never stored in the clear) so a password change invalidates the
cached token; identity errors are generic and never echo the request body.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01BwxdSt4reTm7Dj1oxRvpP3
2026-07-04 20:02:36 +07:00

407 lines
12 KiB
Go

package selectel
import (
"bytes"
"context"
"crypto/sha256"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"sync"
"time"
"github.com/vasyakrg/dns-autoresolver/internal/diff"
"github.com/vasyakrg/dns-autoresolver/internal/model"
"github.com/vasyakrg/dns-autoresolver/internal/provider"
)
const (
DefaultBaseURL = "https://api.selectel.ru/domains/v2"
DefaultIdentityURL = "https://cloud.api.selcloud.ru/identity/v3/auth/tokens"
tokenLeeway = 5 * time.Minute
)
// Client implements provider.Provider for Selectel DNS API v2. Credentials
// are a service-user login (see Creds); the client exchanges them for a
// project-scoped IAM token via the Identity API and caches it in memory.
type Client struct {
BaseURL string
IdentityURL string
HTTP *http.Client
mu sync.Mutex
tokens map[string]cachedToken
}
// cachedToken is an in-memory IAM token with its expiry, keyed by cacheKey.
type cachedToken struct {
token string
expires time.Time
}
// Creds is the Selectel service-user credential set, stored as the encrypted
// JSON blob in provider_accounts.secret_enc and parsed from Credentials.Secret.
type Creds struct {
Username string `json:"username"`
Password string `json:"password"`
AccountID string `json:"account_id"`
ProjectName string `json:"project_name"`
}
func parseCreds(secret string) (Creds, error) {
var c Creds
if err := json.Unmarshal([]byte(secret), &c); err != nil {
return Creds{}, fmt.Errorf("selectel: invalid credentials format")
}
if c.Username == "" || c.Password == "" || c.AccountID == "" || c.ProjectName == "" {
return Creds{}, fmt.Errorf("selectel: username, password, account_id and project_name are required")
}
return c, nil
}
func New() *Client {
return &Client{
BaseURL: DefaultBaseURL,
IdentityURL: DefaultIdentityURL,
HTTP: &http.Client{Timeout: 30 * time.Second},
tokens: map[string]cachedToken{},
}
}
func (c *Client) Name() string { return "selectel" }
// cacheKey identifies a service-user identity for the in-memory token cache.
// The password is folded in via hash (never in the clear) so that a password
// change invalidates any previously cached token for the same account/user.
func cacheKey(c Creds) string {
sum := sha256.Sum256([]byte(c.AccountID + "\x00" + c.ProjectName + "\x00" + c.Username + "\x00" + c.Password))
return hex.EncodeToString(sum[:])
}
// authenticate exchanges the service-user credentials for a project-scoped
// IAM token via the Identity API (Keystone v3). SECURITY: the request body
// contains the password — errors must never wrap or echo it back.
func (c *Client) authenticate(ctx context.Context, cr Creds) (string, time.Time, error) {
body := map[string]any{
"auth": map[string]any{
"identity": map[string]any{
"methods": []string{"password"},
"password": map[string]any{
"user": map[string]any{
"name": cr.Username,
"domain": map[string]any{"name": cr.AccountID},
"password": cr.Password,
},
},
},
"scope": map[string]any{
"project": map[string]any{
"name": cr.ProjectName,
"domain": map[string]any{"name": cr.AccountID},
},
},
},
}
b, _ := json.Marshal(body)
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.IdentityURL, bytes.NewReader(b))
if err != nil {
return "", time.Time{}, err
}
req.Header.Set("Content-Type", "application/json")
resp, err := c.HTTP.Do(req)
if err != nil {
// Never wrap the error with request/body (contains password).
return "", time.Time{}, fmt.Errorf("selectel: identity request failed")
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
return "", time.Time{}, fmt.Errorf("selectel: identity auth failed: %d", resp.StatusCode)
}
tok := resp.Header.Get("X-Subject-Token")
if tok == "" {
return "", time.Time{}, fmt.Errorf("selectel: identity returned no token")
}
var out struct {
Token struct {
ExpiresAt string `json:"expires_at"`
} `json:"token"`
}
// Body may be absent/short — a decode error is non-fatal; fall back to a
// conservative 1h TTL so the token is still usable and refreshed sooner.
exp := time.Time{}
if err := json.NewDecoder(resp.Body).Decode(&out); err == nil && out.Token.ExpiresAt != "" {
if t, perr := time.Parse(time.RFC3339, out.Token.ExpiresAt); perr == nil {
exp = t
}
}
return tok, exp, nil
}
// token returns a cached, still-valid IAM token for cr, re-authenticating
// when there is no cached entry or it is within tokenLeeway of expiring.
func (c *Client) token(ctx context.Context, cr Creds) (string, error) {
key := cacheKey(cr)
c.mu.Lock()
if ct, ok := c.tokens[key]; ok && (ct.expires.IsZero() || time.Now().Add(tokenLeeway).Before(ct.expires)) {
c.mu.Unlock()
return ct.token, nil
}
c.mu.Unlock()
tok, exp, err := c.authenticate(ctx, cr)
if err != nil {
return "", err
}
// Zero expiry (unparseable) → treat as short-lived: cache for 1h from now.
if exp.IsZero() {
exp = time.Now().Add(time.Hour)
}
c.mu.Lock()
c.tokens[key] = cachedToken{token: tok, expires: exp}
c.mu.Unlock()
return tok, nil
}
// Validate performs a trial login so bad credentials are rejected at account
// creation time rather than at first import.
func (c *Client) Validate(ctx context.Context, creds provider.Credentials) error {
cr, err := parseCreds(creds.Secret)
if err != nil {
return err
}
if _, err := c.token(ctx, cr); err != nil {
return err
}
return nil
}
// --- wire types ---
type apiZone struct {
ID string `json:"id"`
Name string `json:"name"`
}
type apiZoneList struct {
Result []apiZone `json:"result"`
NextOffset int `json:"next_offset"`
}
type apiRec struct {
Content string `json:"content"`
Disabled bool `json:"disabled,omitempty"`
}
type apiRRSet struct {
ID string `json:"id,omitempty"`
Name string `json:"name"`
Type string `json:"type"`
TTL int `json:"ttl"`
Records []apiRec `json:"records"`
}
type apiRRSetList struct {
Result []apiRRSet `json:"result"`
NextOffset int `json:"next_offset"`
}
// --- HTTP helper ---
func (c *Client) do(ctx context.Context, method, path, token string, body any, out any) error {
var reader io.Reader
if body != nil {
b, err := json.Marshal(body)
if err != nil {
return err
}
reader = bytes.NewReader(b)
}
req, err := http.NewRequestWithContext(ctx, method, c.BaseURL+path, reader)
if err != nil {
return err
}
req.Header.Set("X-Auth-Token", token)
if body != nil {
req.Header.Set("Content-Type", "application/json")
}
resp, err := c.HTTP.Do(req)
if err != nil {
return err
}
defer resp.Body.Close()
if resp.StatusCode >= 300 {
msg, _ := io.ReadAll(resp.Body)
return fmt.Errorf("selectel %s %s: %d: %s", method, path, resp.StatusCode, string(msg))
}
if out != nil {
return json.NewDecoder(resp.Body).Decode(out)
}
return nil
}
// --- Provider implementation ---
func (c *Client) ListZones(ctx context.Context, creds provider.Credentials) ([]provider.Zone, error) {
cr, err := parseCreds(creds.Secret)
if err != nil {
return nil, err
}
tok, err := c.token(ctx, cr)
if err != nil {
return nil, err
}
var zones []provider.Zone
offset := 0
for {
var page apiZoneList
path := fmt.Sprintf("/zones?limit=1000&offset=%d", offset)
if err := c.do(ctx, http.MethodGet, path, tok, nil, &page); err != nil {
return nil, err
}
for _, z := range page.Result {
zones = append(zones, provider.Zone{ID: z.ID, Name: z.Name})
}
if page.NextOffset == 0 || len(page.Result) == 0 {
break
}
if page.NextOffset <= offset {
// API returned a non-advancing offset; stop instead of looping forever.
break
}
offset = page.NextOffset
}
return zones, nil
}
func (c *Client) GetRecords(ctx context.Context, creds provider.Credentials, zoneID string) ([]model.Record, error) {
cr, err := parseCreds(creds.Secret)
if err != nil {
return nil, err
}
tok, err := c.token(ctx, cr)
if err != nil {
return nil, err
}
rrsets, err := c.listRRSets(ctx, tok, zoneID)
if err != nil {
return nil, err
}
recs := make([]model.Record, 0, len(rrsets))
for _, rr := range rrsets {
recs = append(recs, toRecord(rr))
}
return recs, nil
}
func (c *Client) listRRSets(ctx context.Context, token, zoneID string) ([]apiRRSet, error) {
var all []apiRRSet
offset := 0
for {
var page apiRRSetList
path := fmt.Sprintf("/zones/%s/rrset?limit=1000&offset=%d", url.PathEscape(zoneID), offset)
if err := c.do(ctx, http.MethodGet, path, token, nil, &page); err != nil {
return nil, err
}
all = append(all, page.Result...)
if page.NextOffset == 0 || len(page.Result) == 0 {
break
}
if page.NextOffset <= offset {
// API returned a non-advancing offset; stop instead of looping forever.
break
}
offset = page.NextOffset
}
return all, nil
}
func (c *Client) ApplyChanges(ctx context.Context, creds provider.Credentials, zoneID string, cs diff.Changeset) error {
cr, err := parseCreds(creds.Secret)
if err != nil {
return err
}
tok, err := c.token(ctx, cr)
if err != nil {
return err
}
// resolve rrset ids for update/delete
existing, err := c.listRRSets(ctx, tok, zoneID)
if err != nil {
return err
}
idByKey := make(map[string]string, len(existing))
for _, rr := range existing {
idByKey[toRecord(rr).Key()] = rr.ID
}
base := "/zones/" + url.PathEscape(zoneID) + "/rrset"
for _, d := range cs.Diffs {
if d.ReadOnly || d.Kind == diff.InSync {
continue
}
switch d.Kind {
case diff.Add:
if d.Desired == nil {
return fmt.Errorf("selectel: add/update diff without Desired record")
}
if err := c.do(ctx, http.MethodPost, base, tok, toRRSet(*d.Desired), nil); err != nil {
return err
}
case diff.Update:
if d.Desired == nil {
return fmt.Errorf("selectel: add/update diff without Desired record")
}
id, ok := idByKey[d.Desired.Key()]
if !ok {
return fmt.Errorf("cannot update: rrset %s not found in zone", d.Desired.Key())
}
if err := c.do(ctx, http.MethodPatch, base+"/"+url.PathEscape(id), tok, toRRSet(*d.Desired), nil); err != nil {
return err
}
case diff.Delete:
if d.Actual == nil {
return fmt.Errorf("selectel: delete diff without Actual record")
}
id, ok := idByKey[d.Actual.Key()]
if !ok {
return fmt.Errorf("cannot delete: rrset %s not found in zone", d.Actual.Key())
}
if err := c.do(ctx, http.MethodDelete, base+"/"+url.PathEscape(id), tok, nil, nil); err != nil {
return err
}
}
}
return nil
}
// NOTE: disabled-записи не участвуют в diff и не сохраняются при apply, т.к. PATCH
// заменяет весь набор records; поддержка отключённых записей — вне текущей фазы.
// Selectel PATCH /rrset/{id} полностью заменяет набор records: он не мержит переданные
// значения с уже существующими. toRecord отбрасывает disabled-записи при чтении, а
// toRRSet никогда их не восстанавливает при записи — поэтому round-trip (read -> diff
// -> apply) безвозвратно теряет ранее disabled-записи в rrset, если он был изменён.
func toRecord(rr apiRRSet) model.Record {
vals := make([]string, 0, len(rr.Records))
for _, r := range rr.Records {
if r.Disabled {
continue
}
vals = append(vals, r.Content)
}
return model.Record{Type: model.RecordType(rr.Type), Name: rr.Name, TTL: rr.TTL, Values: vals}
}
// NOTE: disabled-записи не участвуют в diff и не сохраняются при apply, т.к. PATCH
// заменяет весь набор records; поддержка отключённых записей — вне текущей фазы.
// toRRSet всегда шлёт записи без поля disabled, т.е. любой ранее disabled контент,
// отсутствующий в model.Record.Values, будет молча потерян при следующем PATCH этого rrset.
func toRRSet(rec model.Record) apiRRSet {
rs := apiRRSet{Name: rec.Name, Type: string(rec.Type), TTL: rec.TTL}
for _, v := range rec.Values {
rs.Records = append(rs.Records, apiRec{Content: v})
}
return rs
}
// compile-time check
var _ provider.Provider = (*Client)(nil)