fleetctl, API, copy updates around host identifiers (#20220)

## Addresses #19127 
![Screenshot 2024-07-08 at 4 49
33 PM](https://github.com/fleetdm/fleet/assets/61553566/b4704eb9-9707-4cbf-8959-ec67dde57103)
- Also replace all ocurrences of "comma separated" with
"comma-separated"

- [x] Changes file added for user-visible changes in `changes/`
- [x] `SELECT *` is avoided, SQL injection is prevented (using
placeholders for values in statements)
- [x] Added/updated tests
- [x] Manual QA for all new/changed functionality

---------

Co-authored-by: Jacob Shandling <jacob@fleetdm.com>
This commit is contained in:
jacobshandling
2024-07-09 10:25:01 -07:00
committed by GitHub
co-authored by Jacob Shandling
parent 812140a760
commit ec11e3d1d0
29 changed files with 139 additions and 106 deletions
@@ -0,0 +1,2 @@
- Update `fleetctl query --hosts` to work with hostnames, host UUIDs, and/or hardware serial numbers.
- Clarify various help and error texts around host identifiers.
+1 -1
View File
@@ -36,7 +36,7 @@ func transferCommand() *cli.Command {
},
&cli.StringSliceFlag{
Name: hostsFlagName,
Usage: "Comma separated hostnames to transfer",
Usage: "Comma-separated hostnames to transfer",
},
&cli.StringFlag{
Name: labelFlagName,
+7 -11
View File
@@ -41,7 +41,7 @@ func mdmRunCommand() *cli.Command {
debugFlag(),
&cli.StringSliceFlag{
Name: "hosts",
Usage: "Hosts specified by hostname, serial number, uuid, osquery_host_id or node_key that you want to target.",
Usage: "Comma-separated hosts to target. Hosts can be specified by hostname, UUID, or serial number.",
Required: true,
},
&cli.StringFlag{
@@ -122,13 +122,9 @@ func mdmRunCommand() *cli.Command {
hostUUIDs = append(hostUUIDs, host.UUID)
}
if len(hostUUIDs) == 0 {
// all hosts were not found
return errors.New("No hosts targeted. Make sure you provide a valid hostname, UUID, osquery host ID, or node key.")
}
if notFoundCount > 0 {
// at least one was not found
return errors.New("One or more targeted hosts don't exist. Make sure you provide a valid hostname, UUID, osquery host ID, or node key.")
if len(hostUUIDs) == 0 || notFoundCount > 0 {
// Either no hosts were targeted, or at least one targeted host was not found
return errors.New(fleet.TargetedHostsDontExistErrMsg)
}
result, err := client.RunMDMCommand(hostUUIDs, payload, mdmPlatform)
@@ -171,7 +167,7 @@ func mdmLockCommand() *cli.Command {
Usage: "Lock a host when it needs to be returned to your organization.",
Flags: []cli.Flag{contextFlag(), debugFlag(), &cli.StringFlag{
Name: "host",
Usage: "The host, specified by identifier, that you want to lock.",
Usage: "The host, specified by hostname, UUID, or serial number.",
Required: true,
}},
Action: func(c *cli.Context) error {
@@ -210,7 +206,7 @@ func mdmUnlockCommand() *cli.Command {
Usage: "Unlock a host when it needs to be returned to your organization.",
Flags: []cli.Flag{contextFlag(), debugFlag(), &cli.StringFlag{
Name: "host",
Usage: "The host, specified by identifier, that you want to unlock.",
Usage: "The host, specified by hostname, UUID, or serial number.",
Required: true,
}},
Action: func(c *cli.Context) error {
@@ -312,7 +308,7 @@ func hostMdmActionSetup(c *cli.Context, hostIdent string, actionType string) (cl
if err != nil {
var nfe service.NotFoundErr
if errors.As(err, &nfe) {
return nil, nil, errors.New("The host doesn't exist. Please provide a valid host identifier.")
return nil, nil, errors.New(fleet.HostNotFoundErrMsg)
}
var sce kithttp.StatusCoder
+5 -6
View File
@@ -291,7 +291,7 @@ func TestMDMRunCommand(t *testing.T) {
{"macOS yaml payload", []string{"--hosts", "mac-enrolled", "--payload", yamlFilePath}, appCfgAllMDM, `The payload isn't valid XML`},
{"win yaml payload", []string{"--hosts", "win-enrolled", "--payload", yamlFilePath}, appCfgAllMDM, `The payload isn't valid XML`},
{"non-mdm-command plist payload", []string{"--hosts", "mac-enrolled", "--payload", mobileConfigFilePath}, appCfgAllMDM, `The payload isn't valid. Please provide a valid MDM command in the form of a plist-encoded XML file:`},
{"single host not found", []string{"--hosts", "no-such-host", "--payload", appleCmdFilePath}, appCfgAllMDM, `No hosts targeted.`},
{"single host not found", []string{"--hosts", "no-such-host", "--payload", appleCmdFilePath}, appCfgAllMDM, fleet.TargetedHostsDontExistErrMsg},
{"unenrolled macOS host", []string{"--hosts", "mac-unenrolled", "--payload", appleCmdFilePath}, appCfgAllMDM, `Can't run the MDM command because one or more hosts have MDM turned off.`},
{"unenrolled windows host", []string{"--hosts", "win-unenrolled", "--payload", winCmdFilePath}, appCfgAllMDM, `Can't run the MDM command because one or more hosts have MDM turned off.`},
{"macOS non-fleet host", []string{"--hosts", "mac-non-fleet-enrolled", "--payload", appleCmdFilePath}, appCfgAllMDM, `Can't run the MDM command because one or more hosts have MDM turned off.`},
@@ -319,7 +319,7 @@ func TestMDMRunCommand(t *testing.T) {
{"non-Exec win file", []string{"--hosts", "win-enrolled", "--payload", nonExecWinCmdFilePath.Name()}, appCfgAllMDM, `You can run only <Exec> command type.`},
{"empty win file", []string{"--hosts", "win-enrolled", "--payload", emptyWinCmdFilePath.Name()}, appCfgAllMDM, `You can run only a single <Exec> command.`},
{"hosts with different platforms", []string{"--hosts", "win-enrolled,mac-enrolled", "--payload", winCmdFilePath}, appCfgAllMDM, `Command can't run on hosts with different platforms.`},
{"all hosts not found", []string{"--hosts", "no-such-1,no-such-2,no-such-3", "--payload", winCmdFilePath}, appCfgAllMDM, `No hosts targeted.`},
{"all hosts not found", []string{"--hosts", "no-such-1,no-such-2,no-such-3", "--payload", winCmdFilePath}, appCfgAllMDM, fleet.TargetedHostsDontExistErrMsg},
{"one host not found", []string{"--hosts", "win-enrolled,no-such-2,win-enrolled-2", "--payload", winCmdFilePath}, appCfgAllMDM, `One or more targeted hosts don't exist.`},
{"one windows host not enrolled", []string{"--hosts", "win-enrolled,win-unenrolled,win-enrolled-2", "--payload", winCmdFilePath}, appCfgAllMDM, `Can't run the MDM command because one or more hosts have MDM turned off.`},
{"one macOS host not enrolled", []string{"--hosts", "mac-enrolled,mac-unenrolled,mac-enrolled-2", "--payload", appleCmdFilePath}, appCfgAllMDM, `Can't run the MDM command because one or more hosts have MDM turned off.`},
@@ -350,7 +350,6 @@ func TestMDMRunCommand(t *testing.T) {
}
func TestMDMLockCommand(t *testing.T) {
macEnrolled := testhost{
host: &fleet.Host{
ID: 1,
@@ -590,7 +589,7 @@ fleetctl mdm unlock --host=%s
}{
{appCfgAllMDM, "no flags", nil, `Required flag "host" not set`},
{appCfgAllMDM, "host flag empty", []string{"--host", ""}, `No host targeted. Please provide --host.`},
{appCfgAllMDM, "lock non-existent host", []string{"--host", "notfound"}, `The host doesn't exist. Please provide a valid host identifier.`},
{appCfgAllMDM, "lock non-existent host", []string{"--host", "notfound"}, fleet.HostNotFoundErrMsg},
{appCfgMacMDM, "valid windows but only macos mdm", []string{"--host", winEnrolled.host.UUID}, `Windows MDM isn't turned on.`},
{appCfgWinMDM, "valid macos but only windows mdm", []string{"--host", macEnrolled.host.UUID}, `macOS MDM isn't turned on.`},
{appCfgAllMDM, "valid windows", []string{"--host", winEnrolled.host.UUID}, ""},
@@ -866,7 +865,7 @@ fleetctl get host %s
}{
{appCfgAllMDM, "no flags", nil, `Required flag "host" not set`},
{appCfgAllMDM, "host flag empty", []string{"--host", ""}, `No host targeted. Please provide --host.`},
{appCfgAllMDM, "unlock non-existent host", []string{"--host", "notfound"}, `The host doesn't exist. Please provide a valid host identifier.`},
{appCfgAllMDM, "unlock non-existent host", []string{"--host", "notfound"}, fleet.HostNotFoundErrMsg},
{appCfgMacMDM, "valid windows but only macos mdm", []string{"--host", winEnrolled.host.UUID}, `Windows MDM isn't turned on.`},
{appCfgAllMDM, "valid windows", []string{"--host", winEnrolled.host.UUID}, ""},
{appCfgAllMDM, "valid macos", []string{"--host", macEnrolled.host.UUID}, ""},
@@ -1225,7 +1224,7 @@ func TestMDMWipeCommand(t *testing.T) {
}{
{appCfgAllMDM, "no flags", nil, `Required flag "host" not set`},
{appCfgAllMDM, "host flag empty", []string{"--host", ""}, `No host targeted. Please provide --host.`},
{appCfgAllMDM, "wipe non-existent host", []string{"--host", "notfound"}, `The host doesn't exist. Please provide a valid host identifier.`},
{appCfgAllMDM, "wipe non-existent host", []string{"--host", "notfound"}, fleet.HostNotFoundErrMsg},
{appCfgMacMDM, "valid windows but only macos mdm", []string{"--host", winEnrolled.host.UUID}, `Windows MDM isn't turned on.`},
{appCfgAllMDM, "valid windows", []string{"--host", winEnrolled.host.UUID}, ""},
{appCfgAllMDM, "valid macos", []string{"--host", macEnrolled.host.UUID}, ""},
+10 -6
View File
@@ -9,6 +9,7 @@ import (
"time"
"github.com/briandowns/spinner"
"github.com/fleetdm/fleet/v4/server/fleet"
"github.com/urfave/cli/v2"
)
@@ -28,14 +29,14 @@ func queryCommand() *cli.Command {
EnvVars: []string{"HOSTS"},
Value: "",
Destination: &flHosts,
Usage: "Comma separated hostnames to target",
Usage: "Comma-separated hosts to target. Hosts can be specified by hostname, UUID, or serial number.",
},
&cli.StringFlag{
Name: "labels",
EnvVars: []string{"LABELS"},
Value: "",
Destination: &flLabels,
Usage: "Comma separated label names to target",
Usage: "Comma-separated label names to target",
},
&cli.BoolFlag{
Name: "quiet",
@@ -84,7 +85,7 @@ func queryCommand() *cli.Command {
debugFlag(),
},
Action: func(c *cli.Context) error {
fleet, err := clientFromCLI(c)
client, err := clientFromCLI(c)
if err != nil {
return err
}
@@ -103,7 +104,7 @@ func queryCommand() *cli.Command {
if tid := c.Uint(teamFlagName); tid != 0 {
teamID = &tid
}
queries, err := fleet.GetQueries(teamID, &flQueryName)
queries, err := client.GetQueries(teamID, &flQueryName)
if err != nil || len(queries) == 0 {
return fmt.Errorf("Query '%s' not found", flQueryName)
}
@@ -131,11 +132,14 @@ func queryCommand() *cli.Command {
output = newJsonWriter(c.App.Writer)
}
hosts := strings.Split(flHosts, ",")
hostIdentifiers := strings.Split(flHosts, ",")
labels := strings.Split(flLabels, ",")
res, err := fleet.LiveQuery(flQuery, queryID, labels, hosts)
res, err := client.LiveQuery(flQuery, queryID, labels, hostIdentifiers)
if err != nil {
if strings.Contains(err.Error(), "no hosts targeted") {
return errors.New(fleet.NoHostsTargetedErrMsg)
}
return err
}
+7 -2
View File
@@ -48,7 +48,7 @@ func TestSavedLiveQuery(t *testing.T) {
Saved: true,
}
ds.HostIDsByNameFunc = func(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error) {
ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, hostIdentifiers []string) ([]uint, error) {
return []uint{1234}, nil
}
ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
@@ -149,6 +149,11 @@ func TestSavedLiveQuery(t *testing.T) {
))
}()
// errors before requesting live query
_, err = runAppNoChecks([]string{"query", "--hosts", "", "--query-name", queryName})
assert.Error(t, err)
assert.Contains(t, err.Error(), "No hosts or labels targeted")
expected := `{"host":"somehostname","rows":[{"bing":"fds","host_display_name":"somehostname","host_hostname":"somehostname"}]}
`
// Note: runAppForTest never closes the WebSocket connection and does not exit,
@@ -197,7 +202,7 @@ func TestAdHocLiveQuery(t *testing.T) {
}
}
ds.HostIDsByNameFunc = func(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error) {
ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, hostIdentifiers []string) ([]uint, error) {
return []uint{1234}, nil
}
ds.LabelIDsByNameFunc = func(ctx context.Context, labels []string) (map[string]uint, error) {
+2 -2
View File
@@ -37,7 +37,7 @@ func runScriptCommand() *cli.Command {
},
&cli.StringFlag{
Name: "host",
Usage: "A host, specified by hostname, serial number, UUID, osquery host ID, or node key.",
Usage: "The host, specified by hostname, UUID, or serial number.",
Required: true,
},
&cli.StringFlag{
@@ -108,7 +108,7 @@ func runScriptCommand() *cli.Command {
if err != nil {
var nfe service.NotFoundErr
if errors.As(err, &nfe) {
return errors.New(fleet.RunScriptHostNotFoundErrMsg)
return errors.New(fleet.HostNotFoundErrMsg)
}
var sce fleet.ErrWithStatusCode
if errors.As(err, &sce) {
+1 -1
View File
@@ -102,7 +102,7 @@ hello world
{
name: "host not found",
scriptPath: generateValidPath,
expectErrMsg: fleet.RunScriptHostNotFoundErrMsg,
expectErrMsg: fleet.HostNotFoundErrMsg,
expectNotFound: true,
},
{
+1 -1
View File
@@ -2299,7 +2299,7 @@ func main() {
munkiIssueCount = flag.Int("munki_issue_count", 10, "Number of munki issues reported by hosts identified to have munki issues")
// E.g. when running with `-host_count=10`, you can set host count for each template the following way:
// `-os_templates=windows_11.tmpl:3,macos_14.1.2.tmpl:4,ubuntu_22.04.tmpl:3`
osTemplates = flag.String("os_templates", "macos_14.1.2", fmt.Sprintf("Comma separated list of host OS templates to use and optionally their host count separated by ':' (any of %v, with or without the .tmpl extension)", allowedTemplateNames))
osTemplates = flag.String("os_templates", "macos_14.1.2", fmt.Sprintf("Comma-separated list of host OS templates to use and optionally their host count separated by ':' (any of %v, with or without the .tmpl extension)", allowedTemplateNames))
emptySerialProb = flag.Float64("empty_serial_prob", 0.1, "Probability of a host having no serial number [0, 1]")
mdmProb = flag.Float64("mdm_prob", 0.0, "Probability of a host enrolling via Fleet MDM (applies for macOS and Windows hosts, implies orbit enrollment on Windows) [0, 1]")
+1 -1
View File
@@ -17,7 +17,7 @@ const formatFieldForCSV = (value: any): string => {
}
// Wrap the value in double quotes to enclose any value that may
// have a, or a " in it to distinguish them from a comma separated delimiter
// have a, or a " in it to distinguish them from a comma-separated delimiter
return `"${value}"`;
};
+1 -1
View File
@@ -75,7 +75,7 @@ module Crypttab =
(************************************************************************
* View: comma_sep_list
* A comma separated list of options (opt=value or opt)
* A comma-separated list of options (opt=value or opt)
*************************************************************************)
let comma_sep_list (l:string) =
let value = [ label "value" . Util.del_str "=" . store optval ] in
+2 -2
View File
@@ -47,7 +47,7 @@ let integer = Rx.integer
(* View: member *)
let member = [ label "member" . store word ]
(* View: member_list
the member list is a comma separated list of
the member list is a comma-separated list of
users allowed to chgrp to the group without
being prompted for the group's password *)
let member_list = Build.opt_list member comma
@@ -55,7 +55,7 @@ let member_list = Build.opt_list member comma
(* View: admin *)
let admin = [ label "admin" . store word ]
(* View: admin_list
the admin_list is a comma separated list of
the admin_list is a comma-separated list of
users allowed to change the group's password
and the member_list *)
let admin_list = Build.opt_list admin comma
+1 -1
View File
@@ -8,7 +8,7 @@ module Opendkim =
(*
The Dataset spec is so broad as to encompass any string (particularly the
degenerate 'single literal string' case of a comma separated list with
degenerate 'single literal string' case of a comma-separated list with
only one item). So treat them as 'String' types, and it's up to the user to
format them correctly. Given that many of the variants include file paths
etc, it's impossible to validate for 'correctness' anyway
@@ -9,7 +9,7 @@ import (
)
// parseOptions parses the stdout returned from falconctl's displayed options. As far as we know, output is a single
// line, comma separated. We parse multiple lines, but assume data does not space that. Eg: linebreaks and commas
// line, comma-separated. We parse multiple lines, but assume data does not space that. Eg: linebreaks and commas
// treated as seperators.
func parseOptions(reader io.Reader) (any, error) {
results := make(map[string]interface{})
+1 -1
View File
@@ -207,7 +207,7 @@ func getCmdResponseData(outputCmd string) (string, error) {
continue
}
// results will be appended in a comma separated list
// results will be appended in a comma-separated list
if len(element.Item) > 0 {
// extracting the data from the result
+12 -7
View File
@@ -2547,25 +2547,30 @@ func (ds *Datastore) SearchHosts(ctx context.Context, filter fleet.TeamFilter, m
return hosts, nil
}
func (ds *Datastore) HostIDsByName(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error) {
if len(hostnames) == 0 {
func (ds *Datastore) HostIDsByIdentifier(ctx context.Context, filter fleet.TeamFilter, hostIdentifiers []string) ([]uint, error) {
if len(hostIdentifiers) == 0 {
return []uint{}, nil
}
sqlStatement := fmt.Sprintf(`
SELECT id FROM hosts
WHERE hostname IN (?) AND %s
SELECT
DISTINCT id FROM hosts
WHERE
(hostname IN (?)
OR uuid IN (?)
OR hardware_serial IN (?))
AND %s
`, ds.whereFilterHostsByTeams(filter, "hosts"),
)
sql, args, err := sqlx.In(sqlStatement, hostnames)
sql, args, err := sqlx.In(sqlStatement, hostIdentifiers, hostIdentifiers, hostIdentifiers)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "building query to get host IDs")
return nil, ctxerr.Wrap(ctx, err, "building query to get host IDs by identifier")
}
var hostIDs []uint
if err := sqlx.SelectContext(ctx, ds.reader(ctx), &hostIDs, sql, args...); err != nil {
return nil, ctxerr.Wrap(ctx, err, "get host IDs")
return nil, ctxerr.Wrap(ctx, err, "get host IDs by identifier")
}
return hostIDs, nil
+31 -17
View File
@@ -97,7 +97,7 @@ func TestHosts(t *testing.T) {
{"MarkSeen", testHostsMarkSeen},
{"MarkSeenMany", testHostsMarkSeenMany},
{"CleanupIncoming", testHostsCleanupIncoming},
{"IDsByName", testHostsIDsByName},
{"IDsByIdentifier", testHostIDsByIdentifier},
{"Additional", testHostsAdditional},
{"ByIdentifier", testHostsByIdentifier},
{"HostLiteByIdentifierAndID", testHostLiteByIdentifierAndID},
@@ -2273,7 +2273,7 @@ func testHostsCleanupIncoming(t *testing.T, ds *Datastore) {
require.NoError(t, err)
}
func testHostsIDsByName(t *testing.T, ds *Datastore) {
func testHostIDsByIdentifier(t *testing.T, ds *Datastore) {
hosts := make([]*fleet.Host, 10)
for i := range hosts {
h, err := ds.NewHost(context.Background(), &fleet.Host{
@@ -2281,9 +2281,10 @@ func testHostsIDsByName(t *testing.T, ds *Datastore) {
LabelUpdatedAt: time.Now(),
PolicyUpdatedAt: time.Now(),
SeenTime: time.Now(),
OsqueryHostID: ptr.String(fmt.Sprintf("host%d", i)),
NodeKey: ptr.String(fmt.Sprintf("%d", i)),
UUID: fmt.Sprintf("%d", i),
OsqueryHostID: ptr.String(fmt.Sprintf("osq.host%d", i)),
NodeKey: ptr.String(fmt.Sprintf("nk.%d", i)),
UUID: fmt.Sprintf("uuid.%d", i),
HardwareSerial: fmt.Sprintf("hws.%d", i),
Hostname: fmt.Sprintf("foo.%d.local", i),
})
require.NoError(t, err)
@@ -2295,34 +2296,47 @@ func testHostsIDsByName(t *testing.T, ds *Datastore) {
require.NoError(t, ds.AddHostsToTeam(context.Background(), &team1.ID, []uint{hosts[0].ID}))
filter := fleet.TeamFilter{User: test.UserAdmin}
hostsByName, err := ds.HostIDsByName(context.Background(), filter, []string{"foo.2.local", "foo.1.local", "foo.5.local"})
hostsByIdentifier, err := ds.HostIDsByIdentifier(context.Background(), filter, []string{"foo.2.local", "foo.1.local", "foo.5.local"})
require.NoError(t, err)
sort.Slice(hostsByName, func(i, j int) bool { return hostsByName[i] < hostsByName[j] })
assert.Equal(t, hostsByName, []uint{2, 3, 6})
sort.Slice(hostsByIdentifier, func(i, j int) bool { return hostsByIdentifier[i] < hostsByIdentifier[j] })
assert.Equal(t, hostsByIdentifier, []uint{2, 3, 6})
// by UUID
hostsByIdentifier, err = ds.HostIDsByIdentifier(context.Background(), filter, []string{"uuid.0", "uuid.4"})
require.NoError(t, err)
require.Len(t, hostsByIdentifier, 2)
assert.Equal(t, hostsByIdentifier[0], hosts[0].ID)
assert.Equal(t, hostsByIdentifier[1], hosts[4].ID)
// by HardwareSerial
hostsByIdentifier, err = ds.HostIDsByIdentifier(context.Background(), filter, []string{"hws.2"})
require.NoError(t, err)
require.Len(t, hostsByIdentifier, 1)
assert.Equal(t, hostsByIdentifier[0], hosts[2].ID)
userObs := &fleet.User{GlobalRole: ptr.String(fleet.RoleObserver)}
filter = fleet.TeamFilter{User: userObs}
hostsByName, err = ds.HostIDsByName(context.Background(), filter, []string{"foo.2.local", "foo.1.local", "foo.5.local"})
hostsByIdentifier, err = ds.HostIDsByIdentifier(context.Background(), filter, []string{"foo.2.local", "foo.1.local", "foo.5.local"})
require.NoError(t, err)
assert.Len(t, hostsByName, 0)
assert.Len(t, hostsByIdentifier, 0)
filter.IncludeObserver = true
hostsByName, err = ds.HostIDsByName(context.Background(), filter, []string{"foo.2.local", "foo.1.local", "foo.5.local"})
hostsByIdentifier, err = ds.HostIDsByIdentifier(context.Background(), filter, []string{"foo.2.local", "foo.1.local", "foo.5.local"})
require.NoError(t, err)
assert.Len(t, hostsByName, 3)
assert.Len(t, hostsByIdentifier, 3)
userTeam1 := &fleet.User{Teams: []fleet.UserTeam{{Team: *team1, Role: fleet.RoleAdmin}}}
filter = fleet.TeamFilter{User: userTeam1}
hostsByName, err = ds.HostIDsByName(context.Background(), filter, []string{"foo.2.local", "foo.1.local", "foo.5.local"})
hostsByIdentifier, err = ds.HostIDsByIdentifier(context.Background(), filter, []string{"foo.2.local", "foo.1.local", "foo.5.local"})
require.NoError(t, err)
assert.Len(t, hostsByName, 0)
assert.Len(t, hostsByIdentifier, 0)
hostsByName, err = ds.HostIDsByName(context.Background(), filter, []string{"foo.0.local", "foo.1.local", "foo.5.local"})
hostsByIdentifier, err = ds.HostIDsByIdentifier(context.Background(), filter, []string{"foo.0.local", "foo.1.local", "foo.5.local"})
require.NoError(t, err)
require.Len(t, hostsByName, 1)
assert.Equal(t, hostsByName[0], hosts[0].ID)
require.Len(t, hostsByIdentifier, 1)
assert.Equal(t, hostsByIdentifier[0], hosts[0].ID)
}
func testLoadHostByNodeKeyLoadsDisk(t *testing.T, ds *Datastore) {
+1 -1
View File
@@ -995,7 +995,7 @@ type ListOptions struct {
// How many results per page (must be positive integer, 0 indicates
// unlimited)
PerPage uint `query:"per_page,optional"`
// Key to use for ordering. Can be a comma separated set of items, eg: host_count,id
// Key to use for ordering. Can be a comma-separated set of items, eg: host_count,id
OrderKey string `query:"order_key,optional"`
// Direction of ordering
OrderDirection OrderDirection `query:"order_direction,optional"`
+2 -2
View File
@@ -15,7 +15,7 @@ type CapabilityMap map[Capability]struct{}
// mu is used to allow for safe access to the capability map.
var mu sync.Mutex
// PopulateFromString populates the CapabilityMap from a comma separated string.
// PopulateFromString populates the CapabilityMap from a comma-separated string.
// Example: "foo,bar,baz" => {"foo": struct{}, "bar": struct{}, "baz": struct{}}
func (c *CapabilityMap) PopulateFromString(s string) {
mu.Lock()
@@ -31,7 +31,7 @@ func (c *CapabilityMap) PopulateFromString(s string) {
}
}
// String returns a comma separated string with the capabilities in the map.
// String returns a comma-separated string with the capabilities in the map.
// Example: {"foo": struct{}, "bar": struct{}, "baz": struct{}} => "foo,bar,baz"
func (c *CapabilityMap) String() string {
mu.Lock()
+2 -2
View File
@@ -261,8 +261,8 @@ type Datastore interface {
CleanupIncomingHosts(ctx context.Context, now time.Time) ([]uint, error)
// GenerateHostStatusStatistics retrieves the count of online, offline, MIA and new hosts.
GenerateHostStatusStatistics(ctx context.Context, filter TeamFilter, now time.Time, platform *string, lowDiskSpace *int) (*HostSummary, error)
// HostIDsByName Retrieve the IDs associated with the given hostnames
HostIDsByName(ctx context.Context, filter TeamFilter, hostnames []string) ([]uint, error)
// HostIDsByIdentifier retrieves the IDs associated with the given hostnames, UUIDs, or hardware serials.
HostIDsByIdentifier(ctx context.Context, filter TeamFilter, hostnames []string) ([]uint, error)
// HostIDsByOSID retrieves the IDs of all host for the given OS ID
HostIDsByOSID(ctx context.Context, osID uint, offset int, limit int) ([]uint, error)
+6 -1
View File
@@ -536,10 +536,14 @@ func (e OrbitError) Error() string {
// Message that may surfaced by the server or the fleetctl client.
const (
// Hosts, general
HostNotFoundErrMsg = "Host doesn't exist. Make sure you provide a valid hostname, UUID, or serial number. Learn more about host identifiers: https://fleetdm.com/learn-more-about/host-identifiers"
NoHostsTargetedErrMsg = "No hosts targeted. Make sure you provide a valid hostname, UUID, or serial number. Learn more about host identifiers: https://fleetdm.com/learn-more-about/host-identifiers"
TargetedHostsDontExistErrMsg = "One or more targeted hosts don't exist. Make sure you provide a valid hostname, UUID, or serial number. Learn more about host identifiers: https://fleetdm.com/learn-more-about/host-identifiers"
// Scripts
RunScriptInvalidTypeErrMsg = "File type not supported. Only .sh (Bash) and .ps1 (PowerShell) file types are allowed."
RunScriptHostOfflineErrMsg = "Script can't run on offline host."
RunScriptHostNotFoundErrMsg = "Host doesn't exist. Make sure you provide a valid hostname, UUID, osquery host ID, or node key."
RunScriptForbiddenErrMsg = "You don't have the right permissions in Fleet to run the script."
RunScriptAlreadyRunningErrMsg = "A script is already running on this host. Please wait about 5 minutes to let it finish."
RunScriptHostTimeoutErrMsg = "Fleet didn't hear back from the host in under 5 minutes (timeout for live scripts). Fleet doesn't know if the script ran because it didn't receive the result. Please try again."
@@ -554,6 +558,7 @@ const (
// End user authentication
EndUserAuthDEPWebURLConfiguredErrMsg = `End user authentication can't be configured when the configured automatic enrollment (DEP) profile specifies a configuration_web_url.` // #nosec G101
)
// ConflictError is used to indicate a conflict, such as a UUID conflict in the DB.
+3 -3
View File
@@ -294,9 +294,9 @@ type Service interface {
// /////////////////////////////////////////////////////////////////////////////
// CampaignService defines the distributed query campaign related service methods
// NewDistributedQueryCampaignByNames creates a new distributed query campaign with the provided query (or the query
// referenced by ID) and host/label targets (specified by name).
NewDistributedQueryCampaignByNames(
// NewDistributedQueryCampaignByIdentifiers creates a new distributed query campaign with the provided query (or the query
// referenced by ID) and host/label targets (specified by hostname, UUID, or hardware serial).
NewDistributedQueryCampaignByIdentifiers(
ctx context.Context, queryString string, queryID *uint, hosts []string, labels []string,
) (*DistributedQueryCampaign, error)
+6 -6
View File
@@ -191,7 +191,7 @@ type CleanupIncomingHostsFunc func(ctx context.Context, now time.Time) ([]uint,
type GenerateHostStatusStatisticsFunc func(ctx context.Context, filter fleet.TeamFilter, now time.Time, platform *string, lowDiskSpace *int) (*fleet.HostSummary, error)
type HostIDsByNameFunc func(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error)
type HostIDsByIdentifierFunc func(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error)
type HostIDsByOSIDFunc func(ctx context.Context, osID uint, offset int, limit int) ([]uint, error)
@@ -1244,8 +1244,8 @@ type DataStore struct {
GenerateHostStatusStatisticsFunc GenerateHostStatusStatisticsFunc
GenerateHostStatusStatisticsFuncInvoked bool
HostIDsByNameFunc HostIDsByNameFunc
HostIDsByNameFuncInvoked bool
HostIDsByIdentifierFunc HostIDsByIdentifierFunc
HostIDsByIdentifierFuncInvoked bool
HostIDsByOSIDFunc HostIDsByOSIDFunc
HostIDsByOSIDFuncInvoked bool
@@ -3040,11 +3040,11 @@ func (s *DataStore) GenerateHostStatusStatistics(ctx context.Context, filter fle
return s.GenerateHostStatusStatisticsFunc(ctx, filter, now, platform, lowDiskSpace)
}
func (s *DataStore) HostIDsByName(ctx context.Context, filter fleet.TeamFilter, hostnames []string) ([]uint, error) {
func (s *DataStore) HostIDsByIdentifier(ctx context.Context, filter fleet.TeamFilter, hostIdentifiers []string) ([]uint, error) {
s.mu.Lock()
s.HostIDsByNameFuncInvoked = true
s.HostIDsByIdentifierFuncInvoked = true
s.mu.Unlock()
return s.HostIDsByNameFunc(ctx, filter, hostnames)
return s.HostIDsByIdentifierFunc(ctx, filter, hostIdentifiers)
}
func (s *DataStore) HostIDsByOSID(ctx context.Context, osID uint, offset int, limit int) ([]uint, error) {
+12 -11
View File
@@ -174,34 +174,35 @@ func (svc *Service) NewDistributedQueryCampaign(ctx context.Context, queryString
// Create Distributed Query Campaign By Names
////////////////////////////////////////////////////////////////////////////////
type createDistributedQueryCampaignByNamesRequest struct {
QuerySQL string `json:"query"`
QueryID *uint `json:"query_id"`
Selected distributedQueryCampaignTargetsByNames `json:"selected"`
type createDistributedQueryCampaignByIdentifierRequest struct {
QuerySQL string `json:"query"`
QueryID *uint `json:"query_id"`
Selected distributedQueryCampaignTargetsByIdentifiers `json:"selected"`
}
type distributedQueryCampaignTargetsByNames struct {
type distributedQueryCampaignTargetsByIdentifiers struct {
Labels []string `json:"labels"`
Hosts []string `json:"hosts"`
// list of hostnames, UUIDs, and/or hardware serials
Hosts []string `json:"hosts"`
}
func createDistributedQueryCampaignByNamesEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*createDistributedQueryCampaignByNamesRequest)
campaign, err := svc.NewDistributedQueryCampaignByNames(ctx, req.QuerySQL, req.QueryID, req.Selected.Hosts, req.Selected.Labels)
func createDistributedQueryCampaignByIdentifierEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (errorer, error) {
req := request.(*createDistributedQueryCampaignByIdentifierRequest)
campaign, err := svc.NewDistributedQueryCampaignByIdentifiers(ctx, req.QuerySQL, req.QueryID, req.Selected.Hosts, req.Selected.Labels)
if err != nil {
return createDistributedQueryCampaignResponse{Err: err}, nil
}
return createDistributedQueryCampaignResponse{Campaign: campaign}, nil
}
func (svc *Service) NewDistributedQueryCampaignByNames(ctx context.Context, queryString string, queryID *uint, hosts []string, labels []string) (*fleet.DistributedQueryCampaign, error) {
func (svc *Service) NewDistributedQueryCampaignByIdentifiers(ctx context.Context, queryString string, queryID *uint, hostIdentifiers []string, labels []string) (*fleet.DistributedQueryCampaign, error) {
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, fleet.ErrNoContext
}
filter := fleet.TeamFilter{User: vc.User, IncludeObserver: true}
hostIDs, err := svc.ds.HostIDsByName(ctx, filter, hosts)
hostIDs, err := svc.ds.HostIDsByIdentifier(ctx, filter, hostIdentifiers)
if err != nil {
return nil, ctxerr.Wrap(ctx, err, "finding host IDs")
}
+4 -4
View File
@@ -82,7 +82,7 @@ func TestLiveQueryAuth(t *testing.T) {
ds.HostIDsInTargetsFunc = func(ctx context.Context, filters fleet.TeamFilter, targets fleet.HostTargets) ([]uint, error) {
return []uint{1}, nil
}
ds.HostIDsByNameFunc = func(ctx context.Context, filter fleet.TeamFilter, names []string) ([]uint, error) {
ds.HostIDsByIdentifierFunc = func(ctx context.Context, filter fleet.TeamFilter, identifiers []string) ([]uint, error) {
return nil, nil
}
ds.LabelIDsByNameFunc = func(ctx context.Context, names []string) (map[string]uint, error) {
@@ -225,13 +225,13 @@ func TestLiveQueryAuth(t *testing.T) {
// tests with a team target cannot run the "ByNames" calls, as there's no way
// to pass a team target with this call.
if tt.teamID == nil {
_, err = svc.NewDistributedQueryCampaignByNames(ctx, query1ObsCanRun.Query, nil, nil, nil)
_, err = svc.NewDistributedQueryCampaignByIdentifiers(ctx, query1ObsCanRun.Query, nil, nil, nil)
checkAuthErr(t, tt.shouldFailRunNew, err)
_, err = svc.NewDistributedQueryCampaignByNames(ctx, query1ObsCanRun.Query, ptr.Uint(query1ObsCanRun.ID), nil, nil)
_, err = svc.NewDistributedQueryCampaignByIdentifiers(ctx, query1ObsCanRun.Query, ptr.Uint(query1ObsCanRun.ID), nil, nil)
checkAuthErr(t, tt.shouldFailRunObsCan, err)
_, err = svc.NewDistributedQueryCampaignByNames(ctx, query2ObsCannotRun.Query, ptr.Uint(query2ObsCannotRun.ID), nil, nil)
_, err = svc.NewDistributedQueryCampaignByIdentifiers(ctx, query2ObsCannotRun.Query, ptr.Uint(query2ObsCannotRun.ID), nil, nil)
checkAuthErr(t, tt.shouldFailRunObsCannot, err)
}
})
+6 -6
View File
@@ -62,19 +62,19 @@ func (h *LiveQueryResultsHandler) Status() *campaignStatus {
}
// LiveQuery creates a new live query and begins streaming results.
func (c *Client) LiveQuery(query string, queryID *uint, labels []string, hosts []string) (*LiveQueryResultsHandler, error) {
return c.LiveQueryWithContext(context.Background(), query, queryID, labels, hosts)
func (c *Client) LiveQuery(query string, queryID *uint, labels []string, hostIdentifiers []string) (*LiveQueryResultsHandler, error) {
return c.LiveQueryWithContext(context.Background(), query, queryID, labels, hostIdentifiers)
}
func (c *Client) LiveQueryWithContext(
ctx context.Context, query string, queryID *uint, labels []string, hosts []string,
ctx context.Context, query string, queryID *uint, labels []string, hostIdentifiers []string,
) (*LiveQueryResultsHandler, error) {
req := createDistributedQueryCampaignByNamesRequest{
req := createDistributedQueryCampaignByIdentifierRequest{
QueryID: queryID,
QuerySQL: query,
Selected: distributedQueryCampaignTargetsByNames{Labels: labels, Hosts: hosts},
Selected: distributedQueryCampaignTargetsByIdentifiers{Labels: labels, Hosts: hostIdentifiers},
}
verb, path := "POST", "/api/latest/fleet/queries/run_by_names"
verb, path := "POST", "/api/latest/fleet/queries/run_by_identifiers"
var responseBody createDistributedQueryCampaignResponse
err := c.authenticatedRequest(req, verb, path, &responseBody)
if err != nil {
+1 -1
View File
@@ -20,7 +20,7 @@ func TestLiveQueryWithContext(t *testing.T) {
upgrader := websocket.Upgrader{}
ts := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
switch r.URL.Path {
case "/api/latest/fleet/queries/run_by_names":
case "/api/latest/fleet/queries/run_by_identifiers":
resp := createDistributedQueryCampaignResponse{
Campaign: &fleet.DistributedQueryCampaign{
UpdateCreateTimestamps: fleet.UpdateCreateTimestamps{
+3 -1
View File
@@ -428,7 +428,9 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
// The live queries are created with these two endpoints and their results can be queried via
// websockets via the `GET /api/_version_/fleet/results/` endpoint.
ue.POST("/api/_version_/fleet/queries/run", createDistributedQueryCampaignEndpoint, createDistributedQueryCampaignRequest{})
ue.POST("/api/_version_/fleet/queries/run_by_names", createDistributedQueryCampaignByNamesEndpoint, createDistributedQueryCampaignByNamesRequest{})
ue.POST("/api/_version_/fleet/queries/run_by_identifiers", createDistributedQueryCampaignByIdentifierEndpoint, createDistributedQueryCampaignByIdentifierRequest{})
// This endpoint is deprecated and maintained for backwards compatibility. This and above endpoint are functionally equivalent
ue.POST("/api/_version_/fleet/queries/run_by_names", createDistributedQueryCampaignByIdentifierEndpoint, createDistributedQueryCampaignByIdentifierRequest{})
ue.GET("/api/_version_/fleet/activities", listActivitiesEndpoint, listActivitiesRequest{})
@@ -999,14 +999,14 @@ func (s *liveQueriesTestSuite) TestCreateDistributedQueryCampaign() {
// wait to prevent duplicate name for new query
time.Sleep(200 * time.Millisecond)
// create by host name
req2 := createDistributedQueryCampaignByNamesRequest{
req2 := createDistributedQueryCampaignByIdentifierRequest{
QuerySQL: "SELECT 3",
Selected: distributedQueryCampaignTargetsByNames{
Selected: distributedQueryCampaignTargetsByIdentifiers{
Hosts: []string{h1.Hostname},
},
}
s.DoJSON("POST", "/api/latest/fleet/queries/run_by_names", req2, http.StatusOK, &createResp)
s.DoJSON("POST", "/api/latest/fleet/queries/run_by_identifiers", req2, http.StatusOK, &createResp)
assert.NotEqual(t, camp1.ID, createResp.Campaign.ID)
assert.Equal(t, uint(1), createResp.Campaign.Metrics.TotalHosts)
@@ -1014,13 +1014,13 @@ func (s *liveQueriesTestSuite) TestCreateDistributedQueryCampaign() {
time.Sleep(200 * time.Millisecond)
// create by unknown host name - it ignores the unknown names. Must have at least 1 valid host
req2 = createDistributedQueryCampaignByNamesRequest{
req2 = createDistributedQueryCampaignByIdentifierRequest{
QuerySQL: "SELECT 3",
Selected: distributedQueryCampaignTargetsByNames{
Selected: distributedQueryCampaignTargetsByIdentifiers{
Hosts: []string{h1.Hostname, h2.Hostname + "ZZZZZ"},
},
}
s.DoJSON("POST", "/api/latest/fleet/queries/run_by_names", req2, http.StatusOK, &createResp)
s.DoJSON("POST", "/api/latest/fleet/queries/run_by_identifiers", req2, http.StatusOK, &createResp)
}
func (s *liveQueriesTestSuite) TestOsqueryDistributedRead() {