Add ability to disable live queries (#2167)

- Add toggle to disable live queries in advanced settings
- Add new live query status endpoint (checks for disabled via config and Redis health)
- Update QueryPage UI to use new live query status endpoint

Implements #2140
This commit is contained in:
billcobbler 2020-01-13 18:53:04 -06:00 committed by Zachary Wasserman
parent 3b02640334
commit a83a26b279
25 changed files with 196 additions and 33 deletions

View file

@ -27,6 +27,7 @@ const formFields = [
'org_logo_url', 'org_name', 'osquery_enroll_secret', 'password', 'port', 'sender_address',
'server', 'user_name', 'verify_ssl_certs', 'idp_name', 'entity_id', 'issuer_uri', 'idp_image_url',
'metadata', 'metadata_url', 'enable_sso', 'enable_smtp', 'host_expiry_enabled', 'host_expiry_window',
'live_query_disabled',
];
const Header = ({ showAdvancedOptions }) => {
const CaratIcon = <Icon name={showAdvancedOptions ? 'downcarat' : 'upcarat'} />;
@ -63,6 +64,7 @@ class AppConfigForm extends Component {
enable_smtp: formFieldInterface.isRequired,
host_expiry_enabled: formFieldInterface.isRequired,
host_expiry_window: formFieldInterface.isRequired,
live_query_disabled: formFieldInterface.isRequired,
}).isRequired,
handleSubmit: PropTypes.func.isRequired,
smtpConfigured: PropTypes.bool.isRequired,
@ -111,6 +113,7 @@ class AppConfigForm extends Component {
<Slider {...fields.enable_start_tls} label="Enable STARTTLS?" />
<Slider {...fields.host_expiry_enabled} label="Host Expiry" />
<InputField {...fields.host_expiry_window} disabled={!fields.host_expiry_enabled.value} label="Host Expiry Window" />
<Slider {...fields.live_query_disabled} label="Disable Live Queries?" />
</div>
</div>
@ -120,6 +123,7 @@ class AppConfigForm extends Component {
<p><strong>Enable STARTTLS</strong> - Detects if STARTTLS is enabled in your SMTP server and starts to use it. <em className="hint hint--brand">(Default: <strong>On</strong>)</em></p>
<p><strong>Host Expiry</strong> - When enabled, allows automatic cleanup of hosts that have not communicated with Fleet in some number of days. <em className="hint hint--brand">(Default: <strong>Off</strong>)</em></p>
<p><strong>Host Expiry Window</strong> - If a host has not communicated with Fleet in the specified number of days, it will be removed.</p>
<p><strong>Disable Live Queries</strong> - When enabled, disables the ability to run live queries (ad hoc queries executed via the UI or fleetctl). <em className="hint hint--brand">(Default: <strong>Off</strong>)</em></p>
</div>
</div>
);

View file

@ -86,7 +86,7 @@ describe('AppConfigForm - form', () => {
it('renders advanced options when "Advanced Options" is clicked', () => {
expect(form.find({ name: 'domain' }).hostNodes().length).toEqual(1);
expect(form.find('Slider').length).toEqual(3);
expect(form.find('Slider').length).toEqual(4);
});
it('disables host expiry window by default', () => {
@ -102,5 +102,10 @@ describe('AppConfigForm - form', () => {
const inputElement = InputField.find('input');
expect(inputElement.hasClass('input-field--disabled')).toBe(false);
});
it('renders live query disabled input', () => {
form.find({ name: 'live_query_disabled' });
expect(form.length).toEqual(1);
});
});
});

View file

@ -1,6 +1,7 @@
import PropTypes from 'prop-types';
export default PropTypes.shape({
live_query_disabled: PropTypes.bool,
authentication_method: PropTypes.string,
authentication_type: PropTypes.string,
configured: PropTypes.bool,

View file

@ -34,5 +34,6 @@ export default {
return `/v1/kolide/users/${id}/admin`;
},
SSO: '/v1/kolide/sso',
STATUS_LIVE_QUERY: '/v1/kolide/status/live_query',
STATUS_RESULT_STORE: '/v1/kolide/status/result_store',
};

View file

@ -74,7 +74,7 @@ const filterTarget = (targetType) => {
export const formatConfigDataForServer = (config) => {
const orgInfoAttrs = pick(config, ['org_logo_url', 'org_name']);
const serverSettingsAttrs = pick(config, ['kolide_server_url', 'osquery_enroll_secret']);
const serverSettingsAttrs = pick(config, ['kolide_server_url', 'osquery_enroll_secret', 'live_query_disabled']);
const smtpSettingsAttrs = pick(config, [
'authentication_method', 'authentication_type', 'domain', 'enable_ssl_tls',
'enable_start_tls', 'password', 'port', 'sender_address', 'server', 'user_name', 'verify_ssl_certs',

View file

@ -38,6 +38,7 @@ describe('Kolide API - helpers', () => {
enable_start_tls: true,
host_expiry_enabled: false,
host_expiry_window: 0,
live_query_disabled: false,
};
it('splits config into categories for the server', () => {

View file

@ -6,6 +6,12 @@ export default (client) => {
const { STATUS_RESULT_STORE } = endpoints;
const endpoint = client.baseURL + STATUS_RESULT_STORE;
return client.authenticatedGet(endpoint);
},
live_query: () => {
const { STATUS_LIVE_QUERY } = endpoints;
const endpoint = client.baseURL + STATUS_LIVE_QUERY;
return client.authenticatedGet(endpoint);
},
};

View file

@ -93,8 +93,8 @@ export class QueryPage extends Component {
dispatch(hostActions.loadAll());
}
Kolide.status.result_store().catch((response) => {
this.setState({ resultStoreError: response.message.errors[0].reason });
Kolide.status.live_query().catch((response) => {
this.setState({ liveQueryError: response.message.errors[0].reason });
});
helpers.selectHosts(dispatch, {
@ -451,14 +451,14 @@ export class QueryPage extends Component {
return false;
}
renderResultStoreWarning = () => {
const { resultStoreError } = this.state;
renderLiveQueryWarning = () => {
const { liveQueryError } = this.state;
if (!resultStoreError) {
if (!liveQueryError) {
return false;
}
const message = `Live query disabled due to Redis error: ${resultStoreError}`;
const message = `Live query disabled due to error: ${liveQueryError}`;
return (
<WarningBanner labelText="Warning!" className={`${baseClass}__warning`} message={message} shouldShowWarning />
@ -505,7 +505,7 @@ export class QueryPage extends Component {
renderTargetsInput = () => {
const { onFetchTargets, onRunQuery, onStopQuery, onTargetSelect } = this;
const { campaign, queryIsRunning, targetsCount, targetsError, runQueryMilliseconds, resultStoreError } = this.state;
const { campaign, queryIsRunning, targetsCount, targetsError, runQueryMilliseconds, liveQueryError } = this.state;
const { selectedTargets } = this.props;
return (
@ -520,7 +520,7 @@ export class QueryPage extends Component {
selectedTargets={selectedTargets}
targetsCount={targetsCount}
queryTimerMilliseconds={runQueryMilliseconds}
disableRun={resultStoreError !== undefined}
disableRun={liveQueryError !== undefined}
/>
);
}
@ -536,7 +536,7 @@ export class QueryPage extends Component {
onUpdateQuery,
renderResultsTable,
renderTargetsInput,
renderResultStoreWarning,
renderLiveQueryWarning,
} = this;
const { queryIsRunning } = this.state;
const {
@ -569,7 +569,7 @@ export class QueryPage extends Component {
title={title}
/>
</div>
{renderResultStoreWarning()}
{renderLiveQueryWarning()}
{renderTargetsInput()}
{renderResultsTable()}
</div>

View file

@ -13,6 +13,7 @@ export const configStub = {
},
server_settings: {
kolide_server_url: '',
live_query_disabled: false,
},
smtp_settings: {
configured: false,
@ -52,6 +53,7 @@ export const flatConfigStub = {
enable_start_tls: true,
host_expiry_enabled: false,
host_expiry_window: 0,
live_query_disabled: false,
};
export const hostStub = {

View file

@ -117,9 +117,10 @@ func (d *Datastore) SaveAppConfig(info *kolide.AppConfig) error {
fim_interval,
fim_file_accesses,
host_expiry_enabled,
host_expiry_window
host_expiry_window,
live_query_disabled
)
VALUES( 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )
VALUES( 1, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ? )
ON DUPLICATE KEY UPDATE
org_name = VALUES(org_name),
org_logo_url = VALUES(org_logo_url),
@ -147,7 +148,8 @@ func (d *Datastore) SaveAppConfig(info *kolide.AppConfig) error {
fim_interval = VALUES(fim_interval),
fim_file_accesses = VALUES(fim_file_accesses),
host_expiry_enabled = VALUES(host_expiry_enabled),
host_expiry_window = VALUES(host_expiry_window)
host_expiry_window = VALUES(host_expiry_window),
live_query_disabled = VALUES(live_query_disabled)
`
_, err = d.db.Exec(insertStatement,
@ -178,6 +180,7 @@ func (d *Datastore) SaveAppConfig(info *kolide.AppConfig) error {
info.FIMFileAccesses,
info.HostExpiryEnabled,
info.HostExpiryWindow,
info.LiveQueryDisabled,
)
return err

View file

@ -0,0 +1,25 @@
package tables
import (
"database/sql"
)
func init() {
MigrationClient.AddMigration(Up20191220130734, Down20191220130734)
}
func Up20191220130734(tx *sql.Tx) error {
_, err := tx.Exec(
"ALTER TABLE `app_configs` " +
"ADD COLUMN `live_query_disabled` TINYINT(1) NOT NULL DEFAULT FALSE;",
)
return err
}
func Down20191220130734(tx *sql.Tx) error {
_, err := tx.Exec(
"ALTER TABLE `app_configs` " +
"DROP COLUMN `live_query_disabled`;",
)
return err
}

View file

@ -140,6 +140,9 @@ type AppConfig struct {
HostExpiryEnabled bool `db:"host_expiry_enabled"`
// HostExpiryWindow defines a number in days after which a host will be removed if it has not communicated with Fleet.
HostExpiryWindow int `db:"host_expiry_window"`
// LiveQueryDisabled defines whether live queries are disabled.
LiveQueryDisabled bool `db:"live_query_disabled"`
}
// ModifyAppConfigRequest contains application configuration information
@ -228,8 +231,9 @@ type OrgInfo struct {
// ServerSettings contains general settings about the kolide App.
type ServerSettings struct {
KolideServerURL *string `json:"kolide_server_url,omitempty"`
EnrollSecret *string `json:"osquery_enroll_secret,omitempty"`
KolideServerURL *string `json:"kolide_server_url,omitempty"`
EnrollSecret *string `json:"osquery_enroll_secret,omitempty"`
LiveQueryDisabled *bool `json:"live_query_disabled,omitempty"`
}
// HostExpirySettings contains settings pertaining to automatic host expiry.

View file

@ -6,4 +6,8 @@ type StatusService interface {
// StatusResultStore returns nil if the result store is functioning
// correctly, or an error indicating the problem.
StatusResultStore(ctx context.Context) error
// StatusLiveQuery returns nil if live queries are enabled, or an
// error indicating the problem.
StatusLiveQuery(ctx context.Context) error
}

View file

@ -11,6 +11,7 @@ package mock
//go:generate mockimpl -o datastore_osquery_options.go "s *OsqueryOptionsStore" "kolide.OsqueryOptionsStore"
//go:generate mockimpl -o datastore_scheduled_queries.go "s *ScheduledQueryStore" "kolide.ScheduledQueryStore"
//go:generate mockimpl -o datastore_queries.go "s *QueryStore" "kolide.QueryStore"
//go:generate mockimpl -o datastore_query_results.go "s *QueryResultStore" "kolide.QueryResultStore"
//go:generate mockimpl -o datastore_campaigns.go "s *CampaignStore" "kolide.CampaignStore"
//go:generate mockimpl -o datastore_sessions.go "s *SessionStore" "kolide.SessionStore"
@ -35,6 +36,7 @@ type Store struct {
PackStore
UserStore
QueryStore
QueryResultStore
}
func (m *Store) Drop() error {

View file

@ -0,0 +1,43 @@
// Automatically generated by mockimpl. DO NOT EDIT!
package mock
import (
"context"
"github.com/kolide/fleet/server/kolide"
)
var _ kolide.QueryResultStore = (*QueryResultStore)(nil)
type WriteResultFunc func(result kolide.DistributedQueryResult) error
type ReadChannelFunc func(ctx context.Context, query kolide.DistributedQueryCampaign) (<-chan interface{}, error)
type HealthCheckFunc func() error
type QueryResultStore struct {
WriteResultFunc WriteResultFunc
WriteResultFuncInvoked bool
ReadChannelFunc ReadChannelFunc
ReadChannelFuncInvoked bool
HealthCheckFunc HealthCheckFunc
HealthCheckFuncInvoked bool
}
func (s *QueryResultStore) WriteResult(result kolide.DistributedQueryResult) error {
s.WriteResultFuncInvoked = true
return s.WriteResultFunc(result)
}
func (s *QueryResultStore) ReadChannel(ctx context.Context, query kolide.DistributedQueryCampaign) (<-chan interface{}, error) {
s.ReadChannelFuncInvoked = true
return s.ReadChannelFunc(ctx, query)
}
func (s *QueryResultStore) HealthCheck() error {
s.HealthCheckFuncInvoked = true
return s.HealthCheckFunc()
}

View file

@ -160,6 +160,8 @@ func (r *redisQueryResults) HealthCheck() error {
conn := r.pool.Get()
defer conn.Close()
_, err := conn.Do("PING")
return err
if _, err := conn.Do("PING"); err != nil {
return errors.Wrap(err, "reading from redis")
}
return nil
}

View file

@ -63,8 +63,9 @@ func makeGetAppConfigEndpoint(svc kolide.Service) endpoint.Endpoint {
OrgLogoURL: &config.OrgLogoURL,
},
ServerSettings: &kolide.ServerSettings{
KolideServerURL: &config.KolideServerURL,
EnrollSecret: &config.EnrollSecret,
KolideServerURL: &config.KolideServerURL,
EnrollSecret: &config.EnrollSecret,
LiveQueryDisabled: &config.LiveQueryDisabled,
},
SMTPSettings: smtpSettings,
SSOSettings: ssoSettings,
@ -87,8 +88,9 @@ func makeModifyAppConfigEndpoint(svc kolide.Service) endpoint.Endpoint {
OrgLogoURL: &config.OrgLogoURL,
},
ServerSettings: &kolide.ServerSettings{
KolideServerURL: &config.KolideServerURL,
EnrollSecret: &config.EnrollSecret,
KolideServerURL: &config.KolideServerURL,
EnrollSecret: &config.EnrollSecret,
LiveQueryDisabled: &config.LiveQueryDisabled,
},
SMTPSettings: smtpSettingsFromAppConfig(config),
SSOSettings: &kolide.SSOSettingsPayload{

View file

@ -41,6 +41,7 @@ func testGetAppConfig(t *testing.T, r *testResource) {
assert.Equal(t, "http://foo.bar/image.png", *configInfo.OrgInfo.OrgLogoURL)
assert.False(t, *configInfo.HostExpirySettings.HostExpiryEnabled)
assert.Equal(t, 0, *configInfo.HostExpirySettings.HostExpiryWindow)
assert.False(t, *configInfo.ServerSettings.LiveQueryDisabled)
}
@ -62,6 +63,7 @@ func testModifyAppConfig(t *testing.T, r *testResource) {
EntityID: "kolide",
HostExpiryEnabled: true,
HostExpiryWindow: 42,
LiveQueryDisabled: true,
}
payload := appConfigPayloadFromAppConfig(config)
payload.SMTPTest = new(bool)
@ -95,6 +97,8 @@ func testModifyAppConfig(t *testing.T, r *testResource) {
// verify that host expiry settings were saved
assert.True(t, saved.HostExpiryEnabled)
assert.Equal(t, 42, saved.HostExpiryWindow)
//verify that live query disabled setting was saved
assert.True(t, saved.LiveQueryDisabled)
}
@ -131,7 +135,8 @@ func appConfigPayloadFromAppConfig(config *kolide.AppConfig) *kolide.AppConfigPa
OrgName: &config.OrgName,
},
ServerSettings: &kolide.ServerSettings{
KolideServerURL: &config.KolideServerURL,
KolideServerURL: &config.KolideServerURL,
LiveQueryDisabled: &config.LiveQueryDisabled,
},
SMTPSettings: smtpSettingsFromAppConfig(config),
SSOSettings: &kolide.SSOSettingsPayload{

View file

@ -7,15 +7,25 @@ import (
"github.com/kolide/fleet/server/kolide"
)
type statusResultStoreResponse struct {
type statusResponse struct {
Err error `json:"error,omitempty"`
}
func (m statusResultStoreResponse) error() error { return m.Err }
func (m statusResponse) error() error { return m.Err }
func makeStatusLiveQueryEndpoint(svc kolide.Service) endpoint.Endpoint {
return func(ctx context.Context, req interface{}) (interface{}, error) {
var resp statusResponse
if err := svc.StatusLiveQuery(ctx); err != nil {
resp.Err = err
}
return resp, nil
}
}
func makeStatusResultStoreEndpoint(svc kolide.Service) endpoint.Endpoint {
return func(ctx context.Context, req interface{}) (interface{}, error) {
var resp statusResultStoreResponse
var resp statusResponse
if err := svc.StatusResultStore(ctx); err != nil {
resp.Err = err
}

View file

@ -98,6 +98,7 @@ type KolideEndpoints struct {
GetFIM endpoint.Endpoint
ModifyFIM endpoint.Endpoint
StatusResultStore endpoint.Endpoint
StatusLiveQuery endpoint.Endpoint
}
// MakeKolideServerEndpoints creates the Kolide API endpoints.
@ -192,6 +193,7 @@ func MakeKolideServerEndpoints(svc kolide.Service, jwtKey, urlPrefix string) Kol
// Authenticated status endpoints
StatusResultStore: authenticatedUser(jwtKey, svc, makeStatusResultStoreEndpoint(svc)),
StatusLiveQuery: authenticatedUser(jwtKey, svc, makeStatusLiveQueryEndpoint(svc)),
// Osquery endpoints
EnrollAgent: makeEnrollAgentEndpoint(svc),
@ -285,6 +287,7 @@ type kolideHandlers struct {
ModifyFIM http.Handler
GetFIM http.Handler
StatusResultStore http.Handler
StatusLiveQuery http.Handler
}
func makeKolideKitHandlers(e KolideEndpoints, opts []kithttp.ServerOption) *kolideHandlers {
@ -374,6 +377,7 @@ func makeKolideKitHandlers(e KolideEndpoints, opts []kithttp.ServerOption) *koli
ModifyFIM: newServer(e.ModifyFIM, decodeModifyFIMRequest),
GetFIM: newServer(e.GetFIM, decodeNoParamsRequest),
StatusResultStore: newServer(e.StatusResultStore, decodeNoParamsRequest),
StatusLiveQuery: newServer(e.StatusLiveQuery, decodeNoParamsRequest),
}
}
@ -504,6 +508,7 @@ func attachKolideAPIRoutes(r *mux.Router, h *kolideHandlers) {
r.Handle("/api/v1/kolide/targets", h.SearchTargets).Methods("POST").Name("search_targets")
r.Handle("/api/v1/kolide/status/result_store", h.StatusResultStore).Methods("GET").Name("status_result_store")
r.Handle("/api/v1/kolide/status/live_query", h.StatusLiveQuery).Methods("GET").Name("status_live_query")
r.Handle("/api/v1/osquery/enroll", h.EnrollAgent).Methods("POST").Name("enroll_agent")
r.Handle("/api/v1/osquery/config", h.GetClientConfig).Methods("POST").Name("get_client_config")

View file

@ -120,6 +120,9 @@ func appConfigFromAppConfigPayload(p kolide.AppConfigPayload, config kolide.AppC
if p.ServerSettings != nil && p.ServerSettings.EnrollSecret != nil {
config.EnrollSecret = *p.ServerSettings.EnrollSecret
}
if p.ServerSettings != nil && p.ServerSettings.LiveQueryDisabled != nil {
config.LiveQueryDisabled = *p.ServerSettings.LiveQueryDisabled
}
if p.SSOSettings != nil {
if p.SSOSettings.EnableSSO != nil {

View file

@ -48,7 +48,8 @@ func TestCreateAppConfig(t *testing.T) {
OrgName: stringPtr("Acme"),
},
ServerSettings: &kolide.ServerSettings{
KolideServerURL: stringPtr("https://acme.co:8080/"),
KolideServerURL: stringPtr("https://acme.co:8080/"),
LiveQueryDisabled: boolPtr(true),
},
},
},
@ -63,5 +64,6 @@ func TestCreateAppConfig(t *testing.T) {
assert.Equal(t, *payload.OrgInfo.OrgLogoURL, result.OrgLogoURL)
assert.Equal(t, *payload.OrgInfo.OrgName, result.OrgName)
assert.Equal(t, "https://acme.co:8080", result.KolideServerURL)
assert.Equal(t, *payload.ServerSettings.LiveQueryDisabled, result.LiveQueryDisabled)
}
}

View file

@ -30,6 +30,10 @@ func uintPtr(n uint) *uint {
}
func (svc service) NewDistributedQueryCampaign(ctx context.Context, queryString string, hosts []uint, labels []uint) (*kolide.DistributedQueryCampaign, error) {
if err := svc.StatusLiveQuery(ctx); err != nil {
return nil, err
}
vc, ok := viewer.FromContext(ctx)
if !ok {
return nil, errNoContext
@ -57,7 +61,7 @@ func (svc service) NewDistributedQueryCampaign(ctx context.Context, queryString
// Add host targets
for _, hid := range hosts {
_, err = svc.ds.NewDistributedQueryCampaignTarget(&kolide.DistributedQueryCampaignTarget{
Type: kolide.TargetHost,
Type: kolide.TargetHost,
DistributedQueryCampaignID: campaign.ID,
TargetID: hid,
})
@ -69,7 +73,7 @@ func (svc service) NewDistributedQueryCampaign(ctx context.Context, queryString
// Add label targets
for _, lid := range labels {
_, err = svc.ds.NewDistributedQueryCampaignTarget(&kolide.DistributedQueryCampaignTarget{
Type: kolide.TargetLabel,
Type: kolide.TargetLabel,
DistributedQueryCampaignID: campaign.ID,
TargetID: lid,
})

View file

@ -816,9 +816,21 @@ func TestDetailQueries(t *testing.T) {
}
func TestNewDistributedQueryCampaign(t *testing.T) {
ds := &mock.Store{
AppConfigStore: mock.AppConfigStore{
AppConfigFunc: func() (*kolide.AppConfig, error) {
config := &kolide.AppConfig{}
return config, nil
},
},
}
rs := &mock.QueryResultStore{
HealthCheckFunc: func() error {
return nil
},
}
mockClock := clock.NewMockClock()
ds := new(mock.Store)
svc, err := newTestServiceWithClock(ds, nil, mockClock)
svc, err := newTestServiceWithClock(ds, rs, mockClock)
require.Nil(t, err)
ds.LabelQueriesForHostFunc = func(host *kolide.Host, cutoff time.Time) (map[string]string, error) {

View file

@ -1,7 +1,24 @@
package service
import "context"
import (
"context"
"github.com/pkg/errors"
)
func (svc service) StatusResultStore(ctx context.Context) error {
return svc.resultStore.HealthCheck()
}
func (svc service) StatusLiveQuery(ctx context.Context) error {
cfg, err := svc.AppConfig(ctx)
if err != nil {
return errors.Wrap(err, "retreiving app config")
}
if cfg.LiveQueryDisabled {
return errors.New("disabled by administrator")
}
return svc.StatusResultStore(ctx)
}