mirror of
https://github.com/fleetdm/fleet
synced 2026-04-21 13:37:30 +00:00
Add support for downloading a list of hosts in CSV format (#4596)
This commit is contained in:
parent
f4d3159cc9
commit
bb678b6b2e
15 changed files with 248 additions and 62 deletions
1
changes/issue-3640-add-endpoint-download-csv-hosts
Normal file
1
changes/issue-3640-add-endpoint-download-csv-hosts
Normal file
|
|
@ -0,0 +1 @@
|
|||
* Add support for downloading a list of hosts in CSV format
|
||||
|
|
@ -463,6 +463,7 @@ This is the callback endpoint that the identity provider will use to send securi
|
|||
- [Bulk delete hosts by filter or ids](#bulk-delete-hosts-by-filter-or-ids)
|
||||
- [Get host's Google Chrome profiles](#get-hosts-google-chrome-profiles)
|
||||
- [Get host's mobile device management (MDM) and Munki information](#get-hosts-mobile-device-management-mdm-and-munki-information)
|
||||
- [Get hosts report in CSV](#get-hosts-report-in-csv)
|
||||
|
||||
### List hosts
|
||||
|
||||
|
|
@ -1206,6 +1207,49 @@ Retrieves aggregated host's MDM enrollment status and Munki versions.
|
|||
|
||||
---
|
||||
|
||||
### Get hosts report in CSV
|
||||
|
||||
Returns the list of hosts corresponding to the search criteria in CSV format, ready for download when
|
||||
requested by a web browser.
|
||||
|
||||
`GET /api/v1/fleet/hosts/report`
|
||||
|
||||
#### Parameters
|
||||
|
||||
| Name | Type | In | Description |
|
||||
| ----------------------- | ------- | ----- | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
|
||||
| format | string | query | **Required**, must be "csv" (only supported format for now). |
|
||||
| page | integer | query | Page number of the results to fetch. |
|
||||
| per_page | integer | query | Results per page. |
|
||||
| order_key | string | query | What to order results by. Can be any column in the hosts table. |
|
||||
| after | string | query | The value to get results after. This needs order_key defined, as that's the column that would be used. |
|
||||
| order_direction | string | query | **Requires `order_key`**. The direction of the order given the order key. Options include `asc` and `desc`. Default is `asc`. |
|
||||
| status | string | query | Indicates the status of the hosts to return. Can either be `new`, `online`, `offline`, or `mia`. |
|
||||
| query | string | query | Search query keywords. Searchable fields include `hostname`, `machine_serial`, `uuid`, `ipv4` and the hosts' email addresses (only searched if the query looks like an email address, i.e. contains an `@`, no space, etc.). |
|
||||
| team_id | integer | query | _Available in Fleet Premium_ Filters the hosts to only include hosts in the specified team. |
|
||||
| policy_id | integer | query | The ID of the policy to filter hosts by. `policy_response` must also be specified with `policy_id`. |
|
||||
| policy_response | string | query | Valid options are `passing` or `failing`. `policy_id` must also be specified with `policy_response`. |
|
||||
| software_id | integer | query | The ID of the software to filter hosts by. |
|
||||
| label_id | integer | query | A valid label ID. It cannot be used alongside policy filters. |
|
||||
|
||||
#### Example
|
||||
|
||||
`GET /api/v1/fleet/hosts/report?page=0&per_page=100&software_id=123`
|
||||
|
||||
##### Default response
|
||||
|
||||
`Status: 200`
|
||||
|
||||
```csv
|
||||
created_at,updated_at,id,detail_updated_at,label_updated_at,policy_updated_at,last_enrolled_at,seen_time,refetch_requested,hostname,uuid,platform,osquery_version,os_version,build,platform_like,code_name,uptime,memory,cpu_type,cpu_subtype,cpu_brand,cpu_physical_cores,cpu_logical_cores,hardware_vendor,hardware_model,hardware_version,hardware_serial,computer_name,primary_ip_id,primary_ip,primary_mac,distributed_interval,config_tls_refresh,logger_tls_period,team_id,team_name,gigs_disk_space_available,percent_disk_space_available
|
||||
2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,1,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,false,foo.local0,a4fc55a1-b5de-409c-a2f4-441f564680d3,debian,,,,,,0s,0,,,,0,0,,,,,,,,,0,0,0,,,0,0
|
||||
2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:22:56Z,false,foo.local1,689539e5-72f0-4bf7-9cc5-1530d3814660,rhel,,,,,,0s,0,,,,0,0,,,,,,,,,0,0,0,,,0,0
|
||||
2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,3,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:23:56Z,2022-03-15T17:21:56Z,false,foo.local2,48ebe4b0-39c3-4a74-a67f-308f7b5dd171,linux,,,,,,0s,0,,,,0,0,,,,,,,,,0,0,0,,,0,0
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
|
||||
## Labels
|
||||
|
||||
- [Create label](#create-label)
|
||||
|
|
|
|||
1
go.mod
1
go.mod
|
|
@ -33,6 +33,7 @@ require (
|
|||
github.com/ghodss/yaml v1.0.0
|
||||
github.com/go-kit/kit v0.9.0
|
||||
github.com/go-sql-driver/mysql v1.6.0
|
||||
github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055 // indirect
|
||||
github.com/gocolly/colly v1.2.0
|
||||
github.com/golang-jwt/jwt/v4 v4.0.0
|
||||
github.com/gomodule/redigo v1.8.5
|
||||
|
|
|
|||
2
go.sum
2
go.sum
|
|
@ -473,6 +473,8 @@ github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJA
|
|||
github.com/gobwas/httphead v0.0.0-20180130184737-2c6c146eadee/go.mod h1:L0fX3K22YWvt/FAX9NnzrNzcI4wNYi9Yku4O0LKYflo=
|
||||
github.com/gobwas/pool v0.2.0/go.mod h1:q8bcK0KcYlCgd9e7WYLm9LpyS+YeLd8JVDW6WezmKEw=
|
||||
github.com/gobwas/ws v1.0.2/go.mod h1:szmBTxLgaFppYjEmNtny/v3w89xOydFnnZMcgRRu/EM=
|
||||
github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055 h1:UfcDMw41lSx3XM7UvD1i7Fsu3rMgD55OU5LYwLoR/Yk=
|
||||
github.com/gocarina/gocsv v0.0.0-20220310154401-d4df709ca055/go.mod h1:5YoVOkjYAQumqlV356Hj3xeYh4BdZuLE0/nRkf2NKkI=
|
||||
github.com/gocolly/colly v1.2.0 h1:qRz9YAn8FIH0qzgNUw+HT9UN7wm1oF9OBAilwEWpyrI=
|
||||
github.com/gocolly/colly v1.2.0/go.mod h1:Hof5T3ZswNVsOHYmba1u03W65HDWgpV5HifSuueE0EA=
|
||||
github.com/godbus/dbus/v5 v5.0.4/go.mod h1:xhWf0FNVPg57R7Z0UbKHbJfkEywrmjJnf7w5xrFpKfA=
|
||||
|
|
|
|||
|
|
@ -39,6 +39,19 @@ type ErrWithRetryAfter interface {
|
|||
RetryAfter() int
|
||||
}
|
||||
|
||||
type invalidArgWithStatusError struct {
|
||||
InvalidArgumentError
|
||||
code int
|
||||
}
|
||||
|
||||
func (e invalidArgWithStatusError) Status() int {
|
||||
if e.code == 0 {
|
||||
// 422 is the default code for invalid args
|
||||
return http.StatusUnprocessableEntity
|
||||
}
|
||||
return e.code
|
||||
}
|
||||
|
||||
// InvalidArgumentError is the error returned when invalid data is presented to
|
||||
// a service method.
|
||||
type InvalidArgumentError []InvalidArgument
|
||||
|
|
@ -66,6 +79,7 @@ func (e *InvalidArgumentError) Append(name, reason string) {
|
|||
reason: reason,
|
||||
})
|
||||
}
|
||||
|
||||
func (e *InvalidArgumentError) Appendf(name, reasonFmt string, args ...interface{}) {
|
||||
*e = append(*e, InvalidArgument{
|
||||
name: name,
|
||||
|
|
@ -73,6 +87,12 @@ func (e *InvalidArgumentError) Appendf(name, reasonFmt string, args ...interface
|
|||
})
|
||||
}
|
||||
|
||||
// WithStatus returns an error that combines the InvalidArgumentError
|
||||
// with a custom status code.
|
||||
func (e InvalidArgumentError) WithStatus(code int) error {
|
||||
return invalidArgWithStatusError{e, code}
|
||||
}
|
||||
|
||||
func (e *InvalidArgumentError) HasErrors() bool {
|
||||
return len(*e) != 0
|
||||
}
|
||||
|
|
@ -213,7 +233,7 @@ const (
|
|||
ErrNoRoleNeeded = 1
|
||||
// ErrNoOneAdminNeeded is the error number when all admins are about to be removed
|
||||
ErrNoOneAdminNeeded = 2
|
||||
//ErrNoUnknownTranslate is returned when an item type in the translate payload is unknown
|
||||
// ErrNoUnknownTranslate is returned when an item type in the translate payload is unknown
|
||||
ErrNoUnknownTranslate = 3
|
||||
)
|
||||
|
||||
|
|
|
|||
|
|
@ -70,73 +70,73 @@ type HostUser struct {
|
|||
type Host struct {
|
||||
UpdateCreateTimestamps
|
||||
HostSoftware
|
||||
ID uint `json:"id"`
|
||||
ID uint `json:"id" csv:"id"`
|
||||
// OsqueryHostID is the key used in the request context that is
|
||||
// used to retrieve host information. It is sent from osquery and may currently be
|
||||
// a GUID or a Host Name, but in either case, it MUST be unique
|
||||
OsqueryHostID string `json:"-" db:"osquery_host_id"`
|
||||
DetailUpdatedAt time.Time `json:"detail_updated_at" db:"detail_updated_at"` // Time that the host details were last updated
|
||||
LabelUpdatedAt time.Time `json:"label_updated_at" db:"label_updated_at"` // Time that the host labels were last updated
|
||||
PolicyUpdatedAt time.Time `json:"policy_updated_at" db:"policy_updated_at"` // Time that the host policies were last updated
|
||||
LastEnrolledAt time.Time `json:"last_enrolled_at" db:"last_enrolled_at"` // Time that the host last enrolled
|
||||
SeenTime time.Time `json:"seen_time" db:"seen_time"` // Time that the host was last "seen"
|
||||
RefetchRequested bool `json:"refetch_requested" db:"refetch_requested"`
|
||||
NodeKey string `json:"-" db:"node_key"`
|
||||
Hostname string `json:"hostname" db:"hostname"` // there is a fulltext index on this field
|
||||
UUID string `json:"uuid" db:"uuid"` // there is a fulltext index on this field
|
||||
OsqueryHostID string `json:"-" db:"osquery_host_id" csv:"-"`
|
||||
DetailUpdatedAt time.Time `json:"detail_updated_at" db:"detail_updated_at" csv:"detail_updated_at"` // Time that the host details were last updated
|
||||
LabelUpdatedAt time.Time `json:"label_updated_at" db:"label_updated_at" csv:"label_updated_at"` // Time that the host labels were last updated
|
||||
PolicyUpdatedAt time.Time `json:"policy_updated_at" db:"policy_updated_at" csv:"policy_updated_at"` // Time that the host policies were last updated
|
||||
LastEnrolledAt time.Time `json:"last_enrolled_at" db:"last_enrolled_at" csv:"last_enrolled_at"` // Time that the host last enrolled
|
||||
SeenTime time.Time `json:"seen_time" db:"seen_time" csv:"seen_time"` // Time that the host was last "seen"
|
||||
RefetchRequested bool `json:"refetch_requested" db:"refetch_requested" csv:"refetch_requested"`
|
||||
NodeKey string `json:"-" db:"node_key" csv:"-"`
|
||||
Hostname string `json:"hostname" db:"hostname" csv:"hostname"` // there is a fulltext index on this field
|
||||
UUID string `json:"uuid" db:"uuid" csv:"uuid"` // there is a fulltext index on this field
|
||||
// Platform is the host's platform as defined by osquery's os_version.platform.
|
||||
Platform string `json:"platform"`
|
||||
OsqueryVersion string `json:"osquery_version" db:"osquery_version"`
|
||||
OSVersion string `json:"os_version" db:"os_version"`
|
||||
Build string `json:"build"`
|
||||
PlatformLike string `json:"platform_like" db:"platform_like"`
|
||||
CodeName string `json:"code_name" db:"code_name"`
|
||||
Uptime time.Duration `json:"uptime"`
|
||||
Memory int64 `json:"memory" sql:"type:bigint" db:"memory"`
|
||||
Platform string `json:"platform" csv:"platform"`
|
||||
OsqueryVersion string `json:"osquery_version" db:"osquery_version" csv:"osquery_version"`
|
||||
OSVersion string `json:"os_version" db:"os_version" csv:"os_version"`
|
||||
Build string `json:"build" csv:"build"`
|
||||
PlatformLike string `json:"platform_like" db:"platform_like" csv:"platform_like"`
|
||||
CodeName string `json:"code_name" db:"code_name" csv:"code_name"`
|
||||
Uptime time.Duration `json:"uptime" csv:"uptime"`
|
||||
Memory int64 `json:"memory" sql:"type:bigint" db:"memory" csv:"memory"`
|
||||
// system_info fields
|
||||
CPUType string `json:"cpu_type" db:"cpu_type"`
|
||||
CPUSubtype string `json:"cpu_subtype" db:"cpu_subtype"`
|
||||
CPUBrand string `json:"cpu_brand" db:"cpu_brand"`
|
||||
CPUPhysicalCores int `json:"cpu_physical_cores" db:"cpu_physical_cores"`
|
||||
CPULogicalCores int `json:"cpu_logical_cores" db:"cpu_logical_cores"`
|
||||
HardwareVendor string `json:"hardware_vendor" db:"hardware_vendor"`
|
||||
HardwareModel string `json:"hardware_model" db:"hardware_model"`
|
||||
HardwareVersion string `json:"hardware_version" db:"hardware_version"`
|
||||
HardwareSerial string `json:"hardware_serial" db:"hardware_serial"`
|
||||
ComputerName string `json:"computer_name" db:"computer_name"`
|
||||
CPUType string `json:"cpu_type" db:"cpu_type" csv:"cpu_type"`
|
||||
CPUSubtype string `json:"cpu_subtype" db:"cpu_subtype" csv:"cpu_subtype"`
|
||||
CPUBrand string `json:"cpu_brand" db:"cpu_brand" csv:"cpu_brand"`
|
||||
CPUPhysicalCores int `json:"cpu_physical_cores" db:"cpu_physical_cores" csv:"cpu_physical_cores"`
|
||||
CPULogicalCores int `json:"cpu_logical_cores" db:"cpu_logical_cores" csv:"cpu_logical_cores"`
|
||||
HardwareVendor string `json:"hardware_vendor" db:"hardware_vendor" csv:"hardware_vendor"`
|
||||
HardwareModel string `json:"hardware_model" db:"hardware_model" csv:"hardware_model"`
|
||||
HardwareVersion string `json:"hardware_version" db:"hardware_version" csv:"hardware_version"`
|
||||
HardwareSerial string `json:"hardware_serial" db:"hardware_serial" csv:"hardware_serial"`
|
||||
ComputerName string `json:"computer_name" db:"computer_name" csv:"computer_name"`
|
||||
// PrimaryNetworkInterfaceID if present indicates to primary network for the host, the details of which
|
||||
// can be found in the NetworkInterfaces element with the same ip_address.
|
||||
PrimaryNetworkInterfaceID *uint `json:"primary_ip_id,omitempty" db:"primary_ip_id"`
|
||||
NetworkInterfaces []*NetworkInterface `json:"-" db:"-"`
|
||||
PrimaryIP string `json:"primary_ip" db:"primary_ip"`
|
||||
PrimaryMac string `json:"primary_mac" db:"primary_mac"`
|
||||
DistributedInterval uint `json:"distributed_interval" db:"distributed_interval"`
|
||||
ConfigTLSRefresh uint `json:"config_tls_refresh" db:"config_tls_refresh"`
|
||||
LoggerTLSPeriod uint `json:"logger_tls_period" db:"logger_tls_period"`
|
||||
TeamID *uint `json:"team_id" db:"team_id"`
|
||||
PrimaryNetworkInterfaceID *uint `json:"primary_ip_id,omitempty" db:"primary_ip_id" csv:"primary_ip_id"`
|
||||
NetworkInterfaces []*NetworkInterface `json:"-" db:"-" csv:"-"`
|
||||
PrimaryIP string `json:"primary_ip" db:"primary_ip" csv:"primary_ip"`
|
||||
PrimaryMac string `json:"primary_mac" db:"primary_mac" csv:"primary_mac"`
|
||||
DistributedInterval uint `json:"distributed_interval" db:"distributed_interval" csv:"distributed_interval"`
|
||||
ConfigTLSRefresh uint `json:"config_tls_refresh" db:"config_tls_refresh" csv:"config_tls_refresh"`
|
||||
LoggerTLSPeriod uint `json:"logger_tls_period" db:"logger_tls_period" csv:"logger_tls_period"`
|
||||
TeamID *uint `json:"team_id" db:"team_id" csv:"team_id"`
|
||||
|
||||
// Loaded via JOIN in DB
|
||||
PackStats []PackStats `json:"pack_stats"`
|
||||
PackStats []PackStats `json:"pack_stats" csv:"-"`
|
||||
// TeamName is the name of the team, loaded by JOIN to the teams table.
|
||||
TeamName *string `json:"team_name" db:"team_name"`
|
||||
TeamName *string `json:"team_name" db:"team_name" csv:"team_name"`
|
||||
// Additional is the additional information from the host
|
||||
// additional_queries. This should be stored in a separate DB table.
|
||||
Additional *json.RawMessage `json:"additional,omitempty" db:"additional"`
|
||||
Additional *json.RawMessage `json:"additional,omitempty" db:"additional" csv:"-"`
|
||||
|
||||
// Users currently in the host
|
||||
Users []HostUser `json:"users,omitempty"`
|
||||
Users []HostUser `json:"users,omitempty" csv:"-"`
|
||||
|
||||
GigsDiskSpaceAvailable float64 `json:"gigs_disk_space_available" db:"gigs_disk_space_available"`
|
||||
PercentDiskSpaceAvailable float64 `json:"percent_disk_space_available" db:"percent_disk_space_available"`
|
||||
GigsDiskSpaceAvailable float64 `json:"gigs_disk_space_available" db:"gigs_disk_space_available" csv:"gigs_disk_space_available"`
|
||||
PercentDiskSpaceAvailable float64 `json:"percent_disk_space_available" db:"percent_disk_space_available" csv:"percent_disk_space_available"`
|
||||
|
||||
HostIssues `json:"issues,omitempty"`
|
||||
HostIssues `json:"issues,omitempty" csv:"-"`
|
||||
|
||||
Modified bool `json:"-"`
|
||||
Modified bool `json:"-" csv:"-"`
|
||||
}
|
||||
|
||||
type HostIssues struct {
|
||||
TotalIssuesCount int `json:"total_issues_count" db:"total_issues_count"`
|
||||
FailingPoliciesCount int `json:"failing_policies_count" db:"failing_policies_count"`
|
||||
TotalIssuesCount int `json:"total_issues_count" db:"total_issues_count" csv:"-"`
|
||||
FailingPoliciesCount int `json:"failing_policies_count" db:"failing_policies_count" csv:"-"`
|
||||
}
|
||||
|
||||
func (h Host) AuthzType() string {
|
||||
|
|
|
|||
|
|
@ -48,12 +48,12 @@ type VulnerabilitiesSlice []SoftwareCVE
|
|||
// HostSoftware is the set of software installed on a specific host
|
||||
type HostSoftware struct {
|
||||
// Software is the software information.
|
||||
Software []Software `json:"software,omitempty"`
|
||||
Software []Software `json:"software,omitempty" csv:"-"`
|
||||
// Modified is a boolean indicating whether this has been modified since
|
||||
// loading. If Modified is true, datastore implementations should save the
|
||||
// data. We track this here because saving the software set is likely to be
|
||||
// an expensive operation.
|
||||
Modified bool `json:"-"`
|
||||
Modified bool `json:"-" csv:"-"`
|
||||
}
|
||||
|
||||
type SoftwareIterator interface {
|
||||
|
|
|
|||
|
|
@ -4,12 +4,12 @@ import "time"
|
|||
|
||||
// CreateTimestamp contains common timestamp fields indicating create time
|
||||
type CreateTimestamp struct {
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at"`
|
||||
CreatedAt time.Time `json:"created_at" db:"created_at" csv:"created_at"`
|
||||
}
|
||||
|
||||
// UpdateTimestamp contains a timestamp that is set whenever an entity is changed
|
||||
type UpdateTimestamp struct {
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at"`
|
||||
UpdatedAt time.Time `json:"updated_at" db:"updated_at" csv:"updated_at"`
|
||||
}
|
||||
|
||||
type UpdateCreateTimestamps struct {
|
||||
|
|
|
|||
|
|
@ -306,6 +306,7 @@ func attachFleetAPIRoutes(r *mux.Router, svc fleet.Service, config config.FleetC
|
|||
ue.POST("/api/_version_/fleet/hosts/transfer/filter", addHostsToTeamByFilterEndpoint, addHostsToTeamByFilterRequest{})
|
||||
ue.POST("/api/_version_/fleet/hosts/{id:[0-9]+}/refetch", refetchHostEndpoint, refetchHostRequest{})
|
||||
ue.GET("/api/_version_/fleet/hosts/{id:[0-9]+}/device_mapping", listHostDeviceMappingEndpoint, listHostDeviceMappingRequest{})
|
||||
ue.GET("/api/_version_/fleet/hosts/report", hostsReportEndpoint, hostsReportRequest{})
|
||||
|
||||
ue.POST("/api/_version_/fleet/labels", createLabelEndpoint, createLabelRequest{})
|
||||
ue.PATCH("/api/_version_/fleet/labels/{id:[0-9]+}", modifyLabelEndpoint, modifyLabelRequest{})
|
||||
|
|
|
|||
|
|
@ -2,11 +2,16 @@ package service
|
|||
|
||||
import (
|
||||
"context"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"time"
|
||||
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/authz"
|
||||
"github.com/fleetdm/fleet/v4/server/contexts/ctxerr"
|
||||
"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/gocarina/gocsv"
|
||||
)
|
||||
|
||||
// HostResponse is the response struct that contains the full host information
|
||||
|
|
@ -819,3 +824,64 @@ func (svc *Service) AggregatedMacadminsData(ctx context.Context, teamID *uint) (
|
|||
|
||||
return agg, 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"`
|
||||
}
|
||||
|
||||
type hostsReportResponse struct {
|
||||
Hosts []*fleet.Host `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) {
|
||||
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.WriteHeader(http.StatusOK)
|
||||
if err := gocsv.Marshal(r.Hosts, w); err != nil {
|
||||
logging.WithErr(ctx, err)
|
||||
}
|
||||
}
|
||||
|
||||
func hostsReportEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, 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 := authz.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
|
||||
}
|
||||
|
||||
// Those are not supported when listing hosts in a label, so that's just to
|
||||
// make the output consistent whether a label is used or not.
|
||||
req.Opts.DisableFailingPolicies = true
|
||||
req.Opts.AdditionalFilters = nil
|
||||
|
||||
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
|
||||
}
|
||||
return hostsReportResponse{Hosts: hosts}, nil
|
||||
}
|
||||
|
|
|
|||
|
|
@ -4,6 +4,7 @@ import (
|
|||
"context"
|
||||
"crypto/rand"
|
||||
"encoding/base64"
|
||||
"encoding/csv"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io/ioutil"
|
||||
|
|
@ -1033,7 +1034,8 @@ func (s *integrationTestSuite) TestInvites() {
|
|||
updateInviteReq := updateInviteRequest{InvitePayload: fleet.InvitePayload{
|
||||
Teams: []fleet.UserTeam{
|
||||
{Team: fleet.Team{ID: team.ID}, Role: fleet.RoleObserver},
|
||||
}}}
|
||||
},
|
||||
}}
|
||||
updateInviteResp := updateInviteResponse{}
|
||||
s.DoJSON("PATCH", fmt.Sprintf("/api/v1/fleet/invites/%d", validInvite.ID+1), updateInviteReq, http.StatusNotFound, &updateInviteResp)
|
||||
|
||||
|
|
@ -3704,6 +3706,36 @@ func (s *integrationTestSuite) TestModifyUser() {
|
|||
require.Equal(t, u.ID, loginResp.User.ID)
|
||||
}
|
||||
|
||||
func (s *integrationTestSuite) TestHostsReportDownload() {
|
||||
t := s.T()
|
||||
|
||||
hosts := s.createHosts(t)
|
||||
|
||||
res := s.DoRaw("GET", "/api/v1/fleet/hosts/report", nil, http.StatusUnsupportedMediaType, "format", "gzip")
|
||||
var errs struct {
|
||||
Message string `json:"message"`
|
||||
Errors []struct {
|
||||
Name string `json:"name"`
|
||||
Reason string `json:"reason"`
|
||||
} `json:"errors"`
|
||||
}
|
||||
require.NoError(t, json.NewDecoder(res.Body).Decode(&errs))
|
||||
res.Body.Close()
|
||||
require.Len(t, errs.Errors, 1)
|
||||
assert.Equal(t, "format", errs.Errors[0].Name)
|
||||
|
||||
res = s.DoRaw("GET", "/api/v1/fleet/hosts/report", nil, http.StatusOK, "format", "csv")
|
||||
rows, err := csv.NewReader(res.Body).ReadAll()
|
||||
res.Body.Close()
|
||||
require.NoError(t, err)
|
||||
require.Len(t, rows, len(hosts)+1)
|
||||
require.Contains(t, rows[0], "hostname") // first row contains headers
|
||||
require.Contains(t, res.Header, "Content-Disposition")
|
||||
require.Contains(t, res.Header, "Content-Type")
|
||||
require.Contains(t, res.Header.Get("Content-Disposition"), "attachment;")
|
||||
require.Contains(t, res.Header.Get("Content-Type"), "text/csv")
|
||||
}
|
||||
|
||||
// creates a session and returns it, its key is to be passed as authorization header.
|
||||
func createSession(t *testing.T, uid uint, ds fleet.Datastore) *fleet.Session {
|
||||
key := make([]byte, 64)
|
||||
|
|
|
|||
|
|
@ -42,6 +42,12 @@ func (m *Middleware) AuthzCheck() endpoint.Middleware {
|
|||
return nil, err
|
||||
}
|
||||
|
||||
// TODO(mna): currently, any error detected before an authorization check gets
|
||||
// lost and the response is always Unauthorized because of the following condition.
|
||||
// I _think_ it would be safe to check here of response.error() returns a non-nil
|
||||
// error and if so, leave that error go through instead of returning a check missing
|
||||
// authorization error. To look into when addressing #4406.
|
||||
|
||||
// If authorization was not checked, return a response that will
|
||||
// marshal to a generic error and log that the check was missed.
|
||||
if !authzctx.Checked() {
|
||||
|
|
|
|||
|
|
@ -16,10 +16,8 @@ import (
|
|||
"github.com/gorilla/mux"
|
||||
)
|
||||
|
||||
var (
|
||||
// errBadRoute is used for mux errors
|
||||
errBadRoute = errors.New("bad route")
|
||||
)
|
||||
// errBadRoute is used for mux errors
|
||||
var errBadRoute = errors.New("bad route")
|
||||
|
||||
func encodeResponse(ctx context.Context, w http.ResponseWriter, response interface{}) error {
|
||||
// The has to happen first, if an error happens we'll redirect to an error
|
||||
|
|
@ -35,9 +33,14 @@ func encodeResponse(ctx context.Context, w http.ResponseWriter, response interfa
|
|||
return nil
|
||||
}
|
||||
|
||||
if render, ok := response.(renderHijacker); ok {
|
||||
render.hijackRender(ctx, w)
|
||||
return nil
|
||||
}
|
||||
|
||||
if e, ok := response.(statuser); ok {
|
||||
w.WriteHeader(e.status())
|
||||
if e.status() == http.StatusNoContent {
|
||||
w.WriteHeader(e.Status())
|
||||
if e.Status() == http.StatusNoContent {
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
|
@ -50,7 +53,7 @@ func encodeResponse(ctx context.Context, w http.ResponseWriter, response interfa
|
|||
// statuser allows response types to implement a custom
|
||||
// http success status - default is 200 OK
|
||||
type statuser interface {
|
||||
status() int
|
||||
Status() int
|
||||
}
|
||||
|
||||
// loads a html page
|
||||
|
|
@ -59,6 +62,12 @@ type htmlPage interface {
|
|||
error() error
|
||||
}
|
||||
|
||||
// renderHijacker can be implemented by response values to take control of
|
||||
// their own rendering.
|
||||
type renderHijacker interface {
|
||||
hijackRender(ctx context.Context, w http.ResponseWriter)
|
||||
}
|
||||
|
||||
func uintFromRequest(r *http.Request, name string) (uint64, error) {
|
||||
vars := mux.Vars(r)
|
||||
s, ok := vars[name]
|
||||
|
|
|
|||
|
|
@ -90,7 +90,11 @@ func encodeError(ctx context.Context, err error, w http.ResponseWriter) {
|
|||
Message: "Validation Failed",
|
||||
Errors: e.Invalid(),
|
||||
}
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
if statusErr, ok := e.(statuser); ok {
|
||||
w.WriteHeader(statusErr.Status())
|
||||
} else {
|
||||
w.WriteHeader(http.StatusUnprocessableEntity)
|
||||
}
|
||||
enc.Encode(ve)
|
||||
case permissionErrorInterface:
|
||||
pe := jsonError{
|
||||
|
|
|
|||
|
|
@ -884,7 +884,7 @@ type forgotPasswordResponse struct {
|
|||
}
|
||||
|
||||
func (r forgotPasswordResponse) error() error { return r.Err }
|
||||
func (r forgotPasswordResponse) status() int { return http.StatusAccepted }
|
||||
func (r forgotPasswordResponse) Status() int { return http.StatusAccepted }
|
||||
|
||||
func forgotPasswordEndpoint(ctx context.Context, request interface{}, svc fleet.Service) (interface{}, error) {
|
||||
req := request.(*forgotPasswordRequest)
|
||||
|
|
|
|||
Loading…
Reference in a new issue