mirror of
https://github.com/fleetdm/fleet
synced 2026-05-22 16:39:01 +00:00
for #31182 # Details This PR implements the "Action Required" state for Windows host disk encryption. This includes updates to reporting for: * disk encryption summary (`GET /fleet/disk_encryption`) * config profiles summary (`GET /configuration_profiles/summary`) * config profile status ( `GET /configuration_profiles/{profile_uuid}/status`) For disk encryption summary, the statuses are now determined according to [the rules in the Figma](https://www.figma.com/design/XbhlPuEJxQtOgTZW9EOJZp/-28133-Enforce-BitLocker-PIN?node-id=5484-928&t=JB13g8zQ2QDVEmPB-0). TL;DR if the criteria for "verified" or "verifying" are set, but a required PIN is not set, we report a host as "action required". For profiles, I followed what seems to be the existing pattern and set the profile status to "pending" if the disk encryption status is "action required". This is what we do for hosts with the "enforcing" or "removing enforcement" statuses. A lot of the changes in these files are due to the creation of the `fleet.DiskEncryptionConfig` struct to hold info about disk encryption config, and passing variables of that type to various functions instead of passing a `bool` to indicate whether encryption is enabled. Other than that, the functional changes are constrained to a few files. > Note: to get the "require bitlocker pin" UI, compile the front end with: ``` SHOW_BITLOCKER_PIN_OPTION=true NODE_ENV=development yarn run webpack --progress --watch ``` # Checklist for submitter If some of the following don't apply, delete the relevant line. - [ ] Changes file added for user-visible changes in `changes/`, `orbit/changes/` or `ee/fleetd-chrome/changes`. See [Changes files](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/guides/committing-changes.md#changes-files) for more information. Changelog will be added when feature is complete. - [X] Input data is properly validated, `SELECT *` is avoided, SQL injection is prevented (using placeholders for values in statements) ## Testing - [X] Added/updated automated tests - [X] Where appropriate, [automated tests simulate multiple hosts and test for host isolation](https://github.com/fleetdm/fleet/blob/main/docs/Contributing/reference/patterns-backend.md#unit-testing) (updates to one hosts's records do not affect another) - [ ] QA'd all new/changed functionality manually Could use some help testing this end-to-end. I was able to test the banners showing up correctly, but testing the Disk Encryption table requires some Windows-MDM-fu (I just get all zeroes). ## Database migrations - [X] Checked table schema to confirm autoupdate - [X] Checked schema for all modified table for columns that will auto-update timestamps during migration. - [X] Confirmed that updating the timestamps is acceptable, and will not cause unwanted side effects. - [X] Ensured the correct collation is explicitly set for character columns (`COLLATE utf8mb4_unicode_ci`).
2995 lines
93 KiB
Go
2995 lines
93 KiB
Go
package service
|
|
|
|
import (
|
|
"bytes"
|
|
"context"
|
|
"encoding/csv"
|
|
"encoding/json"
|
|
"errors"
|
|
"fmt"
|
|
"io"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"time"
|
|
|
|
"github.com/fleetdm/fleet/v4/server"
|
|
"github.com/fleetdm/fleet/v4/server/authz"
|
|
authzctx "github.com/fleetdm/fleet/v4/server/contexts/authz"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
|
hostctx "github.com/fleetdm/fleet/v4/server/contexts/host"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/license"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/logging"
|
|
"github.com/fleetdm/fleet/v4/server/contexts/viewer"
|
|
"github.com/fleetdm/fleet/v4/server/fleet"
|
|
"github.com/fleetdm/fleet/v4/server/mdm"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/apple/mobileconfig"
|
|
"github.com/fleetdm/fleet/v4/server/mdm/assets"
|
|
mdmlifecycle "github.com/fleetdm/fleet/v4/server/mdm/lifecycle"
|
|
"github.com/fleetdm/fleet/v4/server/ptr"
|
|
"github.com/fleetdm/fleet/v4/server/service/middleware/endpoint_utils"
|
|
"github.com/fleetdm/fleet/v4/server/worker"
|
|
"github.com/go-kit/log/level"
|
|
"github.com/gocarina/gocsv"
|
|
"github.com/google/uuid"
|
|
)
|
|
|
|
// HostDetailResponse is the response struct that contains the full host information
|
|
// with the HostDetail details.
|
|
type HostDetailResponse struct {
|
|
fleet.HostDetail
|
|
Status fleet.HostStatus `json:"status"`
|
|
DisplayText string `json:"display_text"`
|
|
DisplayName string `json:"display_name"`
|
|
Geolocation *fleet.GeoLocation `json:"geolocation,omitempty"`
|
|
}
|
|
|
|
func hostDetailResponseForHost(ctx context.Context, svc fleet.Service, host *fleet.HostDetail) (*HostDetailResponse, error) {
|
|
return &HostDetailResponse{
|
|
HostDetail: *host,
|
|
Status: host.Status(time.Now()),
|
|
DisplayText: host.Hostname,
|
|
DisplayName: host.DisplayName(),
|
|
Geolocation: svc.LookupGeoIP(ctx, host.PublicIP),
|
|
}, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// List Hosts
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type listHostsRequest struct {
|
|
Opts fleet.HostListOptions `url:"host_options"`
|
|
}
|
|
|
|
type listHostsResponse struct {
|
|
Hosts []fleet.HostResponse `json:"hosts"`
|
|
// Software is populated with the software version corresponding to the
|
|
// software_version_id (or software_id) filter if one is provided with the
|
|
// request (and it exists in the database). It is nil otherwise and absent of
|
|
// the JSON response payload.
|
|
Software *fleet.Software `json:"software,omitempty"`
|
|
// SoftwareTitle is populated with the title corresponding to the
|
|
// software_title_id filter if one is provided with the request (and it
|
|
// exists in the database). It is nil otherwise and absent of the JSON
|
|
// response payload.
|
|
SoftwareTitle *fleet.SoftwareTitle `json:"software_title,omitempty"`
|
|
// MDMSolution is populated with the MDM solution corresponding to the mdm_id
|
|
// filter if one is provided with the request (and it exists in the
|
|
// database). It is nil otherwise and absent of the JSON response payload.
|
|
MDMSolution *fleet.MDMSolution `json:"mobile_device_management_solution,omitempty"`
|
|
// MunkiIssue is populated with the munki issue corresponding to the
|
|
// munki_issue_id filter if one is provided with the request (and it exists
|
|
// in the database). It is nil otherwise and absent of the JSON response
|
|
// payload.
|
|
MunkiIssue *fleet.MunkiIssue `json:"munki_issue,omitempty"`
|
|
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listHostsResponse) Error() error { return r.Err }
|
|
|
|
func listHostsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*listHostsRequest)
|
|
|
|
var software *fleet.Software
|
|
if req.Opts.SoftwareVersionIDFilter != nil || req.Opts.SoftwareIDFilter != nil {
|
|
var err error
|
|
|
|
id := req.Opts.SoftwareVersionIDFilter
|
|
if id == nil {
|
|
id = req.Opts.SoftwareIDFilter
|
|
}
|
|
software, err = svc.SoftwareByID(ctx, *id, req.Opts.TeamFilter, false)
|
|
if err != nil && !fleet.IsNotFound(err) { // ignore not found, just return nil for the software in that case
|
|
return listHostsResponse{Err: err}, nil
|
|
}
|
|
}
|
|
|
|
var softwareTitle *fleet.SoftwareTitle
|
|
if req.Opts.SoftwareTitleIDFilter != nil {
|
|
var err error
|
|
|
|
softwareTitle, err = svc.SoftwareTitleByID(ctx, *req.Opts.SoftwareTitleIDFilter, req.Opts.TeamFilter)
|
|
if err != nil && !fleet.IsNotFound(err) { // ignore not found, just return nil for the software title in that case
|
|
return listHostsResponse{Err: err}, nil
|
|
}
|
|
}
|
|
|
|
var mdmSolution *fleet.MDMSolution
|
|
if req.Opts.MDMIDFilter != nil {
|
|
var err error
|
|
mdmSolution, err = svc.GetMDMSolution(ctx, *req.Opts.MDMIDFilter)
|
|
if err != nil && !fleet.IsNotFound(err) { // ignore not found, just return nil for the MDM solution in that case
|
|
return listHostsResponse{Err: err}, nil
|
|
}
|
|
}
|
|
|
|
var munkiIssue *fleet.MunkiIssue
|
|
if req.Opts.MunkiIssueIDFilter != nil {
|
|
var err error
|
|
munkiIssue, err = svc.GetMunkiIssue(ctx, *req.Opts.MunkiIssueIDFilter)
|
|
if err != nil && !fleet.IsNotFound(err) { // ignore not found, just return nil for the munki issue in that case
|
|
return listHostsResponse{Err: err}, nil
|
|
}
|
|
}
|
|
|
|
hosts, err := svc.ListHosts(ctx, req.Opts)
|
|
if err != nil {
|
|
return listHostsResponse{Err: err}, nil
|
|
}
|
|
|
|
hostResponses := make([]fleet.HostResponse, len(hosts))
|
|
for i, host := range hosts {
|
|
h := fleet.HostResponseForHost(ctx, svc, host)
|
|
hostResponses[i] = *h
|
|
|
|
if req.Opts.PopulateLabels {
|
|
labels, err := svc.ListLabelsForHost(ctx, h.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("failed to list labels for host %d", h.ID))
|
|
}
|
|
hostResponses[i].Labels = labels
|
|
}
|
|
}
|
|
|
|
return listHostsResponse{
|
|
Hosts: hostResponses,
|
|
Software: software,
|
|
SoftwareTitle: softwareTitle,
|
|
MDMSolution: mdmSolution,
|
|
MunkiIssue: munkiIssue,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetMDMSolution(ctx context.Context, mdmID uint) (*fleet.MDMSolution, error) {
|
|
// require list hosts permission to view this information
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
return svc.ds.GetMDMSolution(ctx, mdmID)
|
|
}
|
|
|
|
func (svc *Service) GetMunkiIssue(ctx context.Context, munkiIssueID uint) (*fleet.MunkiIssue, error) {
|
|
// require list hosts permission to view this information
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
return svc.ds.GetMunkiIssue(ctx, munkiIssueID)
|
|
}
|
|
|
|
func (svc *Service) ListHosts(ctx context.Context, opt fleet.HostListOptions) ([]*fleet.Host, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
|
|
|
|
// TODO(Sarah): Are we missing any other filters here?
|
|
premiumLicense := license.IsPremium(ctx)
|
|
if !premiumLicense {
|
|
// the low disk space filter is premium-only
|
|
opt.LowDiskSpaceFilter = nil
|
|
// the bootstrap package filter is premium-only
|
|
opt.MDMBootstrapPackageFilter = nil
|
|
// including vulnerability details on software is premium-only
|
|
opt.PopulateSoftwareVulnerabilityDetails = false
|
|
}
|
|
|
|
hosts, err := svc.ds.ListHosts(ctx, filter, opt)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// If issues are enabled, we need to remove the critical vulnerabilities count for non-premium license.
|
|
// If issues are disabled, we need to explicitly set the critical vulnerabilities count to 0 for premium license.
|
|
if !opt.DisableIssues && !premiumLicense {
|
|
// Remove critical vulnerabilities count if not premium license
|
|
for _, host := range hosts {
|
|
host.HostIssues.CriticalVulnerabilitiesCount = nil
|
|
}
|
|
} else if opt.DisableIssues && premiumLicense {
|
|
var zero uint64
|
|
for _, host := range hosts {
|
|
host.HostIssues.CriticalVulnerabilitiesCount = &zero
|
|
}
|
|
}
|
|
|
|
if opt.PopulateSoftware {
|
|
for _, host := range hosts {
|
|
if err = svc.ds.LoadHostSoftware(ctx, host, opt.PopulateSoftwareVulnerabilityDetails); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
}
|
|
|
|
if opt.PopulatePolicies {
|
|
for _, host := range hosts {
|
|
hp, err := svc.ds.ListPoliciesForHost(ctx, host)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("get policies for host %d", host.ID))
|
|
}
|
|
host.Policies = &hp
|
|
}
|
|
}
|
|
|
|
if opt.PopulateUsers {
|
|
for _, host := range hosts {
|
|
hu, err := svc.ds.ListHostUsers(ctx, host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, fmt.Sprintf("get users for host %d", host.ID))
|
|
}
|
|
host.Users = hu
|
|
}
|
|
}
|
|
|
|
return hosts, nil
|
|
}
|
|
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
// Delete Hosts
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
|
|
// These values are modified during testing.
|
|
var (
|
|
deleteHostsTimeout = 30 * time.Second
|
|
deleteHostsSkipAuthorization = false
|
|
)
|
|
|
|
type deleteHostsRequest struct {
|
|
IDs []uint `json:"ids"`
|
|
// Using a pointer to help determine whether an empty filter was passed, like: "filters":{}
|
|
Filters *map[string]interface{} `json:"filters"`
|
|
}
|
|
|
|
type deleteHostsResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
StatusCode int `json:"-"`
|
|
}
|
|
|
|
func (r deleteHostsResponse) Error() error { return r.Err }
|
|
|
|
// Status implements statuser interface to send out custom HTTP success codes.
|
|
func (r deleteHostsResponse) Status() int { return r.StatusCode }
|
|
|
|
func deleteHostsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteHostsRequest)
|
|
|
|
// Since bulk deletes can take a long time, after DeleteHostsTimeout, we will return a 202 (Accepted) status code
|
|
// and allow the delete operation to proceed.
|
|
var err error
|
|
deleteDone := make(chan bool, 1)
|
|
ctx = context.WithoutCancel(ctx) // to make sure DB operations don't get killed after we return a 202
|
|
go func() {
|
|
err = svc.DeleteHosts(ctx, req.IDs, req.Filters)
|
|
if err != nil {
|
|
// logging the error for future debug in case we already sent http.StatusAccepted
|
|
logging.WithErr(ctx, err)
|
|
}
|
|
deleteDone <- true
|
|
}()
|
|
select {
|
|
case <-deleteDone:
|
|
if err != nil {
|
|
return deleteHostsResponse{Err: err}, nil
|
|
}
|
|
return deleteHostsResponse{StatusCode: http.StatusOK}, nil
|
|
case <-time.After(deleteHostsTimeout):
|
|
if deleteHostsSkipAuthorization {
|
|
// Only called during testing.
|
|
svc.(validationMiddleware).Service.(*Service).authz.SkipAuthorization(ctx)
|
|
}
|
|
return deleteHostsResponse{StatusCode: http.StatusAccepted}, nil
|
|
}
|
|
}
|
|
|
|
func (svc *Service) DeleteHosts(ctx context.Context, ids []uint, filter *map[string]interface{}) error {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return err
|
|
}
|
|
|
|
opts, lid, err := hostListOptionsFromFilters(filter)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(ids) == 0 && lid == nil && opts == nil {
|
|
return &fleet.BadRequestError{Message: "list of ids or filters must be specified"}
|
|
}
|
|
|
|
if len(ids) > 0 && (lid != nil || (opts != nil && !opts.Empty())) {
|
|
return &fleet.BadRequestError{Message: "Cannot specify a list of ids and filters at the same time"}
|
|
}
|
|
|
|
doDelete := func(hostIDs []uint, hosts []*fleet.Host) error {
|
|
if err := svc.ds.DeleteHosts(ctx, hostIDs); err != nil {
|
|
return err
|
|
}
|
|
|
|
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger)
|
|
lifecycleErrs := []error{}
|
|
serialsWithErrs := []string{}
|
|
for _, host := range hosts {
|
|
if fleet.MDMSupported(host.Platform) {
|
|
if err := mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{
|
|
Action: mdmlifecycle.HostActionDelete,
|
|
Host: host,
|
|
Platform: host.Platform,
|
|
}); err != nil {
|
|
lifecycleErrs = append(lifecycleErrs, err)
|
|
serialsWithErrs = append(serialsWithErrs, host.HardwareSerial)
|
|
}
|
|
}
|
|
}
|
|
if len(lifecycleErrs) > 0 {
|
|
msg := fmt.Sprintf("failed to recreate pending host records for one or more MDM devices: %+v", serialsWithErrs)
|
|
return ctxerr.Wrap(ctx, errors.Join(lifecycleErrs...), msg)
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
if len(ids) > 0 {
|
|
if err := svc.checkWriteForHostIDs(ctx, ids); err != nil {
|
|
return err
|
|
}
|
|
|
|
hosts, err := svc.ds.ListHostsLiteByIDs(ctx, ids)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return doDelete(ids, hosts)
|
|
}
|
|
|
|
if opts == nil {
|
|
opts = &fleet.HostListOptions{}
|
|
}
|
|
opts.DisableIssues = true // don't check policies for hosts that are about to be deleted
|
|
hostIDs, _, hosts, err := svc.hostIDsAndNamesFromFilters(ctx, *opts, lid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if len(hostIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
err = svc.checkWriteForHostIDs(ctx, hostIDs)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
return doDelete(hostIDs, hosts)
|
|
}
|
|
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
// Count
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type countHostsRequest struct {
|
|
Opts fleet.HostListOptions `url:"host_options"`
|
|
LabelID *uint `query:"label_id,optional"`
|
|
}
|
|
|
|
type countHostsResponse struct {
|
|
Count int `json:"count"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r countHostsResponse) Error() error { return r.Err }
|
|
|
|
func countHostsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*countHostsRequest)
|
|
count, err := svc.CountHosts(ctx, req.LabelID, req.Opts)
|
|
if err != nil {
|
|
return countHostsResponse{Err: err}, nil
|
|
}
|
|
return countHostsResponse{Count: count}, nil
|
|
}
|
|
|
|
func (svc *Service) CountHosts(ctx context.Context, labelID *uint, opts fleet.HostListOptions) (int, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return svc.countHostFromFilters(ctx, labelID, opts)
|
|
}
|
|
|
|
func (svc *Service) countHostFromFilters(ctx context.Context, labelID *uint, opt fleet.HostListOptions) (int, error) {
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return 0, fleet.ErrNoContext
|
|
}
|
|
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
|
|
|
|
if !license.IsPremium(ctx) {
|
|
// the low disk space filter is premium-only
|
|
opt.LowDiskSpaceFilter = nil
|
|
}
|
|
|
|
var count int
|
|
var err error
|
|
if labelID != nil {
|
|
count, err = svc.ds.CountHostsInLabel(ctx, filter, *labelID, opt)
|
|
} else {
|
|
count, err = svc.ds.CountHosts(ctx, filter, opt)
|
|
}
|
|
if err != nil {
|
|
return 0, err
|
|
}
|
|
|
|
return count, nil
|
|
}
|
|
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
// Search
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type searchHostsRequest struct {
|
|
// MatchQuery is the query SQL
|
|
MatchQuery string `json:"query"`
|
|
// QueryID is the ID of a saved query to run (used to determine if this is a
|
|
// query that observers can run).
|
|
QueryID *uint `json:"query_id"`
|
|
// ExcludedHostIDs is the list of IDs selected on the caller side
|
|
// (e.g. the UI) that will be excluded from the returned payload.
|
|
ExcludedHostIDs []uint `json:"excluded_host_ids"`
|
|
}
|
|
|
|
type searchHostsResponse struct {
|
|
Hosts []*fleet.HostResponse `json:"hosts"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r searchHostsResponse) Error() error { return r.Err }
|
|
|
|
func searchHostsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*searchHostsRequest)
|
|
|
|
hosts, err := svc.SearchHosts(ctx, req.MatchQuery, req.QueryID, req.ExcludedHostIDs)
|
|
if err != nil {
|
|
return searchHostsResponse{Err: err}, nil
|
|
}
|
|
|
|
results := []*fleet.HostResponse{}
|
|
|
|
for _, h := range hosts {
|
|
results = append(results, fleet.HostResponseForHostCheap(h))
|
|
}
|
|
|
|
return searchHostsResponse{
|
|
Hosts: results,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) SearchHosts(ctx context.Context, matchQuery string, queryID *uint, excludedHostIDs []uint) ([]*fleet.Host, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
|
|
includeObserver := false
|
|
if queryID != nil {
|
|
canRun, err := svc.ds.ObserverCanRunQuery(ctx, *queryID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
includeObserver = canRun
|
|
}
|
|
|
|
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: includeObserver}
|
|
|
|
results := []*fleet.Host{}
|
|
|
|
hosts, err := svc.ds.SearchHosts(ctx, filter, matchQuery, excludedHostIDs...)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
results = append(results, hosts...)
|
|
|
|
return results, nil
|
|
}
|
|
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
// Get host
|
|
/////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostRequest struct {
|
|
ID uint `url:"id"`
|
|
ExcludeSoftware bool `query:"exclude_software,optional"`
|
|
}
|
|
|
|
type getHostResponse struct {
|
|
Host *HostDetailResponse `json:"host"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getHostResponse) Error() error { return r.Err }
|
|
|
|
func getHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getHostRequest)
|
|
opts := fleet.HostDetailOptions{
|
|
IncludeCVEScores: false,
|
|
IncludePolicies: true, // intentionally true to preserve existing behavior,
|
|
ExcludeSoftware: req.ExcludeSoftware,
|
|
}
|
|
host, err := svc.GetHost(ctx, req.ID, opts)
|
|
if err != nil {
|
|
return getHostResponse{Err: err}, nil
|
|
}
|
|
|
|
resp, err := hostDetailResponseForHost(ctx, svc, host)
|
|
if err != nil {
|
|
return getHostResponse{Err: err}, nil
|
|
}
|
|
|
|
return getHostResponse{Host: resp}, nil
|
|
}
|
|
|
|
func (svc *Service) GetHost(ctx context.Context, id uint, opts fleet.HostDetailOptions) (*fleet.HostDetail, error) {
|
|
alreadyAuthd := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken)
|
|
if !alreadyAuthd {
|
|
// First ensure the user has access to list hosts, then check the specific
|
|
// host once team_id is loaded.
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
// recalculate host failing_policies_count & total_issues_count, at most every minute
|
|
lastUpdated, err := svc.ds.GetHostIssuesLastUpdated(ctx, id)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "checking host's host_issues last updated:")
|
|
}
|
|
if time.Since(lastUpdated) > time.Minute {
|
|
if err := svc.ds.UpdateHostIssuesFailingPolicies(ctx, []uint{id}); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "recalculate host failing policies count:")
|
|
}
|
|
}
|
|
|
|
host, err := svc.ds.Host(ctx, id)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host")
|
|
}
|
|
if !opts.IncludeCriticalVulnerabilitiesCount {
|
|
host.HostIssues.CriticalVulnerabilitiesCount = nil
|
|
}
|
|
|
|
if !alreadyAuthd {
|
|
// Authorize again with team loaded now that we have team_id
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
hostDetails, err := svc.getHostDetails(ctx, host, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return hostDetails, nil
|
|
}
|
|
|
|
func (svc *Service) checkWriteForHostIDs(ctx context.Context, ids []uint) error {
|
|
for _, id := range ids {
|
|
host, err := svc.ds.HostLite(ctx, id)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get host for delete")
|
|
}
|
|
|
|
// Authorize again with team loaded now that we have team_id
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionWrite); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
return nil
|
|
}
|
|
|
|
// //////////////////////////////////////////////////////////////////////////////
|
|
// Get Host Lite
|
|
// //////////////////////////////////////////////////////////////////////////////
|
|
func (svc *Service) GetHostLite(ctx context.Context, id uint) (*fleet.Host, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
host, err := svc.ds.HostLite(ctx, id)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host lite")
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return host, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get Host Summary
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostSummaryRequest struct {
|
|
TeamID *uint `query:"team_id,optional"`
|
|
Platform *string `query:"platform,optional"`
|
|
LowDiskSpace *int `query:"low_disk_space,optional"`
|
|
}
|
|
|
|
type getHostSummaryResponse struct {
|
|
fleet.HostSummary
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getHostSummaryResponse) Error() error { return r.Err }
|
|
|
|
func getHostSummaryEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getHostSummaryRequest)
|
|
summary, err := svc.GetHostSummary(ctx, req.TeamID, req.Platform, req.LowDiskSpace)
|
|
if err != nil {
|
|
return getHostSummaryResponse{Err: err}, nil
|
|
}
|
|
|
|
resp := getHostSummaryResponse{
|
|
HostSummary: *summary,
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
func (svc *Service) GetHostSummary(ctx context.Context, teamID *uint, platform *string, lowDiskSpace *int) (*fleet.HostSummary, error) {
|
|
if lowDiskSpace != nil {
|
|
if *lowDiskSpace < 1 || *lowDiskSpace > 100 {
|
|
svc.authz.SkipAuthorization(ctx)
|
|
return nil, ctxerr.Wrap(
|
|
ctx, badRequest(fmt.Sprintf("invalid low_disk_space threshold, must be between 1 and 100: %d", *lowDiskSpace)),
|
|
)
|
|
}
|
|
}
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, fleet.ErrNoContext
|
|
}
|
|
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true, TeamID: teamID}
|
|
|
|
if !license.IsPremium(ctx) {
|
|
lowDiskSpace = nil
|
|
}
|
|
|
|
hostSummary, err := svc.ds.GenerateHostStatusStatistics(ctx, filter, svc.clock.Now(), platform, lowDiskSpace)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
linuxCount := uint(0)
|
|
for _, p := range hostSummary.Platforms {
|
|
if fleet.IsLinux(p.Platform) {
|
|
linuxCount += p.HostsCount
|
|
}
|
|
}
|
|
hostSummary.AllLinuxCount = linuxCount
|
|
|
|
labelsSummary, err := svc.ds.LabelsSummary(ctx)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
// TODO: should query for "All linux" label be updated to use `platform` from `os_version` table
|
|
// so that the label tracks the way platforms are handled here in the host summary?
|
|
var builtinLabels []*fleet.LabelSummary
|
|
for _, l := range labelsSummary {
|
|
if l.LabelType == fleet.LabelTypeBuiltIn {
|
|
builtinLabels = append(builtinLabels, l)
|
|
}
|
|
}
|
|
hostSummary.BuiltinLabels = builtinLabels
|
|
|
|
return hostSummary, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get Host By Identifier
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type hostByIdentifierRequest struct {
|
|
Identifier string `url:"identifier"`
|
|
ExcludeSoftware bool `query:"exclude_software,optional"`
|
|
}
|
|
|
|
func hostByIdentifierEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*hostByIdentifierRequest)
|
|
opts := fleet.HostDetailOptions{
|
|
IncludeCVEScores: false,
|
|
IncludePolicies: true, // intentionally true to preserve existing behavior
|
|
ExcludeSoftware: req.ExcludeSoftware,
|
|
}
|
|
host, err := svc.HostByIdentifier(ctx, req.Identifier, opts)
|
|
if err != nil {
|
|
return getHostResponse{Err: err}, nil
|
|
}
|
|
|
|
resp, err := hostDetailResponseForHost(ctx, svc, host)
|
|
if err != nil {
|
|
return getHostResponse{Err: err}, nil
|
|
}
|
|
|
|
return getHostResponse{
|
|
Host: resp,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) HostByIdentifier(ctx context.Context, identifier string, opts fleet.HostDetailOptions) (*fleet.HostDetail, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionSelectiveList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
host, err := svc.ds.HostByIdentifier(ctx, identifier)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host by identifier")
|
|
}
|
|
|
|
// Authorize again with team loaded now that we have team_id
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionSelectiveRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hostDetails, err := svc.getHostDetails(ctx, host, opts)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return hostDetails, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Delete Host
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type deleteHostRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type deleteHostResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r deleteHostResponse) Error() error { return r.Err }
|
|
|
|
func deleteHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*deleteHostRequest)
|
|
err := svc.DeleteHost(ctx, req.ID)
|
|
if err != nil {
|
|
return deleteHostResponse{Err: err}, nil
|
|
}
|
|
return deleteHostResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) DeleteHost(ctx context.Context, id uint) error {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return err
|
|
}
|
|
|
|
host, err := svc.ds.HostLite(ctx, id)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get host for delete")
|
|
}
|
|
|
|
// Authorize again with team loaded now that we have team_id
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionWrite); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := svc.ds.DeleteHost(ctx, id); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "delete host")
|
|
}
|
|
|
|
if fleet.MDMSupported(host.Platform) {
|
|
mdmLifecycle := mdmlifecycle.New(svc.ds, svc.logger)
|
|
err = mdmLifecycle.Do(ctx, mdmlifecycle.HostOptions{
|
|
Action: mdmlifecycle.HostActionDelete,
|
|
Platform: host.Platform,
|
|
UUID: host.UUID,
|
|
Host: host,
|
|
})
|
|
return ctxerr.Wrap(ctx, err, "performing MDM actions after delete")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Add Hosts to Team
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type addHostsToTeamRequest struct {
|
|
TeamID *uint `json:"team_id"`
|
|
HostIDs []uint `json:"hosts"`
|
|
}
|
|
|
|
type addHostsToTeamResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r addHostsToTeamResponse) Error() error { return r.Err }
|
|
|
|
func addHostsToTeamEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*addHostsToTeamRequest)
|
|
err := svc.AddHostsToTeam(ctx, req.TeamID, req.HostIDs, false)
|
|
if err != nil {
|
|
return addHostsToTeamResponse{Err: err}, nil
|
|
}
|
|
|
|
return addHostsToTeamResponse{}, err
|
|
}
|
|
|
|
func (svc *Service) AddHostsToTeam(ctx context.Context, teamID *uint, hostIDs []uint, skipBulkPending bool) error {
|
|
// This is currently treated as a "team write". If we ever give users
|
|
// besides global admins permissions to modify team hosts, we will need to
|
|
// check that the user has permissions for both the source and destination
|
|
// teams.
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
|
return err
|
|
}
|
|
|
|
if err := svc.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(teamID, hostIDs)); err != nil {
|
|
return err
|
|
}
|
|
if !skipBulkPending {
|
|
if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
|
|
}
|
|
}
|
|
serials, err := svc.ds.ListMDMAppleDEPSerialsInHostIDs(ctx, hostIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "list mdm dep serials in host ids")
|
|
}
|
|
if len(serials) > 0 {
|
|
if _, err := worker.QueueMacosSetupAssistantJob(
|
|
ctx,
|
|
svc.ds,
|
|
svc.logger,
|
|
worker.MacosSetupAssistantHostsTransferred,
|
|
teamID,
|
|
serials...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "queue macos setup assistant hosts transferred job")
|
|
}
|
|
}
|
|
|
|
return svc.createTransferredHostsActivity(ctx, teamID, hostIDs, nil)
|
|
}
|
|
|
|
// creates the transferred hosts activity if hosts were transferred, taking
|
|
// care of loading the team name and the hosts names if necessary (hostNames
|
|
// may be passed as empty if they were not available to the caller, such as in
|
|
// AddHostsToTeam, while it may be provided if available, such as in
|
|
// AddHostsToTeamByFilter).
|
|
func (svc *Service) createTransferredHostsActivity(ctx context.Context, teamID *uint, hostIDs []uint, hostNames []string) error {
|
|
if len(hostIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
var teamName *string
|
|
if teamID != nil {
|
|
tm, err := svc.ds.Team(ctx, *teamID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get team for activity")
|
|
}
|
|
teamName = &tm.Name
|
|
}
|
|
|
|
if len(hostNames) == 0 {
|
|
hosts, err := svc.ds.ListHostsLiteByIDs(ctx, hostIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "list hosts by ids")
|
|
}
|
|
|
|
// index the hosts by ids to get the names in the same order as hostIDs
|
|
hostsByID := make(map[uint]*fleet.Host, len(hosts))
|
|
for _, h := range hosts {
|
|
hostsByID[h.ID] = h
|
|
}
|
|
|
|
hostNames = make([]string, 0, len(hostIDs))
|
|
for _, hid := range hostIDs {
|
|
if h, ok := hostsByID[hid]; ok {
|
|
hostNames = append(hostNames, h.DisplayName())
|
|
} else {
|
|
// should not happen unless a host gets deleted just after transfer,
|
|
// but this ensures hostNames always matches hostIDs at the same index
|
|
hostNames = append(hostNames, "")
|
|
}
|
|
}
|
|
}
|
|
|
|
if err := svc.NewActivity(
|
|
ctx,
|
|
authz.UserFromContext(ctx),
|
|
fleet.ActivityTypeTransferredHostsToTeam{
|
|
TeamID: teamID,
|
|
TeamName: teamName,
|
|
HostIDs: hostIDs,
|
|
HostDisplayNames: hostNames,
|
|
},
|
|
); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "create transferred_hosts activity")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Add Hosts to Team by Filter
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type addHostsToTeamByFilterRequest struct {
|
|
TeamID *uint `json:"team_id"`
|
|
Filters *map[string]interface{} `json:"filters"`
|
|
}
|
|
|
|
type addHostsToTeamByFilterResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r addHostsToTeamByFilterResponse) Error() error { return r.Err }
|
|
|
|
func addHostsToTeamByFilterEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*addHostsToTeamByFilterRequest)
|
|
err := svc.AddHostsToTeamByFilter(ctx, req.TeamID, req.Filters)
|
|
if err != nil {
|
|
return addHostsToTeamByFilterResponse{Err: err}, nil
|
|
}
|
|
|
|
return addHostsToTeamByFilterResponse{}, err
|
|
}
|
|
|
|
func (svc *Service) AddHostsToTeamByFilter(ctx context.Context, teamID *uint, filters *map[string]interface{}) error {
|
|
// This is currently treated as a "team write". If we ever give users
|
|
// besides global admins permissions to modify team hosts, we will need to
|
|
// check that the user has permissions for both the source and destination
|
|
// teams.
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionWrite); err != nil {
|
|
return err
|
|
}
|
|
|
|
opt, lid, err := hostListOptionsFromFilters(filters)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
if opt == nil {
|
|
return &fleet.BadRequestError{Message: "filters must be specified"}
|
|
}
|
|
|
|
hostIDs, hostNames, _, err := svc.hostIDsAndNamesFromFilters(ctx, *opt, lid)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(hostIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
// Apply the team to the selected hosts.
|
|
if err := svc.ds.AddHostsToTeam(ctx, fleet.NewAddHostsToTeamParams(teamID, hostIDs)); err != nil {
|
|
return err
|
|
}
|
|
if _, err := svc.ds.BulkSetPendingMDMHostProfiles(ctx, hostIDs, nil, nil, nil); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "bulk set pending host profiles")
|
|
}
|
|
serials, err := svc.ds.ListMDMAppleDEPSerialsInHostIDs(ctx, hostIDs)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "list mdm dep serials in host ids")
|
|
}
|
|
if len(serials) > 0 {
|
|
if _, err := worker.QueueMacosSetupAssistantJob(
|
|
ctx,
|
|
svc.ds,
|
|
svc.logger,
|
|
worker.MacosSetupAssistantHostsTransferred,
|
|
teamID,
|
|
serials...); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "queue macos setup assistant hosts transferred job")
|
|
}
|
|
}
|
|
|
|
return svc.createTransferredHostsActivity(ctx, teamID, hostIDs, hostNames)
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Refetch Host
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type refetchHostRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type refetchHostResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r refetchHostResponse) Error() error {
|
|
return r.Err
|
|
}
|
|
|
|
func refetchHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*refetchHostRequest)
|
|
err := svc.RefetchHost(ctx, req.ID)
|
|
if err != nil {
|
|
return refetchHostResponse{Err: err}, nil
|
|
}
|
|
return refetchHostResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) RefetchHost(ctx context.Context, id uint) error {
|
|
var host *fleet.Host
|
|
// iOS and iPadOS refetch are not authenticated with device token because these devices do not have Fleet Desktop,
|
|
// so we don't handle that case
|
|
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
|
|
var err error
|
|
if err = svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return err
|
|
}
|
|
|
|
host, err = svc.ds.HostLite(ctx, id)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "find host for refetch")
|
|
}
|
|
|
|
// We verify fleet.ActionRead instead of fleet.ActionWrite because we want to allow
|
|
// observers to be able to refetch hosts.
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
|
return err
|
|
}
|
|
}
|
|
|
|
if err := svc.ds.UpdateHostRefetchRequested(ctx, id, true); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "save host")
|
|
}
|
|
|
|
// TODO(android): add android to this list?
|
|
if host != nil && (host.Platform == "ios" || host.Platform == "ipados") {
|
|
// Get MDM commands already sent
|
|
commands, err := svc.ds.GetHostMDMCommands(ctx, host.ID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "get host MDM commands")
|
|
}
|
|
doAppRefetch := true
|
|
doDeviceInfoRefetch := true
|
|
doCertsRefetch := true
|
|
for _, cmd := range commands {
|
|
switch cmd.CommandType {
|
|
case fleet.RefetchDeviceCommandUUIDPrefix:
|
|
doDeviceInfoRefetch = false
|
|
case fleet.RefetchAppsCommandUUIDPrefix:
|
|
doAppRefetch = false
|
|
case fleet.RefetchCertsCommandUUIDPrefix:
|
|
doCertsRefetch = false
|
|
}
|
|
}
|
|
if !doAppRefetch && !doDeviceInfoRefetch && !doCertsRefetch {
|
|
// Nothing to do.
|
|
return nil
|
|
}
|
|
err = svc.verifyMDMConfiguredAndConnected(ctx, host)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
hostMDMCommands := make([]fleet.HostMDMCommand, 0, 3)
|
|
cmdUUID := uuid.NewString()
|
|
if doAppRefetch {
|
|
err = svc.mdmAppleCommander.InstalledApplicationList(ctx, []string{host.UUID}, fleet.RefetchAppsCommandUUIDPrefix+cmdUUID, false)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "refetch apps with MDM")
|
|
}
|
|
hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{
|
|
HostID: host.ID,
|
|
CommandType: fleet.RefetchAppsCommandUUIDPrefix,
|
|
})
|
|
}
|
|
if doCertsRefetch {
|
|
if err := svc.mdmAppleCommander.CertificateList(ctx, []string{host.UUID}, fleet.RefetchCertsCommandUUIDPrefix+cmdUUID); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "refetch certs with MDM")
|
|
}
|
|
hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{
|
|
HostID: host.ID,
|
|
CommandType: fleet.RefetchCertsCommandUUIDPrefix,
|
|
})
|
|
}
|
|
if doDeviceInfoRefetch {
|
|
// DeviceInformation is last because the refetch response clears the refetch_requested flag
|
|
err = svc.mdmAppleCommander.DeviceInformation(ctx, []string{host.UUID}, fleet.RefetchDeviceCommandUUIDPrefix+cmdUUID)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "refetch host with MDM")
|
|
}
|
|
hostMDMCommands = append(hostMDMCommands, fleet.HostMDMCommand{
|
|
HostID: host.ID,
|
|
CommandType: fleet.RefetchDeviceCommandUUIDPrefix,
|
|
})
|
|
}
|
|
// Add commands to the database to track the commands sent
|
|
err = svc.ds.AddHostMDMCommands(ctx, hostMDMCommands)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "add host mdm commands")
|
|
}
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) verifyMDMConfiguredAndConnected(ctx context.Context, host *fleet.Host) error {
|
|
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
|
if errors.Is(err, fleet.ErrMDMNotConfigured) {
|
|
err = fleet.NewInvalidArgumentError("id", fleet.AppleMDMNotConfiguredMessage).WithStatus(http.StatusBadRequest)
|
|
}
|
|
return ctxerr.Wrap(ctx, err, "check macOS MDM enabled")
|
|
}
|
|
connected, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host)
|
|
if err != nil {
|
|
return ctxerr.Wrap(ctx, err, "checking if host is connected to Fleet")
|
|
}
|
|
if !connected {
|
|
return ctxerr.Wrap(ctx,
|
|
fleet.NewInvalidArgumentError("id", "Host does not have MDM turned on."))
|
|
}
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) getHostDetails(ctx context.Context, host *fleet.Host, opts fleet.HostDetailOptions) (*fleet.HostDetail, error) {
|
|
if !opts.ExcludeSoftware {
|
|
if err := svc.ds.LoadHostSoftware(ctx, host, opts.IncludeCVEScores); err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "load host software")
|
|
}
|
|
}
|
|
|
|
labels, err := svc.ds.ListLabelsForHost(ctx, host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get labels for host")
|
|
}
|
|
|
|
packs, err := svc.ds.ListPacksForHost(ctx, host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get packs for host")
|
|
}
|
|
|
|
bats, err := svc.ds.ListHostBatteries(ctx, host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get batteries for host")
|
|
}
|
|
|
|
mws, err := svc.ds.ListUpcomingHostMaintenanceWindows(ctx, host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "list upcoming host maintenance windows")
|
|
}
|
|
// we are only interested in the next maintenance window. There should only be one for now, anyway.
|
|
var nextMw *fleet.HostMaintenanceWindow
|
|
if len(mws) > 0 {
|
|
nextMw = mws[0]
|
|
}
|
|
|
|
// nil TimeZone is okay
|
|
if nextMw != nil && nextMw.TimeZone != nil {
|
|
// return the start time in the local timezone of the host's associated google calendar user
|
|
gCalLoc, err := time.LoadLocation(*nextMw.TimeZone)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "list upcoming host maintenance windows - invalid google calendar timezone")
|
|
}
|
|
nextMw.StartsAt = nextMw.StartsAt.In(gCalLoc)
|
|
}
|
|
|
|
var policies *[]*fleet.HostPolicy
|
|
if opts.IncludePolicies {
|
|
hp, err := svc.ds.ListPoliciesForHost(ctx, host)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get policies for host")
|
|
}
|
|
|
|
if hp == nil {
|
|
hp = []*fleet.HostPolicy{}
|
|
}
|
|
|
|
policies = &hp
|
|
}
|
|
|
|
// If Fleet MDM is enabled and configured, we want to include MDM profiles,
|
|
// disk encryption status, and macOS setup details for non-linux hosts.
|
|
ac, err := svc.ds.AppConfig(ctx)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get app config for host mdm details")
|
|
}
|
|
|
|
var profiles []fleet.HostMDMProfile
|
|
var mdmLastEnrollment *time.Time
|
|
var mdmLastCheckedIn *time.Time
|
|
if ac.MDM.EnabledAndConfigured || ac.MDM.WindowsEnabledAndConfigured {
|
|
host.MDM.OSSettings = &fleet.HostMDMOSSettings{}
|
|
switch host.Platform {
|
|
case "windows":
|
|
if !ac.MDM.WindowsEnabledAndConfigured {
|
|
break
|
|
}
|
|
|
|
if license.IsPremium(ctx) {
|
|
// we include disk encryption status only for premium so initialize it to default struct
|
|
host.MDM.OSSettings.DiskEncryption = fleet.HostMDMDiskEncryption{}
|
|
// ensure host mdm info is loaded (we don't know if our caller populated it)
|
|
_, err := svc.ds.GetHostMDM(ctx, host.ID)
|
|
switch {
|
|
case err != nil && fleet.IsNotFound(err):
|
|
// assume host is unmanaged, log for debugging, and move on
|
|
level.Debug(svc.logger).Log("msg", "cannot determine bitlocker status because no mdm info for host", "host_id", host.ID)
|
|
case err != nil:
|
|
return nil, ctxerr.Wrap(ctx, err, "ensure host mdm info")
|
|
default:
|
|
hde, err := svc.ds.GetMDMWindowsBitLockerStatus(ctx, host)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host mdm bitlocker status")
|
|
}
|
|
if hde != nil {
|
|
// overwrite the default disk encryption status
|
|
host.MDM.OSSettings.DiskEncryption = *hde
|
|
}
|
|
}
|
|
}
|
|
|
|
profs, err := svc.ds.GetHostMDMWindowsProfiles(ctx, host.UUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host mdm windows profiles")
|
|
}
|
|
if profs == nil {
|
|
profs = []fleet.HostMDMWindowsProfile{}
|
|
}
|
|
for _, p := range profs {
|
|
p.Detail = fleet.HostMDMProfileDetail(p.Detail).Message()
|
|
profiles = append(profiles, p.ToHostMDMProfile())
|
|
}
|
|
|
|
case "darwin", "ios", "ipados":
|
|
if ac.MDM.EnabledAndConfigured {
|
|
profs, err := svc.ds.GetHostMDMAppleProfiles(ctx, host.UUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host mdm profiles")
|
|
}
|
|
|
|
// determine disk encryption and action required here based on profiles and
|
|
// raw decryptable key status.
|
|
host.MDM.PopulateOSSettingsAndMacOSSettings(profs, mobileconfig.FleetFileVaultPayloadIdentifier)
|
|
|
|
for _, p := range profs {
|
|
if p.Identifier == mobileconfig.FleetFileVaultPayloadIdentifier {
|
|
p.Status = host.MDM.ProfileStatusFromDiskEncryptionState(p.Status)
|
|
}
|
|
p.Detail = fleet.HostMDMProfileDetail(p.Detail).Message()
|
|
profiles = append(profiles, p.ToHostMDMProfile(host.Platform))
|
|
}
|
|
|
|
// fetch host last seen at and last enrolled at times, currently only supported for
|
|
// Apple platforms
|
|
mdmLastEnrollment, mdmLastCheckedIn, err = svc.ds.GetNanoMDMEnrollmentTimes(ctx, host.UUID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host mdm enrollment times")
|
|
}
|
|
}
|
|
}
|
|
}
|
|
host.MDM.Profiles = &profiles
|
|
|
|
isHDEKArchived, err := svc.ds.IsHostDiskEncryptionKeyArchived(ctx, host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "check if host disk encryption key is archived")
|
|
}
|
|
host.MDM.EncryptionKeyArchived = &isHDEKArchived
|
|
|
|
if host.IsLUKSSupported() {
|
|
// since Linux hosts don't require MDM to be enabled & configured, explicitly check that disk encryption is
|
|
// enabled for the host's team
|
|
diskEncryptionConfig, err := svc.ds.GetConfigEnableDiskEncryption(ctx, host.TeamID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host disk encryption enabled setting")
|
|
}
|
|
if diskEncryptionConfig.Enabled {
|
|
status, err := svc.LinuxHostDiskEncryptionStatus(ctx, *host)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host disk encryption status")
|
|
}
|
|
host.MDM.OSSettings = &fleet.HostMDMOSSettings{
|
|
DiskEncryption: status,
|
|
}
|
|
|
|
if status.Status != nil && *status.Status == fleet.DiskEncryptionVerified {
|
|
host.MDM.EncryptionKeyAvailable = true
|
|
}
|
|
}
|
|
}
|
|
|
|
var macOSSetup *fleet.HostMDMMacOSSetup
|
|
if ac.MDM.EnabledAndConfigured && license.IsPremium(ctx) {
|
|
macOSSetup, err = svc.ds.GetHostMDMMacOSSetup(ctx, host.ID)
|
|
if err != nil {
|
|
if !fleet.IsNotFound(err) {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host mdm macos setup")
|
|
}
|
|
// TODO(Sarah): What should we do for not found? Should we return an empty struct or nil?
|
|
macOSSetup = &fleet.HostMDMMacOSSetup{}
|
|
}
|
|
}
|
|
host.MDM.MacOSSetup = macOSSetup
|
|
|
|
mdmActions, err := svc.ds.GetHostLockWipeStatus(ctx, host)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host mdm lock/wipe status")
|
|
}
|
|
|
|
host.MDM.DeviceStatus = ptr.String(string(mdmActions.DeviceStatus()))
|
|
host.MDM.PendingAction = ptr.String(string(mdmActions.PendingAction()))
|
|
|
|
host.Policies = policies
|
|
|
|
endUsers, err := getEndUsers(ctx, svc.ds, host.ID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get end users for host")
|
|
}
|
|
|
|
return &fleet.HostDetail{
|
|
Host: *host,
|
|
Labels: labels,
|
|
Packs: packs,
|
|
Batteries: &bats,
|
|
MaintenanceWindow: nextMw,
|
|
EndUsers: endUsers,
|
|
LastMDMEnrolledAt: mdmLastEnrollment,
|
|
LastMDMCheckedInAt: mdmLastCheckedIn,
|
|
}, nil
|
|
}
|
|
|
|
func getEndUsers(ctx context.Context, ds fleet.Datastore, hostID uint) ([]fleet.HostEndUser, error) {
|
|
scimUser, err := ds.ScimUserByHostID(ctx, hostID)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return nil, ctxerr.Wrap(ctx, err, "get scim user by host id")
|
|
}
|
|
|
|
var endUsers []fleet.HostEndUser
|
|
if scimUser != nil {
|
|
endUser := fleet.HostEndUser{
|
|
IdpUserName: scimUser.UserName,
|
|
IdpFullName: scimUser.DisplayName(),
|
|
IdpInfoUpdatedAt: ptr.Time(scimUser.UpdatedAt),
|
|
}
|
|
|
|
if scimUser.ExternalID != nil {
|
|
endUser.IdpID = *scimUser.ExternalID
|
|
}
|
|
for _, group := range scimUser.Groups {
|
|
endUser.IdpGroups = append(endUser.IdpGroups, group.DisplayName)
|
|
}
|
|
if scimUser.Department != nil {
|
|
endUser.Department = *scimUser.Department
|
|
}
|
|
endUsers = append(endUsers, endUser)
|
|
}
|
|
|
|
deviceMapping, err := ds.ListHostDeviceMapping(ctx, hostID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host device mapping")
|
|
}
|
|
|
|
if len(deviceMapping) > 0 {
|
|
endUser := fleet.HostEndUser{}
|
|
for _, email := range deviceMapping {
|
|
switch {
|
|
case email.Source == fleet.DeviceMappingMDMIdpAccounts && len(endUsers) == 0:
|
|
// If SCIM data is missing, we still populate IdpUserName if present.
|
|
// Note: Username and email is the same thing here until we split them with https://github.com/fleetdm/fleet/issues/27952
|
|
endUser.IdpUserName = email.Email
|
|
case email.Source != fleet.DeviceMappingMDMIdpAccounts:
|
|
endUser.OtherEmails = append(endUser.OtherEmails, *email)
|
|
}
|
|
}
|
|
if len(endUsers) > 0 {
|
|
endUsers[0].OtherEmails = endUser.OtherEmails
|
|
} else {
|
|
endUsers = append(endUsers, endUser)
|
|
}
|
|
}
|
|
|
|
return endUsers, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Get Host Query Report
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostQueryReportRequest struct {
|
|
ID uint `url:"id"`
|
|
QueryID uint `url:"query_id"`
|
|
}
|
|
|
|
type getHostQueryReportResponse struct {
|
|
QueryID uint `json:"query_id"`
|
|
HostID uint `json:"host_id"`
|
|
HostName string `json:"host_name"`
|
|
LastFetched *time.Time `json:"last_fetched"`
|
|
ReportClipped bool `json:"report_clipped"`
|
|
Results []fleet.HostQueryReportResult `json:"results"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getHostQueryReportResponse) Error() error { return r.Err }
|
|
|
|
func getHostQueryReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getHostQueryReportRequest)
|
|
|
|
// Need to return hostname in response even if there are no report results
|
|
host, err := svc.GetHostLite(ctx, req.ID)
|
|
if err != nil {
|
|
return getHostQueryReportResponse{Err: err}, nil
|
|
}
|
|
|
|
reportResults, lastFetched, err := svc.GetHostQueryReportResults(ctx, req.ID, req.QueryID)
|
|
if err != nil {
|
|
return getHostQueryReportResponse{Err: err}, nil
|
|
}
|
|
|
|
appConfig, err := svc.AppConfigObfuscated(ctx)
|
|
if err != nil {
|
|
return getHostQueryReportResponse{Err: err}, nil
|
|
}
|
|
|
|
isClipped, err := svc.QueryReportIsClipped(ctx, req.QueryID, appConfig.ServerSettings.GetQueryReportCap())
|
|
if err != nil {
|
|
return getHostQueryReportResponse{Err: err}, nil
|
|
}
|
|
|
|
return getHostQueryReportResponse{
|
|
QueryID: req.QueryID,
|
|
HostID: host.ID,
|
|
HostName: host.DisplayName(),
|
|
LastFetched: lastFetched,
|
|
ReportClipped: isClipped,
|
|
Results: reportResults,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) GetHostQueryReportResults(ctx context.Context, hostID uint, queryID uint) ([]fleet.HostQueryReportResult, *time.Time, error) {
|
|
query, err := svc.ds.Query(ctx, queryID)
|
|
if err != nil {
|
|
setAuthCheckedOnPreAuthErr(ctx)
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "get query from datastore")
|
|
}
|
|
if err := svc.authz.Authorize(ctx, query, fleet.ActionRead); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
rows, err := svc.ds.QueryResultRowsForHost(ctx, queryID, hostID)
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "get query result rows for host")
|
|
}
|
|
|
|
if len(rows) == 0 {
|
|
return []fleet.HostQueryReportResult{}, nil, nil
|
|
}
|
|
|
|
var lastFetched *time.Time
|
|
result := make([]fleet.HostQueryReportResult, 0, len(rows))
|
|
for _, row := range rows {
|
|
fetched := row.LastFetched // copy to avoid loop reuse issue
|
|
lastFetched = &fetched // need to return value even if data is nil
|
|
|
|
if row.Data != nil {
|
|
columns := map[string]string{}
|
|
if err := json.Unmarshal(*row.Data, &columns); err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "unmarshal query result row data")
|
|
}
|
|
result = append(result, fleet.HostQueryReportResult{Columns: columns})
|
|
}
|
|
}
|
|
|
|
return result, lastFetched, nil
|
|
}
|
|
|
|
func (svc *Service) hostIDsAndNamesFromFilters(ctx context.Context, opt fleet.HostListOptions, lid *uint) ([]uint, []string, []*fleet.Host, error) {
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, nil, nil, fleet.ErrNoContext
|
|
}
|
|
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
|
|
|
|
// Load hosts, either from label if provided or from all hosts.
|
|
var hosts []*fleet.Host
|
|
var err error
|
|
if lid != nil {
|
|
hosts, err = svc.ds.ListHostsInLabel(ctx, filter, *lid, opt)
|
|
} else {
|
|
opt.DisableIssues = true // intentionally ignore failing policies
|
|
hosts, err = svc.ds.ListHosts(ctx, filter, opt)
|
|
}
|
|
if err != nil {
|
|
return nil, nil, nil, err
|
|
}
|
|
|
|
if len(hosts) == 0 {
|
|
return nil, nil, nil, nil
|
|
}
|
|
|
|
hostIDs := make([]uint, 0, len(hosts))
|
|
hostNames := make([]string, 0, len(hosts))
|
|
for _, h := range hosts {
|
|
hostIDs = append(hostIDs, h.ID)
|
|
hostNames = append(hostNames, h.DisplayName())
|
|
}
|
|
return hostIDs, hostNames, hosts, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// List Host Device Mappings
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type listHostDeviceMappingRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type listHostDeviceMappingResponse struct {
|
|
HostID uint `json:"host_id"`
|
|
DeviceMapping []*fleet.HostDeviceMapping `json:"device_mapping"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listHostDeviceMappingResponse) Error() error { return r.Err }
|
|
|
|
// listHostDeviceMappingEndpoint
|
|
// Deprecated: Emails are now included in host details endpoint /api/_version_/fleet/hosts/{id}
|
|
func listHostDeviceMappingEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*listHostDeviceMappingRequest)
|
|
dms, err := svc.ListHostDeviceMapping(ctx, req.ID)
|
|
if err != nil {
|
|
return listHostDeviceMappingResponse{Err: err}, nil
|
|
}
|
|
return listHostDeviceMappingResponse{HostID: req.ID, DeviceMapping: dms}, nil
|
|
}
|
|
|
|
func (svc *Service) ListHostDeviceMapping(ctx context.Context, id uint) ([]*fleet.HostDeviceMapping, error) {
|
|
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
host, err := svc.ds.HostLite(ctx, id)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host")
|
|
}
|
|
|
|
// Authorize again with team loaded now that we have team_id
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
return svc.ds.ListHostDeviceMapping(ctx, id)
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Put Custom Host Device Mapping
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type putHostDeviceMappingRequest struct {
|
|
ID uint `url:"id"`
|
|
Email string `json:"email"`
|
|
}
|
|
|
|
type putHostDeviceMappingResponse struct {
|
|
HostID uint `json:"host_id"`
|
|
DeviceMapping []*fleet.HostDeviceMapping `json:"device_mapping"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r putHostDeviceMappingResponse) Error() error { return r.Err }
|
|
|
|
// putHostDeviceMappingEndpoint
|
|
// Deprecated: Because the corresponding GET endpoint is deprecated.
|
|
func putHostDeviceMappingEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*putHostDeviceMappingRequest)
|
|
dms, err := svc.SetCustomHostDeviceMapping(ctx, req.ID, req.Email)
|
|
if err != nil {
|
|
return putHostDeviceMappingResponse{Err: err}, nil
|
|
}
|
|
return putHostDeviceMappingResponse{HostID: req.ID, DeviceMapping: dms}, nil
|
|
}
|
|
|
|
func (svc *Service) SetCustomHostDeviceMapping(ctx context.Context, hostID uint, email string) ([]*fleet.HostDeviceMapping, error) {
|
|
isInstallerSource := svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnOrbitToken)
|
|
if !isInstallerSource {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
host, err := svc.ds.HostLite(ctx, hostID)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host")
|
|
}
|
|
|
|
// Authorize again with team loaded now that we have team_id
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionWrite); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
source := fleet.DeviceMappingCustomOverride
|
|
if isInstallerSource {
|
|
source = fleet.DeviceMappingCustomInstaller
|
|
}
|
|
return svc.ds.SetOrUpdateCustomHostDeviceMapping(ctx, hostID, email, source)
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// MDM
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostMDMRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type getHostMDMResponse struct {
|
|
*fleet.HostMDM
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getHostMDMResponse) Error() error { return r.Err }
|
|
|
|
func getHostMDM(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getHostMDMRequest)
|
|
mdm, err := svc.MDMData(ctx, req.ID)
|
|
if err != nil {
|
|
return getHostMDMResponse{Err: err}, nil
|
|
}
|
|
return getHostMDMResponse{HostMDM: mdm}, nil
|
|
}
|
|
|
|
type getHostMDMSummaryResponse struct {
|
|
fleet.AggregatedMDMData
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
type getHostMDMSummaryRequest struct {
|
|
TeamID *uint `query:"team_id,optional"`
|
|
Platform string `query:"platform,optional"`
|
|
}
|
|
|
|
func (r getHostMDMSummaryResponse) Error() error { return r.Err }
|
|
|
|
func getHostMDMSummary(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getHostMDMSummaryRequest)
|
|
resp := getHostMDMSummaryResponse{}
|
|
var err error
|
|
|
|
resp.AggregatedMDMData, err = svc.AggregatedMDMData(ctx, req.TeamID, req.Platform)
|
|
if err != nil {
|
|
return getHostMDMSummaryResponse{Err: err}, nil
|
|
}
|
|
return resp, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Macadmins
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getMacadminsDataRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type getMacadminsDataResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
Macadmins *fleet.MacadminsData `json:"macadmins"`
|
|
}
|
|
|
|
func (r getMacadminsDataResponse) Error() error { return r.Err }
|
|
|
|
func getMacadminsDataEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getMacadminsDataRequest)
|
|
data, err := svc.MacadminsData(ctx, req.ID)
|
|
if err != nil {
|
|
return getMacadminsDataResponse{Err: err}, nil
|
|
}
|
|
return getMacadminsDataResponse{Macadmins: data}, nil
|
|
}
|
|
|
|
func (svc *Service) MacadminsData(ctx context.Context, id uint) (*fleet.MacadminsData, error) {
|
|
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
host, err := svc.ds.HostLite(ctx, id)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "find host for macadmins")
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
var munkiInfo *fleet.HostMunkiInfo
|
|
switch version, err := svc.ds.GetHostMunkiVersion(ctx, id); {
|
|
case err != nil && !fleet.IsNotFound(err):
|
|
return nil, err
|
|
case err == nil:
|
|
munkiInfo = &fleet.HostMunkiInfo{Version: version}
|
|
}
|
|
|
|
var mdm *fleet.HostMDM
|
|
switch hmdm, err := svc.ds.GetHostMDM(ctx, id); {
|
|
case err != nil && !fleet.IsNotFound(err):
|
|
return nil, err
|
|
case err == nil:
|
|
mdm = hmdm
|
|
}
|
|
|
|
var munkiIssues []*fleet.HostMunkiIssue
|
|
switch issues, err := svc.ds.GetHostMunkiIssues(ctx, id); {
|
|
case err != nil:
|
|
return nil, err
|
|
case err == nil:
|
|
munkiIssues = issues
|
|
}
|
|
|
|
if munkiInfo == nil && mdm == nil && len(munkiIssues) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
data := &fleet.MacadminsData{
|
|
Munki: munkiInfo,
|
|
MDM: mdm,
|
|
MunkiIssues: munkiIssues,
|
|
}
|
|
|
|
return data, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Aggregated Macadmins
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getAggregatedMacadminsDataRequest struct {
|
|
TeamID *uint `query:"team_id,optional"`
|
|
}
|
|
|
|
type getAggregatedMacadminsDataResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
Macadmins *fleet.AggregatedMacadminsData `json:"macadmins"`
|
|
}
|
|
|
|
func (r getAggregatedMacadminsDataResponse) Error() error { return r.Err }
|
|
|
|
func getAggregatedMacadminsDataEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getAggregatedMacadminsDataRequest)
|
|
data, err := svc.AggregatedMacadminsData(ctx, req.TeamID)
|
|
if err != nil {
|
|
return getAggregatedMacadminsDataResponse{Err: err}, nil
|
|
}
|
|
return getAggregatedMacadminsDataResponse{Macadmins: data}, nil
|
|
}
|
|
|
|
func (svc *Service) AggregatedMacadminsData(ctx context.Context, teamID *uint) (*fleet.AggregatedMacadminsData, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if teamID != nil {
|
|
_, err := svc.ds.Team(ctx, *teamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
}
|
|
|
|
agg := &fleet.AggregatedMacadminsData{}
|
|
|
|
versions, munkiUpdatedAt, err := svc.ds.AggregatedMunkiVersion(ctx, teamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
agg.MunkiVersions = versions
|
|
|
|
issues, munkiIssUpdatedAt, err := svc.ds.AggregatedMunkiIssues(ctx, teamID)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
agg.MunkiIssues = issues
|
|
|
|
var mdmUpdatedAt, mdmSolutionsUpdatedAt time.Time
|
|
agg.MDMStatus, mdmUpdatedAt, err = svc.ds.AggregatedMDMStatus(ctx, teamID, "darwin")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
agg.MDMSolutions, mdmSolutionsUpdatedAt, err = svc.ds.AggregatedMDMSolutions(ctx, teamID, "darwin")
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
agg.CountsUpdatedAt = munkiUpdatedAt
|
|
if munkiIssUpdatedAt.After(agg.CountsUpdatedAt) {
|
|
agg.CountsUpdatedAt = munkiIssUpdatedAt
|
|
}
|
|
if mdmUpdatedAt.After(agg.CountsUpdatedAt) {
|
|
agg.CountsUpdatedAt = mdmUpdatedAt
|
|
}
|
|
if mdmSolutionsUpdatedAt.After(agg.CountsUpdatedAt) {
|
|
agg.CountsUpdatedAt = mdmSolutionsUpdatedAt
|
|
}
|
|
|
|
return agg, nil
|
|
}
|
|
|
|
func (svc *Service) MDMData(ctx context.Context, id uint) (*fleet.HostMDM, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
host, err := svc.ds.HostLite(ctx, id)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "find host for MDMData")
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
hmdm, err := svc.ds.GetHostMDM(ctx, id)
|
|
switch {
|
|
case err == nil:
|
|
return hmdm, nil
|
|
case fleet.IsNotFound(err):
|
|
return nil, nil
|
|
default:
|
|
return nil, ctxerr.Wrap(ctx, err, "get host mdm")
|
|
}
|
|
}
|
|
|
|
func (svc *Service) AggregatedMDMData(ctx context.Context, teamID *uint, platform string) (fleet.AggregatedMDMData, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionList); err != nil {
|
|
return fleet.AggregatedMDMData{}, err
|
|
}
|
|
|
|
mdmStatus, mdmStatusUpdatedAt, err := svc.ds.AggregatedMDMStatus(ctx, teamID, platform)
|
|
if err != nil {
|
|
return fleet.AggregatedMDMData{}, err
|
|
}
|
|
mdmSolutions, mdmSolutionsUpdatedAt, err := svc.ds.AggregatedMDMSolutions(ctx, teamID, platform)
|
|
if err != nil {
|
|
return fleet.AggregatedMDMData{}, err
|
|
}
|
|
|
|
countsUpdatedAt := mdmStatusUpdatedAt
|
|
if mdmStatusUpdatedAt.Before(mdmSolutionsUpdatedAt) {
|
|
countsUpdatedAt = mdmSolutionsUpdatedAt
|
|
}
|
|
|
|
return fleet.AggregatedMDMData{
|
|
MDMStatus: mdmStatus,
|
|
MDMSolutions: mdmSolutions,
|
|
CountsUpdatedAt: countsUpdatedAt,
|
|
}, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Hosts Report in CSV downloadable file
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type hostsReportRequest struct {
|
|
Opts fleet.HostListOptions `url:"host_options"`
|
|
LabelID *uint `query:"label_id,optional"`
|
|
Format string `query:"format"`
|
|
Columns string `query:"columns,optional"`
|
|
}
|
|
|
|
type hostsReportResponse struct {
|
|
Columns []string `json:"-"` // used to control the generated csv, see the HijackRender method
|
|
Hosts []*fleet.HostResponse `json:"-"` // they get rendered explicitly, in csv
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r hostsReportResponse) Error() error { return r.Err }
|
|
|
|
func (r hostsReportResponse) HijackRender(ctx context.Context, w http.ResponseWriter) {
|
|
// post-process the Device Mappings for CSV rendering
|
|
for _, h := range r.Hosts {
|
|
if h.DeviceMapping != nil {
|
|
// return the list of emails, comma-separated, as part of that single CSV field
|
|
var dms []struct {
|
|
Email string `json:"email"`
|
|
}
|
|
if err := json.Unmarshal(*h.DeviceMapping, &dms); err != nil {
|
|
// log the error but keep going
|
|
logging.WithErr(ctx, err)
|
|
continue
|
|
}
|
|
|
|
var sb strings.Builder
|
|
for i, dm := range dms {
|
|
if i > 0 {
|
|
sb.WriteString(",")
|
|
}
|
|
sb.WriteString(dm.Email)
|
|
}
|
|
h.CSVDeviceMapping = sb.String()
|
|
}
|
|
}
|
|
|
|
var buf bytes.Buffer
|
|
if err := gocsv.Marshal(r.Hosts, &buf); err != nil {
|
|
logging.WithErr(ctx, err)
|
|
endpoint_utils.EncodeError(ctx, ctxerr.New(ctx, "failed to generate CSV file"), w)
|
|
return
|
|
}
|
|
|
|
returnAll := len(r.Columns) == 0
|
|
|
|
var outRows [][]string
|
|
if !returnAll {
|
|
// read back the CSV to filter out any unwanted columns
|
|
recs, err := csv.NewReader(&buf).ReadAll()
|
|
if err != nil {
|
|
logging.WithErr(ctx, err)
|
|
endpoint_utils.EncodeError(ctx, ctxerr.New(ctx, "failed to generate CSV file"), w)
|
|
return
|
|
}
|
|
|
|
if len(recs) > 0 {
|
|
// map the header names to their field index
|
|
hdrs := make(map[string]int, len(recs))
|
|
for i, hdr := range recs[0] {
|
|
hdrs[hdr] = i
|
|
}
|
|
|
|
outRows = make([][]string, len(recs))
|
|
for i, rec := range recs {
|
|
for _, col := range r.Columns {
|
|
colIx, ok := hdrs[col]
|
|
if !ok {
|
|
// invalid column name - it would be nice to catch this in the
|
|
// endpoint before processing the results, but it would require
|
|
// duplicating the list of columns from the Host's struct tags to a
|
|
// map and keep this in sync, for what is essentially a programmer
|
|
// mistake that should be caught and corrected early.
|
|
endpoint_utils.EncodeError(ctx, &fleet.BadRequestError{Message: fmt.Sprintf("invalid column name: %q", col)}, w)
|
|
return
|
|
}
|
|
outRows[i] = append(outRows[i], rec[colIx])
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
w.Header().Add("Content-Disposition", fmt.Sprintf(`attachment; filename="Hosts %s.csv"`, time.Now().Format("2006-01-02")))
|
|
w.Header().Set("Content-Type", "text/csv")
|
|
w.Header().Set("X-Content-Type-Options", "nosniff")
|
|
w.WriteHeader(http.StatusOK)
|
|
|
|
var err error
|
|
if returnAll {
|
|
_, err = io.Copy(w, &buf)
|
|
} else {
|
|
err = csv.NewWriter(w).WriteAll(outRows)
|
|
}
|
|
if err != nil {
|
|
logging.WithErr(ctx, err)
|
|
}
|
|
}
|
|
|
|
func hostsReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*hostsReportRequest)
|
|
|
|
// for now, only csv format is allowed
|
|
if req.Format != "csv" {
|
|
// prevent returning an "unauthorized" error, we want that specific error
|
|
if az, ok := authzctx.FromContext(ctx); ok {
|
|
az.SetChecked()
|
|
}
|
|
err := ctxerr.Wrap(ctx, fleet.NewInvalidArgumentError("format", "unsupported or unspecified report format").
|
|
WithStatus(http.StatusUnsupportedMediaType))
|
|
return hostsReportResponse{Err: err}, nil
|
|
}
|
|
|
|
req.Opts.AdditionalFilters = nil
|
|
req.Opts.Page = 0
|
|
req.Opts.PerPage = 0 // explicitly disable any limit, we want all matching hosts
|
|
req.Opts.After = ""
|
|
req.Opts.DeviceMapping = false
|
|
|
|
rawCols := strings.Split(req.Columns, ",")
|
|
var cols []string
|
|
for _, rawCol := range rawCols {
|
|
if rawCol = strings.TrimSpace(rawCol); rawCol != "" {
|
|
cols = append(cols, rawCol)
|
|
if rawCol == "device_mapping" {
|
|
req.Opts.DeviceMapping = true
|
|
}
|
|
}
|
|
}
|
|
if len(cols) == 0 {
|
|
// enable device_mapping retrieval, as no column means all columns
|
|
req.Opts.DeviceMapping = true
|
|
}
|
|
|
|
var (
|
|
hosts []*fleet.Host
|
|
err error
|
|
)
|
|
|
|
if req.LabelID == nil {
|
|
hosts, err = svc.ListHosts(ctx, req.Opts)
|
|
} else {
|
|
hosts, err = svc.ListHostsInLabel(ctx, *req.LabelID, req.Opts)
|
|
}
|
|
if err != nil {
|
|
return hostsReportResponse{Err: err}, nil
|
|
}
|
|
|
|
hostResps := make([]*fleet.HostResponse, len(hosts))
|
|
for i, h := range hosts {
|
|
hr := fleet.HostResponseForHost(ctx, svc, h)
|
|
hostResps[i] = hr
|
|
}
|
|
return hostsReportResponse{Columns: cols, Hosts: hostResps}, nil
|
|
}
|
|
|
|
func (svc *Service) ListLabelsForHost(ctx context.Context, hostID uint) ([]*fleet.Label, error) {
|
|
// require list hosts permission to view this information
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
return svc.ds.ListLabelsForHost(ctx, hostID)
|
|
}
|
|
|
|
type osVersionsRequest struct {
|
|
fleet.ListOptions
|
|
TeamID *uint `query:"team_id,optional"`
|
|
Platform *string `query:"platform,optional"`
|
|
Name *string `query:"os_name,optional"`
|
|
Version *string `query:"os_version,optional"`
|
|
}
|
|
|
|
type osVersionsResponse struct {
|
|
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
|
|
Count int `json:"count"`
|
|
CountsUpdatedAt *time.Time `json:"counts_updated_at"`
|
|
OSVersions []fleet.OSVersion `json:"os_versions"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r osVersionsResponse) Error() error { return r.Err }
|
|
|
|
func osVersionsEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*osVersionsRequest)
|
|
|
|
osVersions, count, metadata, err := svc.OSVersions(ctx, req.TeamID, req.Platform, req.Name, req.Version, req.ListOptions, false)
|
|
if err != nil {
|
|
return &osVersionsResponse{Err: err}, nil
|
|
}
|
|
|
|
return &osVersionsResponse{
|
|
CountsUpdatedAt: &osVersions.CountsUpdatedAt,
|
|
OSVersions: osVersions.OSVersions,
|
|
Meta: metadata,
|
|
Count: count,
|
|
}, nil
|
|
}
|
|
|
|
func (svc *Service) OSVersions(ctx context.Context, teamID *uint, platform *string, name *string, version *string, opts fleet.ListOptions, includeCVSS bool) (*fleet.OSVersions, int, *fleet.PaginationMetadata, error) {
|
|
var count int
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionList); err != nil {
|
|
return nil, count, nil, err
|
|
}
|
|
|
|
if name != nil && version == nil {
|
|
return nil, count, nil, &fleet.BadRequestError{Message: "Cannot specify os_name without os_version"}
|
|
}
|
|
|
|
if name == nil && version != nil {
|
|
return nil, count, nil, &fleet.BadRequestError{Message: "Cannot specify os_version without os_name"}
|
|
}
|
|
|
|
if opts.OrderKey != "" && opts.OrderKey != "hosts_count" {
|
|
return nil, count, nil, &fleet.BadRequestError{Message: "Invalid order key"}
|
|
}
|
|
|
|
if teamID != nil {
|
|
// This auth check ensures we return 403 if the user doesn't have access to the team
|
|
if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{TeamID: teamID}, fleet.ActionRead); err != nil {
|
|
return nil, count, nil, err
|
|
}
|
|
|
|
if *teamID != 0 {
|
|
exists, err := svc.ds.TeamExists(ctx, *teamID)
|
|
if err != nil {
|
|
return nil, count, nil, ctxerr.Wrap(ctx, err, "checking if team exists")
|
|
} else if !exists {
|
|
return nil, count, nil, fleet.NewInvalidArgumentError("team_id", fmt.Sprintf("team %d does not exist", *teamID)).
|
|
WithStatus(http.StatusNotFound)
|
|
}
|
|
}
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, count, nil, fleet.ErrNoContext
|
|
}
|
|
osVersions, err := svc.ds.OSVersions(
|
|
ctx, &fleet.TeamFilter{
|
|
User: vc.User,
|
|
IncludeObserver: true,
|
|
TeamID: teamID,
|
|
}, platform, name, version,
|
|
)
|
|
if err != nil && fleet.IsNotFound(err) {
|
|
// It is possible that os exists, but aggregation job has not run yet.
|
|
osVersions = &fleet.OSVersions{}
|
|
} else if err != nil {
|
|
return nil, count, nil, err
|
|
}
|
|
|
|
for i := range osVersions.OSVersions {
|
|
if err := svc.populateOSVersionDetails(ctx, &osVersions.OSVersions[i], includeCVSS); err != nil {
|
|
return nil, count, nil, err
|
|
}
|
|
}
|
|
|
|
if opts.OrderKey == "hosts_count" && opts.OrderDirection == fleet.OrderAscending {
|
|
sort.Slice(osVersions.OSVersions, func(i, j int) bool {
|
|
return osVersions.OSVersions[i].HostsCount < osVersions.OSVersions[j].HostsCount
|
|
})
|
|
} else {
|
|
sort.Slice(osVersions.OSVersions, func(i, j int) bool {
|
|
return osVersions.OSVersions[i].HostsCount > osVersions.OSVersions[j].HostsCount
|
|
})
|
|
}
|
|
|
|
count = len(osVersions.OSVersions)
|
|
|
|
var metaData *fleet.PaginationMetadata
|
|
osVersions.OSVersions, metaData = paginateOSVersions(osVersions.OSVersions, opts)
|
|
|
|
return osVersions, count, metaData, nil
|
|
}
|
|
|
|
func paginateOSVersions(slice []fleet.OSVersion, opts fleet.ListOptions) ([]fleet.OSVersion, *fleet.PaginationMetadata) {
|
|
metaData := &fleet.PaginationMetadata{
|
|
HasPreviousResults: opts.Page > 0,
|
|
}
|
|
|
|
if opts.PerPage == 0 {
|
|
return slice, metaData
|
|
}
|
|
|
|
start := opts.Page * opts.PerPage
|
|
if start >= uint(len(slice)) {
|
|
return []fleet.OSVersion{}, metaData
|
|
}
|
|
|
|
end := start + opts.PerPage
|
|
if end >= uint(len(slice)) {
|
|
end = uint(len(slice))
|
|
} else {
|
|
metaData.HasNextResults = true
|
|
}
|
|
|
|
return slice[start:end], metaData
|
|
}
|
|
|
|
type getOSVersionRequest struct {
|
|
ID uint `url:"id"`
|
|
TeamID *uint `query:"team_id,optional"`
|
|
}
|
|
|
|
type getOSVersionResponse struct {
|
|
CountsUpdatedAt *time.Time `json:"counts_updated_at"`
|
|
OSVersion *fleet.OSVersion `json:"os_version"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getOSVersionResponse) Error() error { return r.Err }
|
|
|
|
func getOSVersionEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getOSVersionRequest)
|
|
|
|
osVersion, updateTime, err := svc.OSVersion(ctx, req.ID, req.TeamID, false)
|
|
if err != nil {
|
|
return getOSVersionResponse{Err: err}, nil
|
|
}
|
|
if osVersion == nil {
|
|
osVersion = &fleet.OSVersion{}
|
|
}
|
|
|
|
return getOSVersionResponse{CountsUpdatedAt: updateTime, OSVersion: osVersion}, nil
|
|
}
|
|
|
|
func (svc *Service) OSVersion(ctx context.Context, osID uint, teamID *uint, includeCVSS bool) (*fleet.OSVersion, *time.Time, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{TeamID: teamID}, fleet.ActionList); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
if teamID != nil && *teamID != 0 {
|
|
// This auth check ensures we return 403 if the user doesn't have access to the team
|
|
if err := svc.authz.Authorize(ctx, &fleet.AuthzSoftwareInventory{TeamID: teamID}, fleet.ActionRead); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
exists, err := svc.ds.TeamExists(ctx, *teamID)
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "checking if team exists")
|
|
} else if !exists {
|
|
return nil, nil, fleet.NewInvalidArgumentError("team_id", fmt.Sprintf("team %d does not exist", *teamID)).
|
|
WithStatus(http.StatusNotFound)
|
|
}
|
|
}
|
|
|
|
vc, ok := viewer.FromContext(ctx)
|
|
if !ok {
|
|
return nil, nil, fleet.ErrNoContext
|
|
}
|
|
osVersion, updateTime, err := svc.ds.OSVersion(
|
|
ctx, osID, &fleet.TeamFilter{
|
|
User: vc.User,
|
|
IncludeObserver: true,
|
|
TeamID: teamID,
|
|
},
|
|
)
|
|
if err != nil {
|
|
if fleet.IsNotFound(err) {
|
|
// We return an empty result here to be consistent with the fleet/os_versions behavior.
|
|
// It is possible the os version exists, but the aggregation job has not run yet.
|
|
return nil, nil, nil
|
|
}
|
|
return nil, nil, err
|
|
}
|
|
|
|
if osVersion != nil {
|
|
if err = svc.populateOSVersionDetails(ctx, osVersion, includeCVSS); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
return osVersion, updateTime, nil
|
|
}
|
|
|
|
// PopulateOSVersionDetails populates the GeneratedCPEs and Vulnerabilities for an OSVersion.
|
|
func (svc *Service) populateOSVersionDetails(ctx context.Context, osVersion *fleet.OSVersion, includeCVSS bool) error {
|
|
// Populate GeneratedCPEs
|
|
if osVersion.Platform == "darwin" {
|
|
osVersion.GeneratedCPEs = []string{
|
|
fmt.Sprintf("cpe:2.3:o:apple:macos:%s:*:*:*:*:*:*:*", osVersion.Version),
|
|
fmt.Sprintf("cpe:2.3:o:apple:mac_os_x:%s:*:*:*:*:*:*:*", osVersion.Version),
|
|
}
|
|
}
|
|
|
|
// Populate Vulnerabilities
|
|
vulns, err := svc.ds.ListVulnsByOsNameAndVersion(ctx, osVersion.NameOnly, osVersion.Version, includeCVSS)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
|
|
osVersion.Vulnerabilities = make(fleet.Vulnerabilities, 0) // avoid null in JSON
|
|
for _, vuln := range vulns {
|
|
vuln.DetailsLink = fmt.Sprintf("https://nvd.nist.gov/vuln/detail/%s", vuln.CVE)
|
|
osVersion.Vulnerabilities = append(osVersion.Vulnerabilities, vuln)
|
|
}
|
|
return nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Encryption Key
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostEncryptionKeyRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type getHostEncryptionKeyResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
EncryptionKey *fleet.HostDiskEncryptionKey `json:"encryption_key,omitempty"`
|
|
HostID uint `json:"host_id,omitempty"`
|
|
}
|
|
|
|
func (r getHostEncryptionKeyResponse) Error() error { return r.Err }
|
|
|
|
func getHostEncryptionKey(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getHostEncryptionKeyRequest)
|
|
key, err := svc.HostEncryptionKey(ctx, req.ID)
|
|
if err != nil {
|
|
return getHostEncryptionKeyResponse{Err: err}, nil
|
|
}
|
|
return getHostEncryptionKeyResponse{EncryptionKey: key, HostID: req.ID}, nil
|
|
}
|
|
|
|
func (svc *Service) HostEncryptionKey(ctx context.Context, id uint) (*fleet.HostDiskEncryptionKey, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
host, err := svc.ds.HostLite(ctx, id)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting host encryption key")
|
|
}
|
|
|
|
// Permissions to read encryption keys are exactly the same
|
|
// as the ones required to read hosts.
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
level.Info(svc.logger).Log("msg", "retrieving host disk encryption key", "host_id", host.ID, "host_name", host.DisplayName())
|
|
key, err := svc.getHostDiskEncryptionKey(ctx, host)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting host encryption key")
|
|
}
|
|
|
|
err = svc.NewActivity(
|
|
ctx,
|
|
authz.UserFromContext(ctx),
|
|
fleet.ActivityTypeReadHostDiskEncryptionKey{
|
|
HostID: host.ID,
|
|
HostDisplayName: host.DisplayName(),
|
|
},
|
|
)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "create read host disk encryption key activity")
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
func (svc *Service) getHostDiskEncryptionKey(ctx context.Context, host *fleet.Host) (*fleet.HostDiskEncryptionKey, error) {
|
|
// First, determine the decryption function based on the host platform and configuration.
|
|
var decryptFn func(b64 string) (string, error)
|
|
switch {
|
|
case host.IsLUKSSupported():
|
|
if svc.config.Server.PrivateKey == "" {
|
|
return nil, errors.New("private key is unavailable")
|
|
}
|
|
decryptFn = func(b64 string) (string, error) {
|
|
return mdm.DecodeAndDecrypt(b64, svc.config.Server.PrivateKey)
|
|
}
|
|
case host.FleetPlatform() == "windows":
|
|
if err := svc.VerifyMDMWindowsConfigured(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
cert, _, _, err := svc.config.MDM.MicrosoftWSTEP()
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting Microsoft WSTEP certificate to decrypt key")
|
|
}
|
|
decryptFn = func(b64 string) (string, error) {
|
|
b, err := mdm.DecryptBase64CMS(b64, cert.Leaf, cert.PrivateKey)
|
|
return string(b), err
|
|
}
|
|
default:
|
|
// Fallback to using Apple MDM CA assets for decryption.
|
|
if err := svc.VerifyMDMAppleConfigured(ctx); err != nil {
|
|
return nil, err
|
|
}
|
|
cert, err := assets.CAKeyPair(ctx, svc.ds)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "loading existing assets from the database")
|
|
}
|
|
decryptFn = func(b64 string) (string, error) {
|
|
b, err := mdm.DecryptBase64CMS(b64, cert.Leaf, cert.PrivateKey)
|
|
return string(b), err
|
|
}
|
|
}
|
|
|
|
// Next, get host disk encryption key and archived key, if any
|
|
key, err := svc.ds.GetHostDiskEncryptionKey(ctx, host.ID)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting host encryption key")
|
|
}
|
|
archivedKey, err := svc.ds.GetHostArchivedDiskEncryptionKey(ctx, host)
|
|
if err != nil && !fleet.IsNotFound(err) {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting host archived disk encryption key")
|
|
}
|
|
if key == nil && archivedKey == nil {
|
|
return nil, ctxerr.Wrap(ctx, newNotFoundError(), "host encryption key is not set")
|
|
}
|
|
|
|
var decrypted string
|
|
var decryptErrs []error
|
|
if key != nil && key.Base64Encrypted != "" {
|
|
// Assume the current key is not decryptable.
|
|
key.Decryptable = ptr.Bool(false)
|
|
key.DecryptedValue = ""
|
|
|
|
// Try to decrypt the current key.
|
|
decrypted, err = decryptFn(key.Base64Encrypted)
|
|
switch {
|
|
case err != nil:
|
|
decryptErrs = append(decryptErrs, fmt.Errorf("decrypting host disk encryption key: %w", err))
|
|
case decrypted == "":
|
|
decryptErrs = append(decryptErrs, fmt.Errorf("decrypted host disk encryption key is empty for host %d", host.ID))
|
|
default:
|
|
level.Info(svc.logger).Log("msg", "decrypted current host disk encryption key", "host_id", host.ID)
|
|
key.Decryptable = ptr.Bool(true)
|
|
key.DecryptedValue = decrypted
|
|
|
|
return key, nil // Return the decrypted key immediately if successful.
|
|
}
|
|
}
|
|
|
|
// If we have an archived key, try to decrypt it.
|
|
if archivedKey != nil && archivedKey.Base64Encrypted != "" {
|
|
decrypted, err = decryptFn(archivedKey.Base64Encrypted)
|
|
switch {
|
|
case err != nil:
|
|
decryptErrs = append(decryptErrs, fmt.Errorf("decrypting archived disk encryption key: %w", err))
|
|
case decrypted == "":
|
|
decryptErrs = append(decryptErrs, fmt.Errorf("decrypted archived disk encryption key is empty for host %d", host.ID))
|
|
default:
|
|
level.Info(svc.logger).Log("msg", "decrypted archived host disk encryption key", "host_id", host.ID)
|
|
|
|
// We successfully decrypted the archived key so we'll use it in place of the current key.
|
|
key = &fleet.HostDiskEncryptionKey{
|
|
HostID: host.ID,
|
|
Base64Encrypted: archivedKey.Base64Encrypted,
|
|
Base64EncryptedSalt: archivedKey.Base64EncryptedSalt,
|
|
KeySlot: archivedKey.KeySlot,
|
|
Decryptable: ptr.Bool(true),
|
|
DecryptedValue: decrypted,
|
|
UpdatedAt: archivedKey.CreatedAt,
|
|
}
|
|
}
|
|
}
|
|
|
|
if len(decryptErrs) > 0 {
|
|
// If we have any decryption errors, log them.
|
|
level.Error(svc.logger).Log("msg", "decryption errors for host disk encryption key", "host_id", host.ID, "errors", errors.Join(decryptErrs...))
|
|
}
|
|
|
|
if key == nil || key.DecryptedValue == "" {
|
|
// If we couldn't decrypt any key, return an error.
|
|
return nil, ctxerr.Wrap(ctx, newNotFoundError(), "host encryption key")
|
|
}
|
|
|
|
return key, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Host Health
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostHealthRequest struct {
|
|
ID uint `url:"id"`
|
|
}
|
|
|
|
type getHostHealthResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
HostID uint `json:"host_id,omitempty"`
|
|
HostHealth *fleet.HostHealth `json:"health,omitempty"`
|
|
}
|
|
|
|
func (r getHostHealthResponse) Error() error { return r.Err }
|
|
|
|
func getHostHealthEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getHostHealthRequest)
|
|
hh, err := svc.GetHostHealth(ctx, req.ID)
|
|
if err != nil {
|
|
return getHostHealthResponse{Err: err}, nil
|
|
}
|
|
|
|
// remove TeamID as it's needed for authorization internally but is not part of the external API
|
|
hh.TeamID = nil
|
|
|
|
return getHostHealthResponse{HostID: req.ID, HostHealth: hh}, nil
|
|
}
|
|
|
|
func (svc *Service) GetHostHealth(ctx context.Context, id uint) (*fleet.HostHealth, error) {
|
|
svc.authz.SkipAuthorization(ctx)
|
|
hh, err := svc.ds.GetHostHealth(ctx, id)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, hh, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return hh, nil
|
|
}
|
|
|
|
func (svc *Service) HostLiteByIdentifier(ctx context.Context, identifier string) (*fleet.HostLite, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
host, err := svc.ds.HostLiteByIdentifier(ctx, identifier)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host by identifier")
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, fleet.Host{
|
|
TeamID: host.TeamID,
|
|
}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return host, nil
|
|
}
|
|
|
|
func (svc *Service) HostLiteByID(ctx context.Context, id uint) (*fleet.HostLite, error) {
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
host, err := svc.ds.HostLiteByID(ctx, id)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "get host by id")
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, fleet.Host{
|
|
TeamID: host.TeamID,
|
|
}, fleet.ActionRead); err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
return host, nil
|
|
}
|
|
|
|
func hostListOptionsFromFilters(filter *map[string]interface{}) (*fleet.HostListOptions, *uint, error) {
|
|
var labelID *uint
|
|
|
|
if filter == nil {
|
|
return nil, nil, nil
|
|
}
|
|
|
|
opt := fleet.HostListOptions{}
|
|
|
|
for k, v := range *filter {
|
|
switch k {
|
|
case "label_id":
|
|
if l, ok := v.(float64); ok { // json unmarshals numbers as float64
|
|
lid := uint(l)
|
|
labelID = &lid
|
|
} else {
|
|
return nil, nil, badRequest("label_id must be a number")
|
|
}
|
|
case "team_id":
|
|
if teamID, ok := v.(float64); ok { // json unmarshals numbers as float64
|
|
teamID := uint(teamID)
|
|
opt.TeamFilter = &teamID
|
|
} else {
|
|
return nil, nil, badRequest("team_id must be a number")
|
|
}
|
|
case "status":
|
|
status, ok := v.(string)
|
|
if !ok {
|
|
return nil, nil, badRequest("status must be a string")
|
|
}
|
|
if !fleet.HostStatus(status).IsValid() {
|
|
return nil, nil, badRequest("status must be one of: new, online, offline, missing")
|
|
}
|
|
opt.StatusFilter = fleet.HostStatus(status)
|
|
case "query":
|
|
query, ok := v.(string)
|
|
if !ok {
|
|
return nil, nil, badRequest("query must be a string")
|
|
}
|
|
if query == "" {
|
|
return nil, nil, badRequest("query must not be empty")
|
|
}
|
|
opt.MatchQuery = query
|
|
|
|
default:
|
|
return nil, nil, badRequest(fmt.Sprintf("unknown filter key: %s", k))
|
|
}
|
|
}
|
|
|
|
return &opt, labelID, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Host Labels
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type addLabelsToHostRequest struct {
|
|
ID uint `url:"id"`
|
|
Labels []string `json:"labels"`
|
|
}
|
|
|
|
type addLabelsToHostResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r addLabelsToHostResponse) Error() error { return r.Err }
|
|
|
|
func addLabelsToHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*addLabelsToHostRequest)
|
|
if err := svc.AddLabelsToHost(ctx, req.ID, req.Labels); err != nil {
|
|
return addLabelsToHostResponse{Err: err}, nil
|
|
}
|
|
return addLabelsToHostResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) AddLabelsToHost(ctx context.Context, id uint, labelNames []string) error {
|
|
host, err := svc.ds.HostLite(ctx, id)
|
|
if err != nil {
|
|
svc.authz.SkipAuthorization(ctx)
|
|
return ctxerr.Wrap(ctx, err, "load host")
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionWriteHostLabel); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
labelIDs, err := svc.validateLabelNames(ctx, "add", labelNames)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(labelIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if err := svc.ds.AddLabelsToHost(ctx, host.ID, labelIDs); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "add labels to host")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
type removeLabelsFromHostRequest struct {
|
|
ID uint `url:"id"`
|
|
Labels []string `json:"labels"`
|
|
}
|
|
|
|
type removeLabelsFromHostResponse struct {
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r removeLabelsFromHostResponse) Error() error { return r.Err }
|
|
|
|
func removeLabelsFromHostEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*removeLabelsFromHostRequest)
|
|
if err := svc.RemoveLabelsFromHost(ctx, req.ID, req.Labels); err != nil {
|
|
return removeLabelsFromHostResponse{Err: err}, nil
|
|
}
|
|
return removeLabelsFromHostResponse{}, nil
|
|
}
|
|
|
|
func (svc *Service) RemoveLabelsFromHost(ctx context.Context, id uint, labelNames []string) error {
|
|
host, err := svc.ds.HostLite(ctx, id)
|
|
if err != nil {
|
|
svc.authz.SkipAuthorization(ctx)
|
|
return ctxerr.Wrap(ctx, err, "load host")
|
|
}
|
|
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionWriteHostLabel); err != nil {
|
|
return ctxerr.Wrap(ctx, err)
|
|
}
|
|
|
|
labelIDs, err := svc.validateLabelNames(ctx, "remove", labelNames)
|
|
if err != nil {
|
|
return err
|
|
}
|
|
if len(labelIDs) == 0 {
|
|
return nil
|
|
}
|
|
|
|
if err := svc.ds.RemoveLabelsFromHost(ctx, host.ID, labelIDs); err != nil {
|
|
return ctxerr.Wrap(ctx, err, "remove labels from host")
|
|
}
|
|
|
|
return nil
|
|
}
|
|
|
|
func (svc *Service) validateLabelNames(ctx context.Context, action string, labelNames []string) ([]uint, error) {
|
|
if len(labelNames) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
labelNames = server.RemoveDuplicatesFromSlice(labelNames)
|
|
|
|
// Filter out empty label string.
|
|
for i, labelName := range labelNames {
|
|
if labelName == "" {
|
|
labelNames = append(labelNames[:i], labelNames[i+1:]...)
|
|
break
|
|
}
|
|
}
|
|
if len(labelNames) == 0 {
|
|
return nil, nil
|
|
}
|
|
|
|
labels, err := svc.ds.LabelIDsByName(ctx, labelNames)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "getting label IDs by name")
|
|
}
|
|
|
|
var labelsNotFound []string
|
|
for _, labelName := range labelNames {
|
|
if _, ok := labels[labelName]; !ok {
|
|
labelsNotFound = append(labelsNotFound, "\""+labelName+"\"")
|
|
}
|
|
}
|
|
|
|
if len(labelsNotFound) > 0 {
|
|
sort.Slice(labelsNotFound, func(i, j int) bool {
|
|
// Ignore quotes to sort.
|
|
return labelsNotFound[i][1:len(labelsNotFound[i])-1] < labelsNotFound[j][1:len(labelsNotFound[j])-1]
|
|
})
|
|
return nil, &fleet.BadRequestError{
|
|
Message: fmt.Sprintf(
|
|
"Couldn't %s labels. Labels not found: %s. All labels must exist.",
|
|
action,
|
|
strings.Join(labelsNotFound, ", "),
|
|
),
|
|
}
|
|
}
|
|
|
|
var dynamicLabels []string
|
|
for labelName, labelID := range labels {
|
|
// we use a global admin filter because we want to get that label
|
|
// regardless of user roles
|
|
filter := fleet.TeamFilter{User: &fleet.User{GlobalRole: ptr.String(fleet.RoleAdmin)}}
|
|
label, _, err := svc.ds.Label(ctx, labelID, filter)
|
|
if err != nil {
|
|
return nil, ctxerr.Wrap(ctx, err, "load label from id")
|
|
}
|
|
if label.LabelMembershipType != fleet.LabelMembershipTypeManual {
|
|
dynamicLabels = append(dynamicLabels, "\""+labelName+"\"")
|
|
}
|
|
}
|
|
|
|
if len(dynamicLabels) > 0 {
|
|
sort.Slice(dynamicLabels, func(i, j int) bool {
|
|
// Ignore quotes to sort.
|
|
return dynamicLabels[i][1:len(dynamicLabels[i])-1] < dynamicLabels[j][1:len(dynamicLabels[j])-1]
|
|
})
|
|
return nil, &fleet.BadRequestError{
|
|
Message: fmt.Sprintf(
|
|
"Couldn't %s labels. Labels are dynamic: %s. Dynamic labels can not be assigned to hosts manually.",
|
|
action,
|
|
strings.Join(dynamicLabels, ", "),
|
|
),
|
|
}
|
|
}
|
|
|
|
labelIDs := make([]uint, 0, len(labels))
|
|
for _, labelID := range labels {
|
|
labelIDs = append(labelIDs, labelID)
|
|
}
|
|
|
|
return labelIDs, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Host Software
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type getHostSoftwareRequest struct {
|
|
ID uint `url:"id"`
|
|
fleet.HostSoftwareTitleListOptions
|
|
}
|
|
|
|
type getHostSoftwareResponse struct {
|
|
Software []*fleet.HostSoftwareWithInstaller `json:"software"`
|
|
Count int `json:"count"`
|
|
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r getHostSoftwareResponse) Error() error { return r.Err }
|
|
|
|
func (r getHostSoftwareRequest) DecodeRequest(ctx context.Context, req *http.Request) (interface{}, error) {
|
|
type defaultDecodeRequest struct {
|
|
ID uint `url:"id"`
|
|
fleet.HostSoftwareTitleListOptions
|
|
}
|
|
|
|
defaultDecoder := makeDecoder(defaultDecodeRequest{})
|
|
decoded, err := defaultDecoder(ctx, req)
|
|
if err != nil {
|
|
return nil, err
|
|
}
|
|
|
|
result := decoded.(*defaultDecodeRequest)
|
|
queryParams := req.URL.Query()
|
|
_, wasIncludeAvailableForInstallSet := queryParams["include_available_for_install"]
|
|
result.HostSoftwareTitleListOptions.IncludeAvailableForInstallExplicitlySet = wasIncludeAvailableForInstallSet
|
|
|
|
finalResult := getHostSoftwareRequest{
|
|
ID: result.ID,
|
|
HostSoftwareTitleListOptions: result.HostSoftwareTitleListOptions,
|
|
}
|
|
|
|
return &finalResult, nil
|
|
}
|
|
|
|
func getHostSoftwareEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*getHostSoftwareRequest)
|
|
res, meta, err := svc.ListHostSoftware(ctx, req.ID, req.HostSoftwareTitleListOptions)
|
|
if err != nil {
|
|
return getHostSoftwareResponse{Err: err}, nil
|
|
}
|
|
if res == nil {
|
|
res = []*fleet.HostSoftwareWithInstaller{}
|
|
}
|
|
return getHostSoftwareResponse{Software: res, Meta: meta, Count: int(meta.TotalResults)}, nil //nolint:gosec // dismiss G115
|
|
}
|
|
|
|
func (svc *Service) ListHostSoftware(ctx context.Context, hostID uint, opts fleet.HostSoftwareTitleListOptions) ([]*fleet.HostSoftwareWithInstaller, *fleet.PaginationMetadata, error) {
|
|
// When accessed via "My device", we default to only showing inventory (excluding software available for install
|
|
// but not in inventory), unless we're asked to filter to self-service software only.
|
|
//
|
|
// Otherwise (e.g. host software UI within Fleet's admin interface), the default is to show both installed and
|
|
// available-for-install software, to maintain existing API behavior. This behavior can be explicitly overridden
|
|
// if needed (see opts.IncludeAvailableForInstallExplicitlySet).
|
|
var includeAvailableForInstall bool
|
|
|
|
var host *fleet.Host
|
|
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
|
|
includeAvailableForInstall = true
|
|
|
|
if err := svc.authz.Authorize(ctx, &fleet.Host{}, fleet.ActionList); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
h, err := svc.ds.HostLite(ctx, hostID)
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "get host lite")
|
|
}
|
|
host = h
|
|
|
|
// Authorize again with team loaded now that we have team_id
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
} else {
|
|
h, ok := hostctx.FromContext(ctx)
|
|
if !ok {
|
|
return nil, nil, ctxerr.Wrap(ctx, fleet.NewAuthRequiredError("internal error: missing host from request context"))
|
|
}
|
|
host = h
|
|
}
|
|
|
|
mdmEnrolled, err := svc.ds.IsHostConnectedToFleetMDM(ctx, host)
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "checking mdm enrollment status")
|
|
}
|
|
|
|
if opts.IncludeAvailableForInstallExplicitlySet {
|
|
includeAvailableForInstall = opts.IncludeAvailableForInstall
|
|
}
|
|
|
|
// cursor-based pagination is not supported
|
|
opts.ListOptions.After = ""
|
|
// custom ordering is not supported, always by name (but asc/desc is configurable)
|
|
opts.ListOptions.OrderKey = "name"
|
|
// always include metadata
|
|
opts.ListOptions.IncludeMetadata = true
|
|
opts.IncludeAvailableForInstall = includeAvailableForInstall || opts.SelfServiceOnly
|
|
opts.IsMDMEnrolled = mdmEnrolled
|
|
|
|
software, meta, err := svc.ds.ListHostSoftware(ctx, host, opts)
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "list host software")
|
|
}
|
|
|
|
if len(software) > 0 {
|
|
var titleIDs []uint
|
|
softwareByTitleID := make(map[uint]*fleet.HostSoftwareWithInstaller)
|
|
for _, s := range software {
|
|
titleIDs = append(titleIDs, s.ID)
|
|
softwareByTitleID[s.ID] = s
|
|
}
|
|
categories, err := svc.ds.GetCategoriesForSoftwareTitles(ctx, titleIDs, host.TeamID)
|
|
if err != nil {
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "getting categories for software titles")
|
|
}
|
|
|
|
for id, c := range categories {
|
|
if s, ok := softwareByTitleID[id]; ok && s.IsAppStoreApp() {
|
|
softwareByTitleID[id].AppStoreApp.Categories = c
|
|
}
|
|
if s, ok := softwareByTitleID[id]; ok && s.IsPackage() {
|
|
softwareByTitleID[id].SoftwarePackage.Categories = c
|
|
}
|
|
|
|
}
|
|
}
|
|
|
|
return software, meta, nil
|
|
}
|
|
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
// Host Certificates
|
|
////////////////////////////////////////////////////////////////////////////////
|
|
|
|
type listHostCertificatesRequest struct {
|
|
ID uint `url:"id"`
|
|
fleet.ListOptions
|
|
}
|
|
|
|
var listHostCertificatesSortCols = map[string]bool{
|
|
"common_name": true,
|
|
"not_valid_after": true,
|
|
}
|
|
|
|
func (r *listHostCertificatesRequest) ValidateRequest() error {
|
|
if r.ListOptions.OrderKey != "" && !listHostCertificatesSortCols[r.ListOptions.OrderKey] {
|
|
return badRequest("invalid order key")
|
|
}
|
|
return nil
|
|
}
|
|
|
|
type listHostCertificatesResponse struct {
|
|
Certificates []*fleet.HostCertificatePayload `json:"certificates"`
|
|
Meta *fleet.PaginationMetadata `json:"meta,omitempty"`
|
|
Err error `json:"error,omitempty"`
|
|
}
|
|
|
|
func (r listHostCertificatesResponse) Error() error { return r.Err }
|
|
|
|
func listHostCertificatesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (fleet.Errorer, error) {
|
|
req := request.(*listHostCertificatesRequest)
|
|
res, meta, err := svc.ListHostCertificates(ctx, req.ID, req.ListOptions)
|
|
if err != nil {
|
|
return listHostCertificatesResponse{Err: err}, nil
|
|
}
|
|
if res == nil {
|
|
res = []*fleet.HostCertificatePayload{}
|
|
}
|
|
return listHostCertificatesResponse{Certificates: res, Meta: meta}, nil
|
|
}
|
|
|
|
func (svc *Service) ListHostCertificates(ctx context.Context, hostID uint, opts fleet.ListOptions) ([]*fleet.HostCertificatePayload, *fleet.PaginationMetadata, error) {
|
|
if !svc.authz.IsAuthenticatedWith(ctx, authzctx.AuthnDeviceToken) {
|
|
host, err := svc.ds.HostLite(ctx, hostID)
|
|
if err != nil {
|
|
svc.authz.SkipAuthorization(ctx)
|
|
return nil, nil, ctxerr.Wrap(ctx, err, "failed to load host")
|
|
}
|
|
if err := svc.authz.Authorize(ctx, host, fleet.ActionRead); err != nil {
|
|
return nil, nil, err
|
|
}
|
|
}
|
|
|
|
// query/after not supported, always include pagination info
|
|
opts.MatchQuery = ""
|
|
opts.After = ""
|
|
opts.IncludeMetadata = true
|
|
// default sort order is common name ascending
|
|
if opts.OrderKey == "" {
|
|
opts.OrderKey = "common_name"
|
|
}
|
|
|
|
certs, meta, err := svc.ds.ListHostCertificates(ctx, hostID, opts)
|
|
if err != nil {
|
|
return nil, nil, err
|
|
}
|
|
|
|
payload := make([]*fleet.HostCertificatePayload, 0, len(certs))
|
|
for _, cert := range certs {
|
|
payload = append(payload, cert.ToPayload())
|
|
}
|
|
return payload, meta, nil
|
|
}
|