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)