diff --git a/internal/provider/selectel/selectel.go b/internal/provider/selectel/selectel.go new file mode 100644 index 0000000..8e52a85 --- /dev/null +++ b/internal/provider/selectel/selectel.go @@ -0,0 +1,206 @@ +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 + } + 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 + } + 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 err := c.do(ctx, http.MethodPost, base, creds.Secret, toRRSet(*d.Desired), nil); err != nil { + return err + } + case diff.Update: + 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: + 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 +} + +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} +} + +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) diff --git a/internal/provider/selectel/selectel_test.go b/internal/provider/selectel/selectel_test.go new file mode 100644 index 0000000..a50106b --- /dev/null +++ b/internal/provider/selectel/selectel_test.go @@ -0,0 +1,132 @@ +package selectel + +import ( + "context" + "encoding/json" + "net/http" + "net/http/httptest" + "testing" + + "github.com/vasyakrg/dns-autoresolver/internal/diff" + "github.com/vasyakrg/dns-autoresolver/internal/model" + "github.com/vasyakrg/dns-autoresolver/internal/provider" +) + +func creds() provider.Credentials { return provider.Credentials{Secret: "secret-token"} } + +func newTestClient(h http.Handler) (*Client, *httptest.Server) { + srv := httptest.NewServer(h) + return &Client{BaseURL: srv.URL, HTTP: srv.Client()}, srv +} + +func TestListZonesSendsTokenAndParses(t *testing.T) { + var gotToken string + c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + gotToken = r.Header.Get("X-Auth-Token") + json.NewEncoder(w).Encode(map[string]any{ + "result": []map[string]any{ + {"id": "z1", "name": "example.com."}, + {"id": "z2", "name": "test.org."}, + }, + "next_offset": 0, + }) + })) + defer srv.Close() + + zs, err := c.ListZones(context.Background(), creds()) + if err != nil { + t.Fatal(err) + } + if gotToken != "secret-token" { + t.Fatalf("token not sent, got %q", gotToken) + } + if len(zs) != 2 || zs[0].ID != "z1" || zs[1].Name != "test.org." { + t.Fatalf("unexpected zones: %+v", zs) + } +} + +func TestGetRecordsMapsRRSet(t *testing.T) { + c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + json.NewEncoder(w).Encode(map[string]any{ + "result": []map[string]any{ + {"id": "r1", "name": "example.com.", "type": "MX", "ttl": 3600, + "records": []map[string]any{{"content": "10 mx1.example.com.", "disabled": false}}}, + {"id": "r2", "name": "www.example.com.", "type": "A", "ttl": 300, + "records": []map[string]any{{"content": "1.2.3.4"}, {"content": "5.6.7.8", "disabled": true}}}, + }, + "next_offset": 0, + }) + })) + defer srv.Close() + + recs, err := c.GetRecords(context.Background(), creds(), "z1") + if err != nil { + t.Fatal(err) + } + if len(recs) != 2 { + t.Fatalf("want 2 records, got %d", len(recs)) + } + var a model.Record + for _, r := range recs { + if r.Type == model.A { + a = r + } + } + // disabled record dropped -> only one value + if len(a.Values) != 1 || a.Values[0] != "1.2.3.4" { + t.Fatalf("disabled record must be skipped, got %+v", a.Values) + } +} + +func TestApplyChangesRoutesVerbs(t *testing.T) { + type call struct{ method, path string } + var calls []call + c, srv := newTestClient(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + // GET rrset -> return existing set with ids for update/delete resolution + if r.Method == http.MethodGet { + json.NewEncoder(w).Encode(map[string]any{ + "result": []map[string]any{ + {"id": "up1", "name": "b.example.com.", "type": "A", "ttl": 300, + "records": []map[string]any{{"content": "9.9.9.9"}}}, + {"id": "del1", "name": "d.example.com.", "type": "A", "ttl": 300, + "records": []map[string]any{{"content": "4.4.4.4"}}}, + }, + "next_offset": 0, + }) + return + } + calls = append(calls, call{r.Method, r.URL.Path}) + w.WriteHeader(http.StatusOK) + })) + defer srv.Close() + + add := model.Record{Type: model.A, Name: "c.example.com.", TTL: 300, Values: []string{"3.3.3.3"}} + updDesired := model.Record{Type: model.A, Name: "b.example.com.", TTL: 300, Values: []string{"2.2.2.2"}} + delActual := model.Record{Type: model.A, Name: "d.example.com.", TTL: 300, Values: []string{"4.4.4.4"}} + ns := model.Record{Type: model.NS, Name: "example.com.", TTL: 3600, Values: []string{"ns1.example.com."}} + + cs := diff.Changeset{Diffs: []diff.RecordDiff{ + {Kind: diff.Add, Type: add.Type, Name: add.Name, Desired: &add}, + {Kind: diff.Update, Type: updDesired.Type, Name: updDesired.Name, Desired: &updDesired}, + {Kind: diff.Delete, Type: delActual.Type, Name: delActual.Name, Actual: &delActual}, + {Kind: diff.Update, Type: ns.Type, Name: ns.Name, Desired: &ns, ReadOnly: true}, // must be skipped + }} + + if err := c.ApplyChanges(context.Background(), creds(), "z1", cs); err != nil { + t.Fatal(err) + } + + want := map[string]bool{ + "POST /zones/z1/rrset": true, + "PATCH /zones/z1/rrset/up1": true, + "DELETE /zones/z1/rrset/del1": true, + } + if len(calls) != len(want) { + t.Fatalf("want %d calls, got %v", len(want), calls) + } + for _, cl := range calls { + if !want[cl.method+" "+cl.path] { + t.Fatalf("unexpected call %s %s", cl.method, cl.path) + } + } +}