package internal import ( "bytes" "encoding/json" "fmt" "io" "net/http" "regexp" "strconv" "strings" "time" ) // Object types. const ( ConfigType = "Configuration" ViewType = "View" ZoneType = "Zone" TXTType = "TXTRecord" ) type Client struct { HTTPClient *http.Client baseURL string token string tokenExp *regexp.Regexp } func NewClient(baseURL string) *Client { return &Client{ HTTPClient: &http.Client{Timeout: 30 * time.Second}, baseURL: baseURL, tokenExp: regexp.MustCompile("BAMAuthToken: [^ ]+"), } } // Login Logs in as API user. // Authenticates and receives a token to be used in for subsequent requests. // https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/GET/v1/login/9.1.0 func (c *Client) Login(username, password string) error { queryArgs := map[string]string{ "username": username, "password": password, } resp, err := c.sendRequest(http.MethodGet, "login", nil, queryArgs) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { data, _ := io.ReadAll(resp.Body) return &APIError{ StatusCode: resp.StatusCode, Resource: "login", Message: string(data), } } authBytes, err := io.ReadAll(resp.Body) if err != nil { return err } authResp := string(authBytes) if strings.Contains(authResp, "Authentication Error") { return fmt.Errorf("request failed: %s", strings.Trim(authResp, `"`)) } // Upon success, API responds with "Session Token-> BAMAuthToken: dQfuRMTUxNjc3MjcyNDg1ODppcGFybXM= <- for User : username" c.token = c.tokenExp.FindString(authResp) return nil } // Logout Logs out of the current API session. // https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/GET/v1/logout/9.1.0 func (c *Client) Logout() error { if c.token == "" { // nothing to do return nil } resp, err := c.sendRequest(http.MethodGet, "logout", nil, nil) if err != nil { return err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { data, _ := io.ReadAll(resp.Body) return &APIError{ StatusCode: resp.StatusCode, Resource: "logout", Message: string(data), } } authBytes, err := io.ReadAll(resp.Body) if err != nil { return err } authResp := string(authBytes) if !strings.Contains(authResp, "successfully") { return fmt.Errorf("request failed to delete session: %s", strings.Trim(authResp, `"`)) } c.token = "" return nil } // Deploy the DNS config for the specified entity to the authoritative servers. // https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/POST/v1/quickDeploy/9.1.0 func (c *Client) Deploy(entityID uint) error { queryArgs := map[string]string{ "entityId": strconv.FormatUint(uint64(entityID), 10), } resp, err := c.sendRequest(http.MethodPost, "quickDeploy", nil, queryArgs) if err != nil { return err } defer resp.Body.Close() // The API doc says that 201 is expected but in the reality 200 is return. if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK { data, _ := io.ReadAll(resp.Body) return &APIError{ StatusCode: resp.StatusCode, Resource: "quickDeploy", Message: string(data), } } return nil } // AddEntity A generic method for adding configurations, DNS zones, and DNS resource records. // https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/POST/v1/addEntity/9.1.0 func (c *Client) AddEntity(parentID uint, entity Entity) (uint64, error) { queryArgs := map[string]string{ "parentId": strconv.FormatUint(uint64(parentID), 10), } resp, err := c.sendRequest(http.MethodPost, "addEntity", entity, queryArgs) if err != nil { return 0, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { data, _ := io.ReadAll(resp.Body) return 0, &APIError{ StatusCode: resp.StatusCode, Resource: "addEntity", Message: string(data), } } addTxtBytes, _ := io.ReadAll(resp.Body) // addEntity responds only with body text containing the ID of the created record addTxtResp := string(addTxtBytes) id, err := strconv.ParseUint(addTxtResp, 10, 64) if err != nil { return 0, fmt.Errorf("addEntity request failed: %s", addTxtResp) } return id, nil } // GetEntityByName Returns objects from the database referenced by their database ID and with its properties fields populated. // https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/GET/v1/getEntityById/9.1.0 func (c *Client) GetEntityByName(parentID uint, name, objType string) (*EntityResponse, error) { queryArgs := map[string]string{ "parentId": strconv.FormatUint(uint64(parentID), 10), "name": name, "type": objType, } resp, err := c.sendRequest(http.MethodGet, "getEntityByName", nil, queryArgs) if err != nil { return nil, err } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { data, _ := io.ReadAll(resp.Body) return nil, &APIError{ StatusCode: resp.StatusCode, Resource: "getEntityByName", Message: string(data), } } var txtRec EntityResponse if err = json.NewDecoder(resp.Body).Decode(&txtRec); err != nil { return nil, fmt.Errorf("JSON decode: %w", err) } return &txtRec, nil } // Delete Deletes an object using the generic delete method. // https://docs.bluecatnetworks.com/r/Address-Manager-API-Guide/DELETE/v1/delete/9.1.0 func (c *Client) Delete(objectID uint) error { queryArgs := map[string]string{ "objectId": strconv.FormatUint(uint64(objectID), 10), } resp, err := c.sendRequest(http.MethodDelete, "delete", nil, queryArgs) if err != nil { return err } defer resp.Body.Close() // The API doc says that 204 is expected but in the reality 200 is return. if resp.StatusCode != http.StatusNoContent && resp.StatusCode != http.StatusOK { data, _ := io.ReadAll(resp.Body) return &APIError{ StatusCode: resp.StatusCode, Resource: "delete", Message: string(data), } } return nil } // LookupViewID Find the DNS view with the given name within. func (c *Client) LookupViewID(configName, viewName string) (uint, error) { // Lookup the entity ID of the configuration named in our properties. conf, err := c.GetEntityByName(0, configName, ConfigType) if err != nil { return 0, err } view, err := c.GetEntityByName(conf.ID, viewName, ViewType) if err != nil { return 0, err } return view.ID, nil } // LookupParentZoneID Return the entityId of the parent zone by recursing from the root view. // Also return the simple name of the host. func (c *Client) LookupParentZoneID(viewID uint, fqdn string) (uint, string, error) { if fqdn == "" { return viewID, "", nil } zones := strings.Split(strings.Trim(fqdn, "."), ".") name := zones[0] parentViewID := viewID for i := len(zones) - 1; i > -1; i-- { zone, err := c.GetEntityByName(parentViewID, zones[i], ZoneType) if err != nil { return 0, "", fmt.Errorf("could not find zone named %s: %w", name, err) } if zone == nil || zone.ID == 0 { break } if i > 0 { name = strings.Join(zones[0:i], ".") } parentViewID = zone.ID } return parentViewID, name, nil } // Send a REST request, using query parameters specified. // The Authorization header will be set if we have an active auth token. func (c *Client) sendRequest(method, resource string, payload interface{}, queryParams map[string]string) (*http.Response, error) { url := fmt.Sprintf("%s/Services/REST/v1/%s", c.baseURL, resource) body, err := json.Marshal(payload) if err != nil { return nil, err } req, err := http.NewRequest(method, url, bytes.NewReader(body)) if err != nil { return nil, err } req.Header.Set("Content-Type", "application/json") if c.token != "" { req.Header.Set("Authorization", c.token) } q := req.URL.Query() for k, v := range queryParams { q.Set(k, v) } req.URL.RawQuery = q.Encode() return c.HTTPClient.Do(req) }