mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 21:47:20 +00:00
302 lines
8.4 KiB
Go
302 lines
8.4 KiB
Go
|
|
package fleetdm_client
|
||
|
|
|
||
|
|
// This file gives us a nice API to use to call FleetDM's API. It's focused
|
||
|
|
// only on teams.
|
||
|
|
|
||
|
|
import (
|
||
|
|
"bytes"
|
||
|
|
"encoding/json"
|
||
|
|
"fmt"
|
||
|
|
"io"
|
||
|
|
"net/http"
|
||
|
|
"net/url"
|
||
|
|
"strconv"
|
||
|
|
"strings"
|
||
|
|
"time"
|
||
|
|
)
|
||
|
|
|
||
|
|
const prefix = "/api/v1/fleet"
|
||
|
|
const teamPrefix = prefix + "/teams"
|
||
|
|
|
||
|
|
type Team struct {
|
||
|
|
Name string `json:"name"`
|
||
|
|
ID int64 `json:"id"`
|
||
|
|
Description string `json:"description"`
|
||
|
|
AgentOptions interface{} `json:"agent_options"`
|
||
|
|
Scripts interface{} `json:"scripts"`
|
||
|
|
Secrets []struct {
|
||
|
|
Secret string `json:"secret"`
|
||
|
|
Created time.Time `json:"created_at"`
|
||
|
|
TeamID int `json:"team_id"`
|
||
|
|
} `json:"secrets"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type TeamCreate struct {
|
||
|
|
Name string `json:"name,omitempty"`
|
||
|
|
Description string `json:"description,omitempty"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type TeamGetResponse struct {
|
||
|
|
Team Team `json:"team"`
|
||
|
|
}
|
||
|
|
|
||
|
|
type TeamQueryResponse struct {
|
||
|
|
Teams []Team `json:"teams"`
|
||
|
|
}
|
||
|
|
|
||
|
|
// FleetDMClient is a FleetDM HTTP client that overrides http.DefaultClient.
|
||
|
|
type FleetDMClient struct {
|
||
|
|
*http.Client
|
||
|
|
URL string
|
||
|
|
APIKey string
|
||
|
|
}
|
||
|
|
|
||
|
|
// NewFleetDMClient creates a new instance of FleetDMClient with the provided
|
||
|
|
// URL and API key.
|
||
|
|
func NewFleetDMClient(url, apiKey string) *FleetDMClient {
|
||
|
|
// Ensure the URL ends with a trailing slash for bizarre parsing reasons
|
||
|
|
if !strings.HasSuffix(url, "/") {
|
||
|
|
url += "/"
|
||
|
|
}
|
||
|
|
return &FleetDMClient{
|
||
|
|
Client: http.DefaultClient,
|
||
|
|
URL: url,
|
||
|
|
APIKey: apiKey,
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
// Do will add necessary headers and call the http.Client.Do method.
|
||
|
|
func (c *FleetDMClient) do(req *http.Request, query *url.Values) (*http.Response, error) {
|
||
|
|
// Add the API key to the request header
|
||
|
|
req.Header.Add("Authorization", fmt.Sprintf("Bearer %s", c.APIKey))
|
||
|
|
req.Header.Add("Accept", `application/json`)
|
||
|
|
// Set the request URL based on the client URL
|
||
|
|
baseURL, err := url.Parse(c.URL)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to parse base URL %s: %w", c.URL, err)
|
||
|
|
}
|
||
|
|
req.URL = baseURL.JoinPath(req.URL.Path)
|
||
|
|
if query != nil {
|
||
|
|
req.URL.RawQuery = query.Encode()
|
||
|
|
}
|
||
|
|
// Send the request using the embedded http.Client
|
||
|
|
return c.Client.Do(req)
|
||
|
|
}
|
||
|
|
|
||
|
|
// TeamNameToID will return the ID of a team given the name.
|
||
|
|
func (c *FleetDMClient) TeamNameToID(name string) (int64, error) {
|
||
|
|
req, err := http.NewRequest(http.MethodGet, teamPrefix, nil)
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("failed to create GET request for %s: %w", teamPrefix, err)
|
||
|
|
}
|
||
|
|
vals := make(url.Values)
|
||
|
|
vals.Add("query", name)
|
||
|
|
resp, err := c.do(req, &vals)
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("failed to GET %s %s: %w", teamPrefix, name, err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
return 0, fmt.Errorf("failed to get team: %s %s", name, resp.Status)
|
||
|
|
}
|
||
|
|
|
||
|
|
var teamqry TeamQueryResponse
|
||
|
|
b, err := io.ReadAll(resp.Body)
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("failed to read team response: %w", err)
|
||
|
|
}
|
||
|
|
err = json.Unmarshal(b, &teamqry)
|
||
|
|
if err != nil {
|
||
|
|
return 0, fmt.Errorf("failed to decode get team response: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
for _, team := range teamqry.Teams {
|
||
|
|
if team.Name == name {
|
||
|
|
return team.ID, nil
|
||
|
|
}
|
||
|
|
}
|
||
|
|
|
||
|
|
return 0, fmt.Errorf("team %s not found", name)
|
||
|
|
}
|
||
|
|
|
||
|
|
// CreateTeam creates a new team with the provided name and description.
|
||
|
|
func (c *FleetDMClient) CreateTeam(name string, description string) (*TeamGetResponse, error) {
|
||
|
|
teamCreate := TeamCreate{
|
||
|
|
Name: name,
|
||
|
|
Description: description,
|
||
|
|
}
|
||
|
|
nameJson, err := json.Marshal(teamCreate)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to create team body: %w", err)
|
||
|
|
}
|
||
|
|
req, err := http.NewRequest(http.MethodPost, teamPrefix, bytes.NewReader(nameJson))
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to create POST request for %s name %s: %w",
|
||
|
|
req.URL.String(), name, err)
|
||
|
|
}
|
||
|
|
resp, err := c.do(req, nil)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to POST %s name %s: %w",
|
||
|
|
req.URL.String(), name, err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode >= 300 {
|
||
|
|
b, _ := io.ReadAll(resp.Body)
|
||
|
|
return nil, fmt.Errorf("failed to create team %s: %s %s", name, resp.Status, string(b))
|
||
|
|
}
|
||
|
|
|
||
|
|
var newTeam TeamGetResponse
|
||
|
|
b, err := io.ReadAll(resp.Body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to read team response: %w", err)
|
||
|
|
}
|
||
|
|
err = json.Unmarshal(b, &newTeam)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return &newTeam, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// GetTeam returns the team with the provided ID.
|
||
|
|
func (c *FleetDMClient) GetTeam(id int64) (*TeamGetResponse, error) {
|
||
|
|
url := teamPrefix + "/" + strconv.FormatInt(id, 10)
|
||
|
|
req, err := http.NewRequest(http.MethodGet, url, nil)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to create GET request for %s: %w",
|
||
|
|
url, err)
|
||
|
|
}
|
||
|
|
resp, err := c.do(req, nil)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to GET %s: %w", url, err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode != http.StatusOK {
|
||
|
|
b, _ := io.ReadAll(resp.Body)
|
||
|
|
return nil, fmt.Errorf("failed to get team %d: %s %s", id, resp.Status, string(b))
|
||
|
|
}
|
||
|
|
|
||
|
|
var team TeamGetResponse
|
||
|
|
b, err := io.ReadAll(resp.Body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to read team response: %w", err)
|
||
|
|
}
|
||
|
|
err = json.Unmarshal(b, &team)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||
|
|
}
|
||
|
|
return &team, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// UpdateTeam updates the team with the provided ID with the provided name and description.
|
||
|
|
func (c *FleetDMClient) UpdateTeam(id int64, name, description *string) (*TeamGetResponse, error) {
|
||
|
|
if name == nil && description == nil {
|
||
|
|
return nil, fmt.Errorf("both name and description are nil")
|
||
|
|
}
|
||
|
|
|
||
|
|
url := teamPrefix + "/" + strconv.FormatInt(id, 10)
|
||
|
|
var teamUpdate TeamCreate
|
||
|
|
if name != nil {
|
||
|
|
teamUpdate.Name = *name
|
||
|
|
}
|
||
|
|
if description != nil {
|
||
|
|
teamUpdate.Description = *description
|
||
|
|
}
|
||
|
|
updateJson, err := json.Marshal(teamUpdate)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to update team body request: %w", err)
|
||
|
|
}
|
||
|
|
req, err := http.NewRequest(http.MethodPatch, url, bytes.NewReader(updateJson))
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to create PATCH request for %s body %s: %w",
|
||
|
|
url, updateJson, err)
|
||
|
|
}
|
||
|
|
resp, err := c.do(req, nil)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to PATCH %s body %s: %w",
|
||
|
|
url, updateJson, err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode >= 300 {
|
||
|
|
b, _ := io.ReadAll(resp.Body)
|
||
|
|
return nil, fmt.Errorf("failed PATCH %s body %s: %s %s",
|
||
|
|
url, updateJson, resp.Status, string(b))
|
||
|
|
}
|
||
|
|
|
||
|
|
var newTeam TeamGetResponse
|
||
|
|
b, err := io.ReadAll(resp.Body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to read team response: %w", err)
|
||
|
|
}
|
||
|
|
err = json.Unmarshal(b, &newTeam)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to decode response: %w", err)
|
||
|
|
}
|
||
|
|
|
||
|
|
return &newTeam, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// UpdateAgentOptions pretends that the agent options are a string, when it's really actually json.
|
||
|
|
// Strangely the body that comes back is a team, not just the agent options.
|
||
|
|
func (c *FleetDMClient) UpdateAgentOptions(id int64, ao string) (*TeamGetResponse, error) {
|
||
|
|
|
||
|
|
// First verify it's actually json.
|
||
|
|
valid := json.Valid([]byte(ao))
|
||
|
|
if !valid {
|
||
|
|
return nil, fmt.Errorf("agent_options isn't json: %s", ao)
|
||
|
|
}
|
||
|
|
|
||
|
|
aoUrl := teamPrefix + "/" + strconv.FormatInt(id, 10) + "/" + "agent_options"
|
||
|
|
req, err := http.NewRequest(http.MethodPost, aoUrl, strings.NewReader(ao))
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to create agent_options POST request for %s id %d: %w",
|
||
|
|
teamPrefix, id, err)
|
||
|
|
}
|
||
|
|
resp, err := c.do(req, nil)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to POST agent_options %s id %d: %w",
|
||
|
|
teamPrefix, id, err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode >= 300 {
|
||
|
|
b, _ := io.ReadAll(resp.Body)
|
||
|
|
return nil, fmt.Errorf("failed to modify agent_options %d: %s %s", id, resp.Status, string(b))
|
||
|
|
}
|
||
|
|
|
||
|
|
var team TeamGetResponse
|
||
|
|
b, err := io.ReadAll(resp.Body)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to read team response: %w", err)
|
||
|
|
}
|
||
|
|
err = json.Unmarshal(b, &team)
|
||
|
|
if err != nil {
|
||
|
|
return nil, fmt.Errorf("failed to decode agent_options team response: %w", err)
|
||
|
|
}
|
||
|
|
return &team, nil
|
||
|
|
}
|
||
|
|
|
||
|
|
// DeleteTeam deletes the team with the provided ID.
|
||
|
|
func (c *FleetDMClient) DeleteTeam(id int64) error {
|
||
|
|
url := teamPrefix + "/" + strconv.FormatInt(id, 10)
|
||
|
|
req, err := http.NewRequest(http.MethodDelete, url, nil)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to create DELETE request for %s: %w", url, err)
|
||
|
|
}
|
||
|
|
resp, err := c.do(req, nil)
|
||
|
|
if err != nil {
|
||
|
|
return fmt.Errorf("failed to DELETE %s: %w", url, err)
|
||
|
|
}
|
||
|
|
defer resp.Body.Close()
|
||
|
|
|
||
|
|
if resp.StatusCode >= 300 {
|
||
|
|
return fmt.Errorf("failed to delete team %d: %s", id, resp.Status)
|
||
|
|
}
|
||
|
|
|
||
|
|
return nil
|
||
|
|
}
|