Files
fleet/tools/fleet-mcp/fleet_integration.go
T

1025 lines
31 KiB
Go

package main
import (
"bytes"
"crypto/tls"
"crypto/x509"
"encoding/json"
"fmt"
"io"
"net/http"
"net/url"
"os"
"strings"
"time"
"github.com/sirupsen/logrus"
)
// FleetClient represents a client for interacting with Fleet API
type FleetClient struct {
baseURL string
apiKey string
httpClient *http.Client
}
// PlatformBreakdown represents platform distribution data
type PlatformBreakdown struct {
MacOS int `json:"macos"`
Windows int `json:"windows"`
Linux int `json:"linux"`
ChromeOS int `json:"chromeos"`
IOS int `json:"ios"`
IPadOS int `json:"ipados"`
Android int `json:"android"`
Other int `json:"other"`
Total int `json:"total"`
}
// PolicyCompliance represents policy compliance data
type PolicyCompliance struct {
PolicyID string `json:"policy_id"`
PolicyName string `json:"policy_name"`
Total int `json:"total"`
Compliant int `json:"compliant"`
NonCompliant int `json:"non_compliant"`
}
// VulnerabilityImpact represents vulnerability impact data
type VulnerabilityImpact struct {
CVEID string `json:"cve_id"`
TotalSystems int `json:"total_systems"`
ImpactedSystems int `json:"impacted_systems"`
}
// AggregateResponse represents a consistent response format for aggregations
type AggregateResponse struct {
Count int `json:"count"`
Data interface{} `json:"data"`
}
// NewFleetClient creates a new Fleet client.
// tlsSkipVerify disables certificate verification (unsafe; use only in dev/test).
// caFile, if non-empty, is a path to a PEM-encoded CA certificate to trust (for self-signed certs).
func NewFleetClient(baseURL, apiKey string, tlsSkipVerify bool, caFile string) *FleetClient {
tlsCfg := &tls.Config{}
if tlsSkipVerify && caFile != "" {
logrus.Fatalf("conflicting TLS settings: tlsSkipVerify and caFile are mutually exclusive — use one or the other, not both")
}
if tlsSkipVerify {
logrus.Warn("TLS certificate verification is disabled — do not use in production")
tlsCfg.InsecureSkipVerify = true //nolint:gosec
} else if caFile != "" {
pemData, err := os.ReadFile(caFile)
if err != nil {
logrus.Fatalf("failed to read CA certificate file %s: %v", caFile, err)
}
certPool := x509.NewCertPool()
if !certPool.AppendCertsFromPEM(pemData) {
logrus.Fatalf("failed to parse CA certificate from %s", caFile)
}
tlsCfg.RootCAs = certPool
logrus.Infof("loaded custom CA certificate from %s", caFile)
}
transport := &http.Transport{TLSClientConfig: tlsCfg}
return &FleetClient{
baseURL: baseURL,
apiKey: apiKey,
httpClient: &http.Client{
Timeout: 30 * time.Second,
Transport: transport,
},
}
}
// HostLabel represents a label attached to a host (Fleet returns objects, not plain strings)
type HostLabel struct {
ID uint `json:"id"`
Name string `json:"name"`
}
// Endpoint represents a Fleet endpoint
type Endpoint struct {
ID uint `json:"id"`
Name string `json:"hostname"`
DisplayName string `json:"display_name"`
ComputerName string `json:"computer_name"`
Status string `json:"status"`
LastSeen int64 `json:"last_seen"`
Platform string `json:"platform"`
Version string `json:"osquery_version"`
TeamID *uint `json:"team_id"`
TeamName string `json:"team_name"`
Labels []HostLabel `json:"labels"`
}
// Query represents a Fleet query
type Query struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
SQL string `json:"sql"`
Platform string `json:"platform"`
Created int64 `json:"created"`
}
// Policy represents a Fleet policy
type Policy struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Platform string `json:"platform"`
PassingHostCount int `json:"passing_host_count"`
FailingHostCount int `json:"failing_host_count"`
}
// Label represents a Fleet label
type Label struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
Created int64 `json:"created"`
}
// Team represents a Fleet team
type Team struct {
ID uint `json:"id"`
Name string `json:"name"`
Description string `json:"description"`
}
// AdHocQueryRequest is the body for single-host ad hoc queries
type AdHocQueryRequest struct {
Query string `json:"query"`
}
// AdHocQueryResponse is the response from a single-host ad hoc query
type AdHocQueryResponse struct {
HostID uint `json:"host_id"`
Query string `json:"query"`
Status string `json:"status"`
Error *string `json:"error"`
Rows []map[string]interface{} `json:"rows"`
}
// MultiQueryRunRequest is the body for running a saved query against multiple hosts
type MultiQueryRunRequest struct {
HostIDs []uint `json:"host_ids,omitempty"`
}
// LiveQueryHostResult is a single host's result from a multi-host query run
type LiveQueryHostResult struct {
HostID uint `json:"host_id"`
Rows []map[string]interface{} `json:"rows"`
Error *string `json:"error"`
}
// MultiQueryRunResponse is the response from POST /api/v1/fleet/queries/:id/run
type MultiQueryRunResponse struct {
QueryID uint `json:"query_id"`
TargetedHostCount int `json:"targeted_host_count"`
RespondedHostCount int `json:"responded_host_count"`
Results []LiveQueryHostResult `json:"results"`
}
// LiveQueryResult is a unified result returned from RunLiveQuery
type LiveQueryResult struct {
TargetedHostCount int `json:"targeted_host_count"`
RespondedHostCount int `json:"responded_host_count"`
Results []map[string]interface{} `json:"results"`
}
// CreateQueryRequest represents the payload for creating a saved query
type CreateQueryRequest struct {
Name string `json:"name"`
Description string `json:"description,omitempty"`
Query string `json:"query"`
Platform string `json:"platform,omitempty"`
}
// normalizePlatform normalizes platform input to Fleet's canonical platform string.
func normalizePlatform(p string) string {
switch strings.ToLower(strings.TrimSpace(p)) {
case "macos", "mac", "osx", "darwin":
return "darwin"
case "windows":
return "windows"
case "linux", "ubuntu", "centos", "rhel", "debian", "fedora", "amzn":
return "linux"
case "chromeos", "chrome":
return "chrome"
default:
return strings.ToLower(p)
}
}
// matchesPlatform checks if a host's platform matches the target platform.
func matchesPlatform(hostPlatform, targetPlatform string) bool {
hp := strings.ToLower(hostPlatform)
if targetPlatform == "linux" {
return hp == "linux" || hp == "ubuntu" || hp == "centos" || hp == "rhel" || hp == "debian" || hp == "fedora" || hp == "amzn"
}
return hp == targetPlatform
}
// platformToBuiltinLabel maps user-facing platform names to Fleet's built-in label names.
func platformToBuiltinLabel(platform string) string {
switch strings.ToLower(strings.TrimSpace(platform)) {
case "macos", "darwin", "mac", "osx":
return "macOS"
case "windows":
return "MS Windows"
case "linux":
return "All Linux"
case "chromeos", "chrome":
return "chrome"
default:
return ""
}
}
// GetEndpoints retrieves endpoints from Fleet with server-side pagination.
// Pass 0 for perPage to use the Fleet API default.
func (fc *FleetClient) GetEndpoints(perPage int) ([]Endpoint, error) {
params := url.Values{}
params.Set("populate_labels", "true")
if perPage > 0 {
params.Set("per_page", fmt.Sprintf("%d", perPage))
}
endpoint := "/api/v1/fleet/hosts?" + params.Encode()
resp, err := fc.makeFleetRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get endpoints: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get endpoints: status code %d", resp.StatusCode)
}
var result struct {
Hosts []Endpoint `json:"hosts"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode endpoints response: %w", err)
}
return result.Hosts, nil
}
// GetHostByIdentifier fetches full host details (including labels) by hostname, UUID, or serial.
// Uses GET /api/v1/fleet/hosts/identifier/:identifier which returns complete host and label data.
// Note: GetEndpoints already requests labels via populate_labels=true; this method is for targeted lookups of a single host.
func (fc *FleetClient) GetHostByIdentifier(identifier string) (*Endpoint, error) {
endpointPath := fmt.Sprintf("/api/v1/fleet/hosts/identifier/%s", url.PathEscape(identifier))
resp, err := fc.makeFleetRequest("GET", endpointPath, nil)
if err != nil {
return nil, fmt.Errorf("failed to get host: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode == http.StatusNotFound {
return nil, fmt.Errorf("host not found: %s", identifier)
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get host: status code %d", resp.StatusCode)
}
var result struct {
Host Endpoint `json:"host"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode host response: %w", err)
}
return &result.Host, nil
}
// GetQueries retrieves global and all team-specific queries from Fleet.
func (fc *FleetClient) GetQueries() ([]Query, error) {
resp, err := fc.makeFleetRequest("GET", "/api/v1/fleet/reports", nil)
if err != nil {
return nil, fmt.Errorf("failed to get queries: %w", err)
}
defer resp.Body.Close()
var result struct {
Queries []Query `json:"queries"`
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get queries: status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode queries: %w", err)
}
teams, err := fc.GetTeams()
if err != nil {
logrus.Warnf("skipping team queries: %v", err)
return result.Queries, nil
}
for _, team := range teams {
teamResp, err := fc.makeFleetRequest("GET", fmt.Sprintf("/api/v1/fleet/reports?team_id=%d", team.ID), nil)
if err != nil {
logrus.Warnf("team %d queries error: %v", team.ID, err)
continue
}
if teamResp.StatusCode == http.StatusOK {
var tr struct {
Queries []Query `json:"queries"`
}
if err := json.NewDecoder(teamResp.Body).Decode(&tr); err == nil {
for i := range tr.Queries {
tr.Queries[i].Name = fmt.Sprintf("[%s] %s", team.Name, tr.Queries[i].Name)
}
result.Queries = append(result.Queries, tr.Queries...)
}
}
teamResp.Body.Close()
}
return result.Queries, nil
}
// GetPolicies retrieves global and all team-specific policies from Fleet.
func (fc *FleetClient) GetPolicies() ([]Policy, error) {
resp, err := fc.makeFleetRequest("GET", "/api/v1/fleet/global/policies", nil)
if err != nil {
return nil, fmt.Errorf("failed to get policies: %w", err)
}
defer resp.Body.Close()
var result struct {
Policies []Policy `json:"policies"`
}
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get policies: status %d", resp.StatusCode)
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode policies: %w", err)
}
teams, err := fc.GetTeams()
if err != nil {
logrus.Warnf("skipping team policies: %v", err)
return result.Policies, nil
}
for _, team := range teams {
teamResp, err := fc.makeFleetRequest("GET", fmt.Sprintf("/api/v1/fleet/teams/%d/policies", team.ID), nil)
if err != nil {
logrus.Warnf("team %d policies error: %v", team.ID, err)
continue
}
if teamResp.StatusCode == http.StatusOK {
var tr struct {
Policies []Policy `json:"policies"`
}
if err := json.NewDecoder(teamResp.Body).Decode(&tr); err == nil {
for i := range tr.Policies {
tr.Policies[i].Name = fmt.Sprintf("[%s] %s", team.Name, tr.Policies[i].Name)
}
result.Policies = append(result.Policies, tr.Policies...)
}
}
teamResp.Body.Close()
}
return result.Policies, nil
}
// GetLabels retrieves all labels from Fleet
func (fc *FleetClient) GetLabels() ([]Label, error) {
endpoint := "/api/v1/fleet/labels"
resp, err := fc.makeFleetRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get labels: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get labels: status code %d", resp.StatusCode)
}
var result struct {
Labels []Label `json:"labels"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode labels response: %w", err)
}
return result.Labels, nil
}
// GetFleetConfig retrieves the Fleet server configuration.
func (fc *FleetClient) GetFleetConfig() (map[string]interface{}, error) {
resp, err := fc.makeFleetRequest("GET", "/api/v1/fleet/config", nil)
if err != nil {
return nil, fmt.Errorf("failed to get fleet config: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get fleet config: status code %d", resp.StatusCode)
}
var result map[string]interface{}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode fleet config: %w", err)
}
return result, nil
}
// GetEndpointsWithAggregations retrieves all endpoints and performs aggregations
func (fc *FleetClient) GetEndpointsWithAggregations() (*AggregateResponse, error) {
endpoints, err := fc.GetEndpoints(0)
if err != nil {
return nil, err
}
// Count by platform
platformBreakdown := PlatformBreakdown{}
for _, endpoint := range endpoints {
switch endpoint.Platform {
case "darwin":
platformBreakdown.MacOS++
case "windows":
platformBreakdown.Windows++
case "linux", "ubuntu", "centos", "rhel", "debian", "fedora", "amzn":
platformBreakdown.Linux++
case "chrome":
platformBreakdown.ChromeOS++
case "ios":
platformBreakdown.IOS++
case "ipados":
platformBreakdown.IPadOS++
case "android":
platformBreakdown.Android++
default:
platformBreakdown.Other++
}
}
platformBreakdown.Total = len(endpoints)
return &AggregateResponse{
Count: len(endpoints),
Data: map[string]interface{}{
"platform_breakdown": platformBreakdown,
"total_count": len(endpoints),
},
}, nil
}
// GetTeams retrieves all teams from Fleet
func (fc *FleetClient) GetTeams() ([]Team, error) {
endpoint := "/api/v1/fleet/teams"
resp, err := fc.makeFleetRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get teams: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get teams: status code %d", resp.StatusCode)
}
var result struct {
Teams []Team `json:"teams"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode teams response: %w", err)
}
return result.Teams, nil
}
// GetHostCount retrieves the total host count without fetching all host data.
func (fc *FleetClient) GetHostCount() (int, error) {
resp, err := fc.makeFleetRequest("GET", "/api/v1/fleet/hosts/count", nil)
if err != nil {
return 0, fmt.Errorf("failed to get host count: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return 0, fmt.Errorf("failed to get host count: status %d", resp.StatusCode)
}
var result struct {
Count int `json:"count"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return 0, fmt.Errorf("failed to decode host count response: %w", err)
}
return result.Count, nil
}
// getTeamHosts returns all hosts belonging to a specific team.
func (fc *FleetClient) getTeamHosts(teamID uint) ([]Endpoint, error) {
endpointPath := fmt.Sprintf("/api/v1/fleet/hosts?team_id=%d", teamID)
resp, err := fc.makeFleetRequest("GET", endpointPath, nil)
if err != nil {
return nil, fmt.Errorf("failed to get team hosts: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get team hosts: status %d", resp.StatusCode)
}
var result struct {
Hosts []Endpoint `json:"hosts"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode team hosts: %w", err)
}
return result.Hosts, nil
}
// resolveTeamNames resolves team names to team IDs using exact case-insensitive
// matching. On failure, lists available teams so the caller can retry.
func (fc *FleetClient) resolveTeamNames(teamNames []string) ([]uint, error) {
teams, err := fc.GetTeams()
if err != nil {
return nil, fmt.Errorf("failed to get teams: %w", err)
}
teamMap := make(map[string]uint)
var availableNames []string
for _, t := range teams {
teamMap[strings.ToLower(t.Name)] = t.ID
availableNames = append(availableNames, t.Name)
}
var ids []uint
for _, name := range teamNames {
id, ok := teamMap[strings.ToLower(strings.TrimSpace(name))]
if !ok {
return nil, fmt.Errorf("fleet not found: %q (available fleets: %s)", name, strings.Join(availableNames, ", "))
}
ids = append(ids, id)
}
return ids, nil
}
// GetEndpointsWithFilters retrieves endpoints from Fleet with optional server-side filters.
func (fc *FleetClient) GetEndpointsWithFilters(teamName, platform, status string, perPage int) ([]Endpoint, error) {
params := url.Values{}
params.Set("populate_labels", "true")
if perPage > 0 {
params.Set("per_page", fmt.Sprintf("%d", perPage))
}
if teamName != "" {
teamIDs, err := fc.resolveTeamNames([]string{teamName})
if err != nil {
return nil, fmt.Errorf("failed to resolve fleet: %w", err)
}
params.Set("team_id", fmt.Sprintf("%d", teamIDs[0]))
}
if platform != "" {
params.Set("platform", normalizePlatform(platform))
}
if status != "" {
params.Set("status", status)
}
endpoint := "/api/v1/fleet/hosts?" + params.Encode()
resp, err := fc.makeFleetRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get filtered endpoints: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get filtered endpoints: status %d", resp.StatusCode)
}
var result struct {
Hosts []Endpoint `json:"hosts"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode filtered endpoints response: %w", err)
}
return result.Hosts, nil
}
// GetPolicyCompliance retrieves policy compliance data
func (fc *FleetClient) GetPolicyCompliance(policyID string) (*PolicyCompliance, error) {
endpoint := fmt.Sprintf("/api/v1/fleet/global/policies/%s", url.PathEscape(policyID))
resp, err := fc.makeFleetRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get policy compliance: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get policy compliance: status code %d", resp.StatusCode)
}
// The response format for policies typically includes hosts_count, passing_host_count, failing_host_count
var result struct {
Policy struct {
ID uint `json:"id"`
Name string `json:"name"`
PassingHostCount int `json:"passing_host_count"`
FailingHostCount int `json:"failing_host_count"`
} `json:"policy"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode policy compliance response: %w", err)
}
total := result.Policy.PassingHostCount + result.Policy.FailingHostCount
return &PolicyCompliance{
PolicyID: fmt.Sprint(result.Policy.ID),
PolicyName: result.Policy.Name,
Total: total,
Compliant: result.Policy.PassingHostCount,
NonCompliant: result.Policy.FailingHostCount,
}, nil
}
// GetVulnerabilityImpact retrieves vulnerability impact data
func (fc *FleetClient) GetVulnerabilityImpact(cveID string) (*VulnerabilityImpact, error) {
endpoint := fmt.Sprintf("/api/v1/fleet/vulnerabilities/%s", url.PathEscape(cveID))
resp, err := fc.makeFleetRequest("GET", endpoint, nil)
if err != nil {
return nil, fmt.Errorf("failed to get vulnerability impact: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("failed to get vulnerability impact: status code %d", resp.StatusCode)
}
// Vulnerability response format has hosts_count
var result struct {
Vulnerability struct {
CVE string `json:"cve"`
HostsCount int `json:"hosts_count"`
} `json:"vulnerability"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode vulnerability impact response: %w", err)
}
totalSystems := 0
if count, err := fc.GetHostCount(); err == nil {
totalSystems = count
}
return &VulnerabilityImpact{
CVEID: result.Vulnerability.CVE,
TotalSystems: totalSystems,
ImpactedSystems: result.Vulnerability.HostsCount,
}, nil
}
// CreateSavedQuery creates a new saved query in Fleet
func (fc *FleetClient) CreateSavedQuery(name, description, sql, platform string) (*Query, error) {
endpoint := "/api/v1/fleet/reports"
reqBody := CreateQueryRequest{
Name: name,
Description: description,
Query: sql,
Platform: platform,
}
resp, err := fc.makeFleetRequest("POST", endpoint, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create saved query: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK && resp.StatusCode != http.StatusCreated {
bodyBytes, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("failed to create saved query: status code %d, body: %s", resp.StatusCode, string(bodyBytes))
}
var result struct {
Query Query `json:"query"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, fmt.Errorf("failed to decode created query response: %w", err)
}
return &result.Query, nil
}
// RunLiveQuery executes a live query against the specified targets using Fleet's modern REST API.
// Uses targeted API calls per dimension to avoid fetching all hosts.
// For single hosts: uses per-host ad hoc endpoint (POST /api/v1/fleet/hosts/:id/query).
// For multiple hosts: creates a temp saved query → runs by ID → deletes it.
func (fc *FleetClient) RunLiveQuery(sql string, hostnames, labels, platforms, teams []string) (*LiveQueryResult, error) {
hostIDs, nameByID, err := fc.resolveTargetHosts(hostnames, labels, platforms, teams)
if err != nil {
return nil, fmt.Errorf("failed to resolve target hosts: %w", err)
}
if len(hostIDs) == 0 {
return nil, fmt.Errorf("no matching hosts found for the provided targets")
}
// Deduplicate
seen := make(map[uint]bool)
unique := hostIDs[:0]
for _, id := range hostIDs {
if !seen[id] {
seen[id] = true
unique = append(unique, id)
}
}
hostIDs = unique
if len(hostIDs) == 1 {
return fc.runAdHocSingleHost(hostIDs[0], sql, nameByID)
}
return fc.runMultiHostQuery(hostIDs, sql, nameByID)
}
// resolveTargetHosts resolves targeting parameters into host IDs and a name lookup map.
// Uses targeted API calls to avoid fetching all hosts.
// Returns an error listing any selectors that could not be resolved.
func (fc *FleetClient) resolveTargetHosts(hostnames, labels, platforms, teams []string) ([]uint, map[uint]Endpoint, error) {
var hostIDs []uint
nameByID := make(map[uint]Endpoint)
var unresolved []string
// Hostname targeting: use GetHostByIdentifier per host (no bulk fetch)
if len(hostnames) > 0 {
for _, hostname := range hostnames {
host, err := fc.GetHostByIdentifier(strings.TrimSpace(hostname))
if err != nil {
logrus.Warnf("host lookup failed for %q: %v", hostname, err)
unresolved = append(unresolved, "hostname:"+strings.TrimSpace(hostname))
continue
}
hostIDs = append(hostIDs, host.ID)
nameByID[host.ID] = *host
}
}
// Label targeting: use getLabelHosts per label (no bulk fetch)
if len(labels) > 0 {
fleetLabels, err := fc.GetLabels()
if err != nil {
return nil, nil, fmt.Errorf("failed to get labels: %w", err)
}
labelTargets := make(map[string]bool)
for _, l := range labels {
labelTargets[strings.ToLower(strings.TrimSpace(l))] = true
}
resolvedLabels := make(map[string]bool)
for _, l := range fleetLabels {
key := strings.ToLower(l.Name)
if !labelTargets[key] {
continue
}
members, err := fc.getLabelHosts(l.ID)
if err != nil {
logrus.Warnf("label host lookup failed for %q: %v", l.Name, err)
continue
}
resolvedLabels[key] = true
for _, m := range members {
hostIDs = append(hostIDs, m.ID)
nameByID[m.ID] = m
}
}
for _, l := range labels {
if !resolvedLabels[strings.ToLower(strings.TrimSpace(l))] {
unresolved = append(unresolved, "label:"+strings.TrimSpace(l))
}
}
}
// Team targeting: use getTeamHosts per team (no bulk fetch)
if len(teams) > 0 {
teamIDs, err := fc.resolveTeamNames(teams)
if err != nil {
return nil, nil, fmt.Errorf("failed to resolve teams: %w", err)
}
for i, tid := range teamIDs {
members, err := fc.getTeamHosts(tid)
if err != nil {
logrus.Warnf("team host lookup failed for %q (ID %d): %v", teams[i], tid, err)
unresolved = append(unresolved, "team:"+strings.TrimSpace(teams[i]))
continue
}
for _, m := range members {
hostIDs = append(hostIDs, m.ID)
nameByID[m.ID] = m
}
}
}
// Platform targeting: use Fleet's built-in platform labels via getLabelHosts
if len(platforms) > 0 {
fleetLabels, err := fc.GetLabels()
if err != nil {
return nil, nil, fmt.Errorf("failed to get labels for platform resolution: %w", err)
}
for _, p := range platforms {
labelName := platformToBuiltinLabel(strings.TrimSpace(p))
if labelName == "" {
logrus.Warnf("unknown platform %q, skipping", p)
unresolved = append(unresolved, "platform:"+strings.TrimSpace(p))
continue
}
found := false
for _, l := range fleetLabels {
if strings.EqualFold(l.Name, labelName) {
members, err := fc.getLabelHosts(l.ID)
if err != nil {
logrus.Warnf("platform label host lookup failed for %q: %v", labelName, err)
unresolved = append(unresolved, "platform:"+strings.TrimSpace(p))
continue
}
for _, m := range members {
hostIDs = append(hostIDs, m.ID)
nameByID[m.ID] = m
}
found = true
break
}
}
if !found {
logrus.Warnf("built-in label %q not found in Fleet, skipping platform %q", labelName, p)
unresolved = append(unresolved, "platform:"+strings.TrimSpace(p))
}
}
}
if len(unresolved) > 0 {
return nil, nil, fmt.Errorf("unresolved selectors: %s", strings.Join(unresolved, ", "))
}
return hostIDs, nameByID, nil
}
// getLabelHosts returns the hosts belonging to a Fleet label.
func (fc *FleetClient) getLabelHosts(labelID uint) ([]Endpoint, error) {
endpointPath := fmt.Sprintf("/api/v1/fleet/labels/%d/hosts", labelID)
resp, err := fc.makeFleetRequest("GET", endpointPath, nil)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, fmt.Errorf("label hosts status %d", resp.StatusCode)
}
var result struct {
Hosts []Endpoint `json:"hosts"`
}
if err := json.NewDecoder(resp.Body).Decode(&result); err != nil {
return nil, err
}
return result.Hosts, nil
}
// runAdHocSingleHost uses POST /api/v1/fleet/hosts/:id/query (Fleet 4.43+ synchronous REST).
func (fc *FleetClient) runAdHocSingleHost(hostID uint, sql string, endpointByID map[uint]Endpoint) (*LiveQueryResult, error) {
endpointPath := fmt.Sprintf("/api/v1/fleet/hosts/%d/query", hostID)
resp, err := fc.makeFleetRequest("POST", endpointPath, AdHocQueryRequest{Query: sql})
if err != nil {
return nil, fmt.Errorf("ad hoc query failed: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("ad hoc query failed with status %d: %s", resp.StatusCode, string(body))
}
var adHoc AdHocQueryResponse
if err := json.NewDecoder(resp.Body).Decode(&adHoc); err != nil {
return nil, fmt.Errorf("failed to decode ad hoc query response: %w", err)
}
hostName := ""
if ep, ok := endpointByID[hostID]; ok {
hostName = ep.DisplayName
if hostName == "" {
hostName = ep.Name
}
}
resultRow := map[string]interface{}{
"host_id": hostID,
"host_name": hostName,
"status": adHoc.Status,
"rows": adHoc.Rows,
}
if adHoc.Error != nil {
resultRow["error"] = *adHoc.Error
}
respondedCount := 0
if adHoc.Status == "online" {
respondedCount = 1
}
return &LiveQueryResult{
TargetedHostCount: 1,
RespondedHostCount: respondedCount,
Results: []map[string]interface{}{resultRow},
}, nil
}
// runMultiHostQuery creates a temporary saved query, runs it by ID, then deletes it.
// Uses POST /api/v1/fleet/queries/:id/run (Fleet 4.43+ synchronous REST).
func (fc *FleetClient) runMultiHostQuery(hostIDs []uint, sql string, endpointByID map[uint]Endpoint) (*LiveQueryResult, error) {
tempName := fmt.Sprintf("fleet-mcp-temp-%d", time.Now().UnixMilli())
savedQuery, err := fc.CreateSavedQuery(tempName, "Temporary MCP live query", sql, "")
if err != nil {
return nil, fmt.Errorf("failed to create temporary query: %w", err)
}
defer func() {
delEndpoint := fmt.Sprintf("/api/v1/fleet/reports/id/%d", savedQuery.ID)
r, _ := fc.makeFleetRequest("DELETE", delEndpoint, nil)
if r != nil {
r.Body.Close()
}
}()
logrus.Infof("Created temp query ID=%d, running against %d hosts", savedQuery.ID, len(hostIDs))
runEndpoint := fmt.Sprintf("/api/v1/fleet/reports/%d/run", savedQuery.ID)
resp, err := fc.makeFleetRequest("POST", runEndpoint, MultiQueryRunRequest{HostIDs: hostIDs})
if err != nil {
return nil, fmt.Errorf("failed to run live query: %w", err)
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
body, _ := io.ReadAll(resp.Body)
return nil, fmt.Errorf("live query run failed with status %d: %s", resp.StatusCode, string(body))
}
var runResp MultiQueryRunResponse
if err := json.NewDecoder(resp.Body).Decode(&runResp); err != nil {
return nil, fmt.Errorf("failed to decode live query run response: %w", err)
}
var enriched []map[string]interface{}
for _, r := range runResp.Results {
row := map[string]interface{}{
"host_id": r.HostID,
"rows": r.Rows,
}
if ep, ok := endpointByID[r.HostID]; ok {
name := ep.DisplayName
if name == "" {
name = ep.Name
}
row["host_name"] = name
}
if r.Error != nil {
row["error"] = *r.Error
}
enriched = append(enriched, row)
}
return &LiveQueryResult{
TargetedHostCount: runResp.TargetedHostCount,
RespondedHostCount: runResp.RespondedHostCount,
Results: enriched,
}, nil
}
func (fc *FleetClient) makeFleetRequest(method, endpoint string, body interface{}) (*http.Response, error) {
url := fmt.Sprintf("%s%s", fc.baseURL, endpoint)
var reqBody io.Reader
if body != nil {
jsonBody, err := json.Marshal(body)
if err != nil {
return nil, fmt.Errorf("failed to marshal request body: %w", err)
}
reqBody = bytes.NewReader(jsonBody)
}
req, err := http.NewRequest(method, url, reqBody)
if err != nil {
return nil, fmt.Errorf("failed to create request: %w", err)
}
req.Header.Set("Content-Type", "application/json")
req.Header.Set("Authorization", "Bearer "+fc.apiKey)
logrus.Debugf("%s %s", method, endpoint)
resp, err := fc.httpClient.Do(req)
if err != nil {
return nil, fmt.Errorf("failed to make request to Fleet API: %w", err)
}
return resp, nil
}