package selectel import ( "bytes" "context" "encoding/json" "fmt" "io" "net/http" "net/url" "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" // Client implements provider.Provider for Selectel DNS API v2. type Client struct { BaseURL string HTTP *http.Client } func New() *Client { return &Client{BaseURL: DefaultBaseURL, HTTP: &http.Client{Timeout: 30 * time.Second}} } func (c *Client) Name() string { return "selectel" } // --- 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) { 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, creds.Secret, 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) { rrsets, err := c.listRRSets(ctx, creds.Secret, 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 { // resolve rrset ids for update/delete existing, err := c.listRRSets(ctx, creds.Secret, 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, creds.Secret, 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), creds.Secret, 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), creds.Secret, 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)