diff --git a/cmd/api/handlers/console.go b/cmd/api/handlers/console.go index bd1ef456..5c843b17 100644 --- a/cmd/api/handlers/console.go +++ b/cmd/api/handlers/console.go @@ -84,11 +84,10 @@ func (h *HandlersApi) ConsoleSessionCreateHandler(w http.ResponseWriter, r *http apiErrorResponse(w, "error creating console session", http.StatusInternalServerError, err) return } - // Dispatch a priming metadata query so the node's next QueryRead - // returns an accelerated interval (fast polling) before the operator - // types their first command, and live osquery_info metadata can be - // surfaced in the console header. A priming failure is non-fatal: - // the session is still usable, acceleration just won't be pre-warmed. + // Dispatch a priming metadata query so live osquery_info metadata can + // be surfaced in the console header. When acceleration is enabled, the + // node's next QueryRead can also switch to fast polling before the + // operator types their first command. A priming failure is non-fatal. var priming *console.Command if primingCmd, primingErr := h.Console.SubmitPrimingCommand(session.ID, h.consolePrimingTimeout()); primingErr == nil { priming = &primingCmd diff --git a/cmd/api/handlers/features.go b/cmd/api/handlers/features.go index 9c79787e..a6d11e31 100644 --- a/cmd/api/handlers/features.go +++ b/cmd/api/handlers/features.go @@ -13,6 +13,7 @@ type FeaturesResponse struct { // false the /api/v1/service-config routes are not registered at all. ServiceConfig bool `json:"service_config"` Accelerated bool `json:"accelerated"` + Console bool `json:"console"` FileExplorer bool `json:"file_explorer"` } @@ -25,6 +26,7 @@ func (h *HandlersApi) FeaturesHandler(w http.ResponseWriter, r *http.Request) { Posture: h.PostureEnabled, ServiceConfig: h.ServiceConfigEnabled, Accelerated: h.OsqueryValues.Accelerated, - FileExplorer: h.OsqueryValues.Query && h.OsqueryValues.Accelerated && h.OsqueryValues.FileExplorer, + Console: h.OsqueryValues.Query && h.OsqueryValues.Console, + FileExplorer: h.OsqueryValues.Query && h.OsqueryValues.FileExplorer, }) } diff --git a/cmd/api/handlers/features_test.go b/cmd/api/handlers/features_test.go index 06db2bc8..ed0b1077 100644 --- a/cmd/api/handlers/features_test.go +++ b/cmd/api/handlers/features_test.go @@ -97,7 +97,39 @@ func TestFeaturesHandlerReportsAcceleratedEnabled(t *testing.T) { } } -func TestFeaturesHandlerReportsFileExplorerOnlyWhenQueryAcceleratedAndEnabled(t *testing.T) { +func TestFeaturesHandlerReportsConsoleOnlyWhenQueryAndEnabled(t *testing.T) { + for _, tt := range []struct { + name string + cfg config.YAMLConfigurationOsquery + want bool + }{ + {name: "disabled by default", cfg: config.YAMLConfigurationOsquery{}, want: false}, + {name: "requires query", cfg: config.YAMLConfigurationOsquery{Console: true}, want: false}, + {name: "does not require accelerated", cfg: config.YAMLConfigurationOsquery{Query: true, Console: true}, want: true}, + } { + t.Run(tt.name, func(t *testing.T) { + h := &HandlersApi{} + WithOsqueryValues(tt.cfg)(h) + r := httptest.NewRequest(http.MethodGet, "/api/v1/features", nil) + w := httptest.NewRecorder() + + h.FeaturesHandler(w, r) + + if w.Code != http.StatusOK { + t.Fatalf("status: got %d want 200", w.Code) + } + var resp FeaturesResponse + if err := json.Unmarshal(w.Body.Bytes(), &resp); err != nil { + t.Fatalf("decode: %v", err) + } + if resp.Console != tt.want { + t.Fatalf("console feature: got %t want %t", resp.Console, tt.want) + } + }) + } +} + +func TestFeaturesHandlerReportsFileExplorerOnlyWhenQueryAndEnabled(t *testing.T) { for _, tt := range []struct { name string cfg config.YAMLConfigurationOsquery @@ -105,7 +137,7 @@ func TestFeaturesHandlerReportsFileExplorerOnlyWhenQueryAcceleratedAndEnabled(t }{ {name: "disabled by default", cfg: config.YAMLConfigurationOsquery{}, want: false}, {name: "requires query", cfg: config.YAMLConfigurationOsquery{Accelerated: true, FileExplorer: true}, want: false}, - {name: "requires accelerated", cfg: config.YAMLConfigurationOsquery{Query: true, FileExplorer: true}, want: false}, + {name: "does not require accelerated", cfg: config.YAMLConfigurationOsquery{Query: true, FileExplorer: true}, want: true}, {name: "enabled", cfg: config.YAMLConfigurationOsquery{Query: true, Accelerated: true, FileExplorer: true}, want: true}, } { t.Run(tt.name, func(t *testing.T) { diff --git a/cmd/api/handlers/file_explorer.go b/cmd/api/handlers/file_explorer.go index 8398794a..7b340732 100644 --- a/cmd/api/handlers/file_explorer.go +++ b/cmd/api/handlers/file_explorer.go @@ -56,10 +56,11 @@ func (h *HandlersApi) FileExplorerSessionCreateHandler(w http.ResponseWriter, r apiErrorResponse(w, "error creating file explorer session", http.StatusInternalServerError, err) return } - // Dispatch a priming metadata query so the node's next QueryRead - // returns an accelerated interval (fast polling) before the operator - // expands the first directory, and live osquery_info metadata can be - // surfaced in the file explorer header. Non-fatal on failure. + // Dispatch a priming metadata query so live osquery_info metadata can + // be surfaced in the file explorer header. When acceleration is + // enabled, the node's next QueryRead can also switch to fast polling + // before the operator expands the first directory. Non-fatal on + // failure. var priming *fileexplorer.Request if primingReq, primingErr := h.FileExplorer.SubmitPrimingRequest(session.ID, h.fileExplorerRequestTimeout()); primingErr == nil { priming = &primingReq diff --git a/cmd/api/main.go b/cmd/api/main.go index 6b520d64..d6c862d3 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -741,27 +741,29 @@ func osctrlAPIService() { muxAPI.Handle( "POST "+_apiPath(apiQueriesPath)+"/{env}/{action}/{name}", handlerAuthCheck(http.HandlerFunc(handlersApi.QueriesActionHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - // API: accelerated per-node console - muxAPI.Handle( - "POST "+_apiPath("/console")+"/{env}/nodes/{uuid}/sessions", - handlerAuthCheck(http.HandlerFunc(handlersApi.ConsoleSessionCreateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - muxAPI.Handle( - "GET "+_apiPath("/console")+"/{env}/sessions/{session_id}", - handlerAuthCheck(http.HandlerFunc(handlersApi.ConsoleSessionShowHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - muxAPI.Handle( - "DELETE "+_apiPath("/console")+"/{env}/sessions/{session_id}", - handlerAuthCheck(http.HandlerFunc(handlersApi.ConsoleSessionDeleteHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - muxAPI.Handle( - "POST "+_apiPath("/console")+"/{env}/sessions/{session_id}/commands", - handlerAuthCheck(http.HandlerFunc(handlersApi.ConsoleCommandCreateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - muxAPI.Handle( - "GET "+_apiPath("/console")+"/{env}/sessions/{session_id}/commands/{command_id}", - handlerAuthCheck(http.HandlerFunc(handlersApi.ConsoleCommandShowHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - muxAPI.Handle( - "GET "+_apiPath("/console")+"/{env}/sessions/{session_id}/commands/{command_id}/results", - handlerAuthCheck(http.HandlerFunc(handlersApi.ConsoleCommandResultsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - if flagParams.Osquery.Accelerated && flagParams.Osquery.FileExplorer { - // API: accelerated per-node file explorer + if flagParams.Osquery.Console { + // API: per-node console + muxAPI.Handle( + "POST "+_apiPath("/console")+"/{env}/nodes/{uuid}/sessions", + handlerAuthCheck(http.HandlerFunc(handlersApi.ConsoleSessionCreateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "GET "+_apiPath("/console")+"/{env}/sessions/{session_id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.ConsoleSessionShowHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "DELETE "+_apiPath("/console")+"/{env}/sessions/{session_id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.ConsoleSessionDeleteHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath("/console")+"/{env}/sessions/{session_id}/commands", + handlerAuthCheck(http.HandlerFunc(handlersApi.ConsoleCommandCreateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "GET "+_apiPath("/console")+"/{env}/sessions/{session_id}/commands/{command_id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.ConsoleCommandShowHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "GET "+_apiPath("/console")+"/{env}/sessions/{session_id}/commands/{command_id}/results", + handlerAuthCheck(http.HandlerFunc(handlersApi.ConsoleCommandResultsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + } + if flagParams.Osquery.FileExplorer { + // API: per-node file explorer muxAPI.Handle( "POST "+_apiPath(apiFileExplorerPath)+"/{env}/nodes/{uuid}/sessions", handlerAuthCheck(http.HandlerFunc(handlersApi.FileExplorerSessionCreateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) diff --git a/cmd/tls/handlers/console_acceleration_test.go b/cmd/tls/handlers/console_acceleration_test.go index 5dd2aed5..a249f51d 100644 --- a/cmd/tls/handlers/console_acceleration_test.go +++ b/cmd/tls/handlers/console_acceleration_test.go @@ -25,7 +25,7 @@ func TestShouldAccelerateQueryReadForActiveConsoleSession(t *testing.T) { require.NoError(t, err) require.NoError(t, db.AutoMigrate(&console.Session{})) queryManager := queries.CreateQueries(db) - handler := &HandlersTLS{Queries: queryManager} + handler := &HandlersTLS{Queries: queryManager, OsqueryValues: &config.YAMLConfigurationOsquery{Console: true}} node := nodes.OsqueryNode{ID: 7, UUID: "NODE-UUID", EnvironmentID: 1} otherNode := nodes.OsqueryNode{ID: 8, UUID: "OTHER-NODE-UUID", EnvironmentID: 1} @@ -91,6 +91,26 @@ func TestShouldAccelerateQueryReadForActiveConsoleSession(t *testing.T) { require.True(t, handler.shouldAccelerateQueryRead(node, false)) } +func TestShouldNotAccelerateQueryReadForConsoleWhenConsoleDisabled(t *testing.T) { + db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate(&console.Session{})) + queryManager := queries.CreateQueries(db) + handler := &HandlersTLS{Queries: queryManager, OsqueryValues: &config.YAMLConfigurationOsquery{}} + node := nodes.OsqueryNode{ID: 7, UUID: "NODE-UUID", EnvironmentID: 1} + + require.NoError(t, db.Create(&console.Session{ + EnvironmentID: node.EnvironmentID, + NodeID: node.ID, + NodeUUID: node.UUID, + Creator: "alice", + CWD: "/", + Platform: "linux", + Active: true, + }).Error) + require.False(t, handler.shouldAccelerateQueryRead(node, false)) +} + func TestShouldAccelerateQueryReadForActiveFileExplorerSession(t *testing.T) { db, err := gorm.Open(sqlite.Open("file:"+t.Name()+"?mode=memory&cache=shared"), &gorm.Config{}) require.NoError(t, err) @@ -160,7 +180,7 @@ func TestQueryReadAcceleratesOnlyConsoleSessionNode(t *testing.T) { WithQueries(queryManager), WithSettings(settingsMgr), WithWriteHandler(NewBatchWriter(100, time.Hour, 10, *nodesMgr)), - WithOsqueryValues(&config.YAMLConfigurationOsquery{Accelerated: true}), + WithOsqueryValues(&config.YAMLConfigurationOsquery{Accelerated: true, Console: true}), ) consoleResp := queryReadResponse(t, handler, env.UUID, consoleNode.NodeKey) diff --git a/cmd/tls/handlers/handlers.go b/cmd/tls/handlers/handlers.go index bcf3c677..4a393225 100644 --- a/cmd/tls/handlers/handlers.go +++ b/cmd/tls/handlers/handlers.go @@ -293,6 +293,9 @@ func (h *HandlersTLS) shouldAccelerateQueryRead(node nodes.OsqueryNode, queryAcc } func (h *HandlersTLS) hasActiveConsoleSession(node nodes.OsqueryNode) bool { + if h.OsqueryValues == nil || !h.OsqueryValues.Console { + return false + } if node.ID == 0 || node.UUID == "" || node.EnvironmentID == 0 || h.Queries == nil || h.Queries.DB == nil { return false } diff --git a/deploy/config/api.yml b/deploy/config/api.yml index dce7106b..a9715ed1 100644 --- a/deploy/config/api.yml +++ b/deploy/config/api.yml @@ -185,7 +185,9 @@ osquery: carve: true # Whether accelerated query polling features are enabled. accelerated: false - # Enables accelerated file explorer routes when query and accelerated are also true. + # Whether per-node console routes are enabled when query is also true. + console: false + # Whether per-node file explorer routes are enabled when query is also true. fileExplorer: false # Prevents API-driven osquery configuration changes when true. readOnly: false diff --git a/deploy/config/tls.yml b/deploy/config/tls.yml index 94140712..ea08d3eb 100644 --- a/deploy/config/tls.yml +++ b/deploy/config/tls.yml @@ -175,7 +175,9 @@ osquery: carve: true # Allows accelerated query polling responses. accelerated: false - # Enables file explorer query behavior when accelerated/query are also enabled. + # Allows active console sessions to request accelerated query polling when accelerated is also enabled. + console: false + # Allows active file explorer sessions to request accelerated query polling when accelerated is also enabled. fileExplorer: false # Prevents config changes through operator surfaces when true. readOnly: false diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index f6482c9c..a5687860 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -108,6 +108,7 @@ services: #### osquery settings #### - OSQUERY_TABLES=/usr/src/app/deploy/osquery/data/${OSQUERY_VERSION}.json - OSQUERY_ACCELERATED=true + - OSQUERY_CONSOLE=true - OSQUERY_FILE_EXPLORER=true #### Database settings #### - DB_HOST=osctrl-postgres diff --git a/frontend/src/api/features.ts b/frontend/src/api/features.ts index 9dcced70..f1d249e7 100644 --- a/frontend/src/api/features.ts +++ b/frontend/src/api/features.ts @@ -4,6 +4,7 @@ export interface Features { posture: boolean; service_config: boolean; accelerated: boolean; + console?: boolean; file_explorer: boolean; } diff --git a/frontend/src/features/nodes/NodeConsolePage.tsx b/frontend/src/features/nodes/NodeConsolePage.tsx index d4d95007..05ebe8a5 100644 --- a/frontend/src/features/nodes/NodeConsolePage.tsx +++ b/frontend/src/features/nodes/NodeConsolePage.tsx @@ -109,10 +109,8 @@ export function NodeConsolePanel({ env, uuid }: { env: string; uuid: string }) { // Poll the priming metadata command until it reaches a terminal // status, then fetch its osquery_info results and surface them as - // live node metadata in the console header. This is what makes the - // console "already responsive" on first open: the priming query both - // warms acceleration (so the node polls fast) and gives us fresh - // osquery version / build / start_time values. + // live node metadata in the console header. When accelerated polling + // is enabled server-side this also warms the node into fast polling. const primingCommandId = primingCommand?.id; const primingQuery = useQuery({ queryKey: ['console-priming', env, session?.id, primingCommandId], @@ -302,10 +300,10 @@ export function NodeConsolePanel({ env, uuid }: { env: string; uuid: string }) { {primingCommand && ( )} diff --git a/frontend/src/features/nodes/NodeDetailPage.test.tsx b/frontend/src/features/nodes/NodeDetailPage.test.tsx index 1d29f12d..ac5468aa 100644 --- a/frontend/src/features/nodes/NodeDetailPage.test.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.test.tsx @@ -621,8 +621,8 @@ describe('NodeDetailPage', () => { expect(screen.queryByText('7d 3h 12m')).not.toBeInTheDocument(); }); - it('shows the console action only when accelerated queries are enabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: true, file_explorer: false }); + it('shows the console action when console is enabled', async () => { + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, console: true, file_explorer: false }); const router = makeTestRouter(); renderWithProviders(router); @@ -634,8 +634,8 @@ describe('NodeDetailPage', () => { expect(screen.getByRole('link', { name: /console/i })).toBeInTheDocument(); }); - it('hides the console action when accelerated queries are disabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, file_explorer: false }); + it('hides the console action when console is disabled', async () => { + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: true, console: false, file_explorer: false }); renderWithProviders(makeTestRouter()); @@ -646,8 +646,8 @@ describe('NodeDetailPage', () => { expect(screen.queryByRole('link', { name: /console/i })).not.toBeInTheDocument(); }); - it('hides the console action from non-admin users even when accelerated queries are enabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: true, file_explorer: false }); + it('hides the console action from non-admin users even when console is enabled', async () => { + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, console: true, file_explorer: false }); mockGetMe.mockResolvedValue({ admin: false, permissions: { @@ -664,8 +664,8 @@ describe('NodeDetailPage', () => { expect(screen.queryByRole('link', { name: /console/i })).not.toBeInTheDocument(); }); - it('shows the file explorer tab when file explorer is enabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: true, file_explorer: true }); + it('shows the file explorer tab when file explorer is enabled without acceleration', async () => { + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, file_explorer: true }); renderWithProviders(makeTestRouter()); diff --git a/frontend/src/features/nodes/NodeDetailPage.tsx b/frontend/src/features/nodes/NodeDetailPage.tsx index a28acf78..c13ddad8 100644 --- a/frontend/src/features/nodes/NodeDetailPage.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.tsx @@ -609,7 +609,7 @@ export function NodeDetailPage() { staleTime: 5 * 60_000, }); const postureEnabled = features?.posture === true; - const acceleratedEnabled = features?.accelerated === true; + const consoleEnabled = features?.console === true; const fileExplorerEnabled = features?.file_explorer === true; const visibleTabs = useMemo( () => TABS.filter((tab) => { @@ -882,7 +882,7 @@ export function NodeDetailPage() { Tag )} - {acceleratedEnabled && canAdminNode && ( + {consoleEnabled && canAdminNode && (