client.go raw
1 package internal
2
3 import (
4 "bytes"
5 "context"
6 "encoding/json"
7 "fmt"
8 "io"
9 "net/http"
10 "net/url"
11 "regexp"
12 "strconv"
13 "strings"
14 "time"
15
16 "github.com/go-acme/lego/v4/providers/dns/internal/errutils"
17 )
18
19 // Object types.
20 const (
21 ConfigType = "Configuration"
22 ViewType = "View"
23 ZoneType = "Zone"
24 TXTType = "TXTRecord"
25 )
26
27 const authorizationHeader = "Authorization"
28
29 type Client struct {
30 username string
31 password string
32
33 tokenExp *regexp.Regexp
34
35 baseURL *url.URL
36 HTTPClient *http.Client
37 }
38
39 func NewClient(baseURL, username, password string) *Client {
40 bu, _ := url.Parse(baseURL)
41
42 return &Client{
43 username: username,
44 password: password,
45 tokenExp: regexp.MustCompile("BAMAuthToken: [^ ]+"),
46 baseURL: bu,
47 HTTPClient: &http.Client{Timeout: 30 * time.Second},
48 }
49 }
50
51 // Deploy the DNS config for the specified entity to the authoritative servers.
52 // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/quickDeploy/9.5.0
53 func (c *Client) Deploy(ctx context.Context, entityID uint) error {
54 endpoint := c.createEndpoint("quickDeploy")
55
56 q := endpoint.Query()
57 q.Set("entityId", strconv.FormatUint(uint64(entityID), 10))
58 endpoint.RawQuery = q.Encode()
59
60 req, err := newJSONRequest(ctx, http.MethodPost, endpoint, nil)
61 if err != nil {
62 return err
63 }
64
65 resp, err := c.doAuthenticated(ctx, req)
66 if err != nil {
67 return errutils.NewHTTPDoError(req, err)
68 }
69
70 defer func() { _ = resp.Body.Close() }()
71
72 // The API doc says that 201 is expected but in the reality 200 is return.
73 if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
74 return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
75 }
76
77 return nil
78 }
79
80 // AddEntity A generic method for adding configurations, DNS zones, and DNS resource records.
81 // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/POST/v1/addEntity/9.5.0
82 func (c *Client) AddEntity(ctx context.Context, parentID uint, entity Entity) (uint64, error) {
83 endpoint := c.createEndpoint("addEntity")
84
85 q := endpoint.Query()
86 q.Set("parentId", strconv.FormatUint(uint64(parentID), 10))
87 endpoint.RawQuery = q.Encode()
88
89 req, err := newJSONRequest(ctx, http.MethodPost, endpoint, entity)
90 if err != nil {
91 return 0, err
92 }
93
94 resp, err := c.doAuthenticated(ctx, req)
95 if err != nil {
96 return 0, errutils.NewHTTPDoError(req, err)
97 }
98
99 defer func() { _ = resp.Body.Close() }()
100
101 if resp.StatusCode != http.StatusOK {
102 return 0, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
103 }
104
105 raw, _ := io.ReadAll(resp.Body)
106
107 // addEntity responds only with body text containing the ID of the created record
108 addTxtResp := string(raw)
109
110 id, err := strconv.ParseUint(addTxtResp, 10, 64)
111 if err != nil {
112 return 0, fmt.Errorf("addEntity request failed: %s", addTxtResp)
113 }
114
115 return id, nil
116 }
117
118 // GetEntityByName Returns objects from the database referenced by their database ID and with its properties fields populated.
119 // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/GET/v1/getEntityById/9.5.0
120 func (c *Client) GetEntityByName(ctx context.Context, parentID uint, name, objType string) (*EntityResponse, error) {
121 endpoint := c.createEndpoint("getEntityByName")
122
123 q := endpoint.Query()
124 q.Set("parentId", strconv.FormatUint(uint64(parentID), 10))
125 q.Set("name", name)
126 q.Set("type", objType)
127 endpoint.RawQuery = q.Encode()
128
129 req, err := newJSONRequest(ctx, http.MethodGet, endpoint, nil)
130 if err != nil {
131 return nil, err
132 }
133
134 resp, err := c.doAuthenticated(ctx, req)
135 if err != nil {
136 return nil, errutils.NewHTTPDoError(req, err)
137 }
138
139 defer func() { _ = resp.Body.Close() }()
140
141 if resp.StatusCode != http.StatusOK {
142 return nil, errutils.NewUnexpectedResponseStatusCodeError(req, resp)
143 }
144
145 raw, err := io.ReadAll(resp.Body)
146 if err != nil {
147 return nil, errutils.NewReadResponseError(req, resp.StatusCode, err)
148 }
149
150 var entity EntityResponse
151
152 err = json.Unmarshal(raw, &entity)
153 if err != nil {
154 return nil, errutils.NewUnmarshalError(req, resp.StatusCode, raw, err)
155 }
156
157 return &entity, nil
158 }
159
160 // Delete Deletes an object using the generic delete method.
161 // https://docs.bluecatnetworks.com/r/Address-Manager-Legacy-v1-API-Guide/DELETE/v1/delete/9.5.0
162 func (c *Client) Delete(ctx context.Context, objectID uint) error {
163 endpoint := c.createEndpoint("delete")
164
165 q := endpoint.Query()
166 q.Set("objectId", strconv.FormatUint(uint64(objectID), 10))
167 endpoint.RawQuery = q.Encode()
168
169 req, err := newJSONRequest(ctx, http.MethodDelete, endpoint, nil)
170 if err != nil {
171 return err
172 }
173
174 resp, err := c.doAuthenticated(ctx, req)
175 if err != nil {
176 return errutils.NewHTTPDoError(req, err)
177 }
178
179 defer func() { _ = resp.Body.Close() }()
180
181 // The API doc says that 204 is expected but in the reality 200 is returned.
182 if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK {
183 return errutils.NewUnexpectedResponseStatusCodeError(req, resp)
184 }
185
186 return nil
187 }
188
189 // LookupViewID Find the DNS view with the given name within.
190 func (c *Client) LookupViewID(ctx context.Context, configName, viewName string) (uint, error) {
191 // Lookup the entity ID of the configuration named in our properties.
192 conf, err := c.GetEntityByName(ctx, 0, configName, ConfigType)
193 if err != nil {
194 return 0, err
195 }
196
197 view, err := c.GetEntityByName(ctx, conf.ID, viewName, ViewType)
198 if err != nil {
199 return 0, err
200 }
201
202 return view.ID, nil
203 }
204
205 // LookupParentZoneID returns the entityId of the parent zone by iterating through the root labels.
206 // Also return the simple name of the host.
207 func (c *Client) LookupParentZoneID(ctx context.Context, viewID uint, fqdn string) (uint, string, error) {
208 if fqdn == "" {
209 return viewID, "", nil
210 }
211
212 zones := strings.Split(strings.Trim(fqdn, "."), ".")
213
214 name := zones[0]
215 parentViewID := viewID
216
217 for i := len(zones) - 1; i > -1; i-- {
218 zone, err := c.GetEntityByName(ctx, parentViewID, zones[i], ZoneType)
219 if err != nil {
220 return 0, "", fmt.Errorf("could not find zone named %s: %w", name, err)
221 }
222
223 if zone == nil || zone.ID == 0 {
224 break
225 }
226
227 if i > 0 {
228 name = strings.Join(zones[0:i], ".")
229 }
230
231 parentViewID = zone.ID
232 }
233
234 return parentViewID, name, nil
235 }
236
237 func (c *Client) createEndpoint(resource string) *url.URL {
238 return c.baseURL.JoinPath("Services", "REST", "v1", resource)
239 }
240
241 func (c *Client) doAuthenticated(ctx context.Context, req *http.Request) (*http.Response, error) {
242 tok := getToken(ctx)
243 if tok != "" {
244 req.Header.Set(authorizationHeader, tok)
245 }
246
247 return c.HTTPClient.Do(req)
248 }
249
250 func newJSONRequest(ctx context.Context, method string, endpoint *url.URL, payload any) (*http.Request, error) {
251 buf := new(bytes.Buffer)
252
253 if payload != nil {
254 err := json.NewEncoder(buf).Encode(payload)
255 if err != nil {
256 return nil, fmt.Errorf("failed to create request JSON body: %w", err)
257 }
258 }
259
260 req, err := http.NewRequestWithContext(ctx, method, endpoint.String(), buf)
261 if err != nil {
262 return nil, fmt.Errorf("unable to create request: %w", err)
263 }
264
265 req.Header.Set("Accept", "application/json")
266
267 if payload != nil {
268 req.Header.Set("Content-Type", "application/json")
269 }
270
271 return req, nil
272 }
273