From 2175260ae36b43e7ed7264ec257bd970a9f82b06 Mon Sep 17 00:00:00 2001 From: Javier Marcos <1271349+javuto@users.noreply.github.com> Date: Sat, 15 Aug 2026 23:10:27 +0200 Subject: [PATCH] Opt-in switch for the service configuration API and frontend --- cmd/api/handlers/features.go | 16 ++-- cmd/api/handlers/features_test.go | 26 ++++++ cmd/api/handlers/handlers.go | 60 +++++++------ cmd/api/main.go | 86 +++++++++++-------- deploy/config/api.yml | 9 ++ deploy/config/tls.yml | 6 ++ docker-compose-dev.yml | 2 + frontend/src/api/features.ts | 1 + frontend/src/components/chrome/SideNav.tsx | 13 ++- .../environments/EnvConfigPage.test.tsx | 6 +- .../features/nodes/NodeDetailPage.test.tsx | 24 +++--- .../features/nodes/NodesTablePage.test.tsx | 6 +- .../service-config/ServiceConfigPage.test.tsx | 33 ++++++- .../service-config/ServiceConfigPage.tsx | 36 ++++++-- pkg/config/flags.go | 7 ++ pkg/config/flags_test.go | 26 ++++++ pkg/config/types.go | 14 ++- 17 files changed, 274 insertions(+), 97 deletions(-) diff --git a/cmd/api/handlers/features.go b/cmd/api/handlers/features.go index e81acc4e..9c79787e 100644 --- a/cmd/api/handlers/features.go +++ b/cmd/api/handlers/features.go @@ -8,9 +8,12 @@ import ( // FeaturesResponse advertises server-side feature switches consumed by the SPA. type FeaturesResponse struct { - Posture bool `json:"posture"` - Accelerated bool `json:"accelerated"` - FileExplorer bool `json:"file_explorer"` + Posture bool `json:"posture"` + // ServiceConfig gates the whole Service Config section in the SPA. When + // false the /api/v1/service-config routes are not registered at all. + ServiceConfig bool `json:"service_config"` + Accelerated bool `json:"accelerated"` + FileExplorer bool `json:"file_explorer"` } // FeaturesHandler — GET /api/v1/features. @@ -19,8 +22,9 @@ func (h *HandlersApi) FeaturesHandler(w http.ResponseWriter, r *http.Request) { utils.DebugHTTPDump(h.DebugHTTP, r, false) } utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, FeaturesResponse{ - Posture: h.PostureEnabled, - Accelerated: h.OsqueryValues.Accelerated, - FileExplorer: h.OsqueryValues.Query && h.OsqueryValues.Accelerated && h.OsqueryValues.FileExplorer, + Posture: h.PostureEnabled, + ServiceConfig: h.ServiceConfigEnabled, + Accelerated: h.OsqueryValues.Accelerated, + FileExplorer: h.OsqueryValues.Query && h.OsqueryValues.Accelerated && h.OsqueryValues.FileExplorer, }) } diff --git a/cmd/api/handlers/features_test.go b/cmd/api/handlers/features_test.go index b6e893d8..06db2bc8 100644 --- a/cmd/api/handlers/features_test.go +++ b/cmd/api/handlers/features_test.go @@ -31,6 +31,32 @@ func TestFeaturesHandlerReportsPostureDisabledByDefault(t *testing.T) { } } +func TestFeaturesHandlerReportsServiceConfigEnabled(t *testing.T) { + h := &HandlersApi{} + r := httptest.NewRequest(http.MethodGet, "/api/v1/features", nil) + w := httptest.NewRecorder() + h.FeaturesHandler(w, r) + + var off FeaturesResponse + if err := json.Unmarshal(w.Body.Bytes(), &off); err != nil { + t.Fatalf("decode: %v", err) + } + if off.ServiceConfig { + t.Fatalf("service config feature: got true want false") + } + + WithServiceConfigEnabled(true)(h) + w = httptest.NewRecorder() + h.FeaturesHandler(w, r) + var on FeaturesResponse + if err := json.Unmarshal(w.Body.Bytes(), &on); err != nil { + t.Fatalf("decode: %v", err) + } + if !on.ServiceConfig { + t.Fatalf("service config feature: got false want true") + } +} + func TestFeaturesHandlerReportsPostureEnabled(t *testing.T) { h := &HandlersApi{} WithPostureEnabled(true)(h) diff --git a/cmd/api/handlers/handlers.go b/cmd/api/handlers/handlers.go index 6b3e537e..55788020 100644 --- a/cmd/api/handlers/handlers.go +++ b/cmd/api/handlers/handlers.go @@ -35,31 +35,35 @@ type HandlersApi struct { // writes to the DB, this is the legacy GORM-backed reader. nil falls // back to h.DB via NewDBLogReader at call time so existing tests // that only wire WithDB keep working. - LogReader logging.LogReader - Users *users.UserManager - Tags *tags.TagManager - Envs *environments.EnvManager - EnvCache *environments.EnvCache - Nodes *nodes.NodeManager - Queries *queries.Queries - Console *console.Manager - FileExplorer *fileexplorer.Manager - Carves *carves.Carves - Settings *settings.Settings - ServiceConfig *serviceconfig.ServiceConfigManager - ServiceCommands *servicecommands.Manager - Activity activityReader - GeoIP *geoip.GeoIPResolver - Posture *posture.PostureManager - PostureEnabled bool - ServiceVersion string - ServiceName string - AuditLog *auditlog.AuditLogManager - ApiConfig *config.APIConfiguration - DebugHTTP *zerolog.Logger - DebugHTTPConfig *config.YAMLConfigurationDebug - OsqueryTables []types.OsqueryTable - OsqueryValues config.YAMLConfigurationOsquery + LogReader logging.LogReader + Users *users.UserManager + Tags *tags.TagManager + Envs *environments.EnvManager + EnvCache *environments.EnvCache + Nodes *nodes.NodeManager + Queries *queries.Queries + Console *console.Manager + FileExplorer *fileexplorer.Manager + Carves *carves.Carves + Settings *settings.Settings + ServiceConfig *serviceconfig.ServiceConfigManager + // ServiceConfigEnabled mirrors service.serviceConfigEnabled. When false + // the service-config routes are never registered and the SPA hides the + // section; the rows are still seeded and resolved at every boot. + ServiceConfigEnabled bool + ServiceCommands *servicecommands.Manager + Activity activityReader + GeoIP *geoip.GeoIPResolver + Posture *posture.PostureManager + PostureEnabled bool + ServiceVersion string + ServiceName string + AuditLog *auditlog.AuditLogManager + ApiConfig *config.APIConfiguration + DebugHTTP *zerolog.Logger + DebugHTTPConfig *config.YAMLConfigurationDebug + OsqueryTables []types.OsqueryTable + OsqueryValues config.YAMLConfigurationOsquery // JWTSecret is the HMAC key used by pkg/auth state-cookie // helpers. Populated via WithJWTSecret at handler init. Same // bytes the Users manager signs user JWTs with; the auth @@ -208,6 +212,12 @@ func WithPostureEnabled(enabled bool) HandlersOption { } } +func WithServiceConfigEnabled(enabled bool) HandlersOption { + return func(h *HandlersApi) { + h.ServiceConfigEnabled = enabled + } +} + func WithVersion(version string) HandlersOption { return func(h *HandlersApi) { h.ServiceVersion = version diff --git a/cmd/api/main.go b/cmd/api/main.go index bfb55340..5529da23 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -401,6 +401,9 @@ func osctrlAPIService() { if err := serviceConfigMgr.ReportFile(config.ServiceAPI, flagParams.ConfigFilePath()); err != nil { log.Err(err).Msg("Error reporting service config file status") } + if !flagParams.Service.ServiceConfigEnabled { + log.Info().Msg("Service config API is disabled (enable with --service-config-enabled) — sections are still seeded and resolved, change the service_config rows or the YAML file directly") + } if flagParams.RateLimits == nil { flagParams.RateLimits = config.DefaultRateLimitsPtr() } @@ -483,6 +486,7 @@ func osctrlAPIService() { handlers.WithCarves(filecarves), handlers.WithSettings(settingsmgr), handlers.WithServiceConfig(serviceConfigMgr), + handlers.WithServiceConfigEnabled(flagParams.Service.ServiceConfigEnabled), handlers.WithServiceCommands(serviceCommandMgr), handlers.WithConfigPersist(persistConfig), handlers.WithActivityReader(activity.NewRedisStore(redis.Client, activity.DefaultPrefix, activity.DefaultRetentionDays, 8*24*time.Hour)), @@ -934,44 +938,50 @@ func osctrlAPIService() { muxAPI.Handle( "PATCH "+_apiPath(apiSettingsPath)+"/{service}/{name}", handlerAuthCheck(http.HandlerFunc(handlersApi.SettingPatchHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - // API: service config (phase 1 read + phase 2 editable PUT + apply) - // Rate-limit the restart endpoint to 3 per 10 minutes per IP by default - // — strict enough to prevent brute-forcing restarts, generous enough - // for an operator to retry after a failed restart. Rejections are - // audit-logged so SoC tooling sees attempted abuse. - restartLimiter := ratelimit.NewFromConfig(flagParams.RateLimits.ServiceConfigApply) - restartRateLimit := restartLimiter.HTTPMiddleware(ratelimit.KeyByIP, func(r *http.Request, key string) { - handlersApi.AuditLog.SettingsAction("", fmt.Sprintf("service-config apply rate limit exceeded from %s", key), utils.GetIP(r)) - }) - muxAPI.Handle( - "GET "+_apiPath(apiServiceConfigPath), - handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - muxAPI.Handle( - "GET "+_apiPath(apiServiceConfigPath)+"/commands/{command_id}", - handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceCommandHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - muxAPI.Handle( - "GET "+_apiPath(apiServiceConfigPath)+"/{service}", - handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigServiceHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - // Literal-prefixed like /commands/{command_id}: "{service}/status" would - // conflict with it — neither pattern is more specific than the other, and - // ServeMux panics at registration. - muxAPI.Handle( - "GET "+_apiPath(apiServiceConfigPath)+"/status/{service}", - handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigStatusHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - muxAPI.Handle( - "GET "+_apiPath(apiServiceConfigPath)+"/{service}/{section}", - handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigSectionHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - muxAPI.Handle( - "PUT "+_apiPath(apiServiceConfigPath)+"/{service}/{section}", - handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigUpdateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) - muxAPI.Handle( - "POST "+_apiPath(apiServiceConfigPath)+"/apply", - restartRateLimit(handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigApplyHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))) - // Persist writes a file rather than restarting anything, but it is the - // same class of privileged, disk-touching operation — same limiter. - muxAPI.Handle( - "POST "+_apiPath(apiServiceConfigPath)+"/persist", - restartRateLimit(handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigPersistHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))) + // API: service config. The whole surface is opt-in: with + // --service-config-enabled off, none of these routes exist. Seeding + // YAML into the service_config rows and resolving them at startup + // happen either way, so the rows stay the values the services run on + // and can be changed directly in the database. + if flagParams.Service.ServiceConfigEnabled { + muxAPI.Handle( + "GET "+_apiPath(apiServiceConfigPath), + handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "GET "+_apiPath(apiServiceConfigPath)+"/commands/{command_id}", + handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceCommandHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "GET "+_apiPath(apiServiceConfigPath)+"/{service}", + handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigServiceHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // Literal-prefixed like /commands/{command_id}: "{service}/status" would + // conflict with it — neither pattern is more specific than the other, and + // ServeMux panics at registration. + muxAPI.Handle( + "GET "+_apiPath(apiServiceConfigPath)+"/status/{service}", + handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigStatusHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "GET "+_apiPath(apiServiceConfigPath)+"/{service}/{section}", + handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigSectionHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + // Rate-limit the restart endpoint to 3 per 10 minutes per IP by default + // — strict enough to prevent brute-forcing restarts, generous enough + // for an operator to retry after a failed restart. Rejections are + // audit-logged so SoC tooling sees attempted abuse. + restartLimiter := ratelimit.NewFromConfig(flagParams.RateLimits.ServiceConfigApply) + restartRateLimit := restartLimiter.HTTPMiddleware(ratelimit.KeyByIP, func(r *http.Request, key string) { + handlersApi.AuditLog.SettingsAction("", fmt.Sprintf("service-config apply rate limit exceeded from %s", key), utils.GetIP(r)) + }) + muxAPI.Handle( + "PUT "+_apiPath(apiServiceConfigPath)+"/{service}/{section}", + handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigUpdateHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiServiceConfigPath)+"/apply", + restartRateLimit(handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigApplyHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))) + // Persist writes a file rather than restarting anything, but it is the + // same class of privileged, disk-touching operation — same limiter. + muxAPI.Handle( + "POST "+_apiPath(apiServiceConfigPath)+"/persist", + restartRateLimit(handlerAuthCheck(http.HandlerFunc(handlersApi.ServiceConfigPersistHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret))) + } // API: audit log if flagParams.Service.AuditLog { muxAPI.Handle( diff --git a/deploy/config/api.yml b/deploy/config/api.yml index c1cebd21..78a9bd6e 100644 --- a/deploy/config/api.yml +++ b/deploy/config/api.yml @@ -18,6 +18,15 @@ service: # in the environment and is intended for local-dev only — it impersonates # super-admin on every request. Production deployments MUST use `jwt`. auth: jwt + # Serve /api/v1/service-config and show the Service Config section in + # the SPA, where sections can be read, edited, written back to this file + # and applied with a restart. This switch does not change how config is + # loaded: every boot seeds the sections of this file into the + # service_config table and then resolves those rows back over them, so + # the service always runs on the stored values. When false (default) + # none of the routes are registered and the SPA hides the section — + # change the rows directly in the database, or this file, and restart. + serviceConfigEnabled: false # Write security-relevant API actions to audit_logs. auditLog: true # Comma-separated CIDR list whose X-Real-IP / X-Forwarded-For headers diff --git a/deploy/config/tls.yml b/deploy/config/tls.yml index 1f0caa38..a5186955 100644 --- a/deploy/config/tls.yml +++ b/deploy/config/tls.yml @@ -16,6 +16,12 @@ service: host: osctrl.net # Valid value: "none". osquery authentication uses enroll secrets and node_key. auth: none + # Only used by osctrl-api, which serves the service-config endpoints; + # kept here so the service configuration shape is complete and can + # round-trip through the API. osctrl-tls always seeds the sections of + # this file into the service_config table and resolves those rows back + # at startup, so it runs on the stored values either way. + serviceConfigEnabled: false # Write security-relevant TLS activity, such as enroll failures, to audit_logs. auditLog: true # Comma-separated CIDR list whose X-Real-IP / X-Forwarded-For headers diff --git a/docker-compose-dev.yml b/docker-compose-dev.yml index 35bfc32d..f6482c9c 100644 --- a/docker-compose-dev.yml +++ b/docker-compose-dev.yml @@ -49,6 +49,7 @@ services: - LOGGER_DB_SAME=true - SERVICE_LOG_FORMAT=console - SERVICE_POSTURE_ENABLED=true + - SERVICE_CONFIG_ENABLED=true #### Database settings #### - DB_HOST=osctrl-postgres - DB_NAME=${POSTGRES_DB_NAME} @@ -103,6 +104,7 @@ services: - SERVICE_LOGGER=db - SERVICE_LOG_FORMAT=console - SERVICE_POSTURE_ENABLED=true + - SERVICE_CONFIG_ENABLED=true #### osquery settings #### - OSQUERY_TABLES=/usr/src/app/deploy/osquery/data/${OSQUERY_VERSION}.json - OSQUERY_ACCELERATED=true diff --git a/frontend/src/api/features.ts b/frontend/src/api/features.ts index 7784e5d4..9dcced70 100644 --- a/frontend/src/api/features.ts +++ b/frontend/src/api/features.ts @@ -2,6 +2,7 @@ import { apiFetch } from './client'; export interface Features { posture: boolean; + service_config: boolean; accelerated: boolean; file_explorer: boolean; } diff --git a/frontend/src/components/chrome/SideNav.tsx b/frontend/src/components/chrome/SideNav.tsx index 2a0f944c..f2fb75a0 100644 --- a/frontend/src/components/chrome/SideNav.tsx +++ b/frontend/src/components/chrome/SideNav.tsx @@ -5,6 +5,7 @@ import { Logo } from '$/components/atoms/Logo'; import { EnvSwitcher } from './EnvSwitcher'; import { listEnvironments } from '$/api/environments'; import { getMe } from '$/api/users'; +import { getFeatures } from '$/api/features'; import type { EnvAccess } from '$/api/types'; interface NavItemProps { @@ -110,6 +111,14 @@ export function SideNav({ className, collapsed, onToggleCollapse }: SideNavProps retry: 1, }); const isSuperAdmin = me?.admin === true; + // Service Config is opt-in server-side (service.serviceConfigEnabled). + // When it is off the endpoints do not exist, so hide the entry rather + // than link to a page that can only fail. + const { data: features } = useQuery({ + queryKey: ['features'], + queryFn: () => getFeatures(), + staleTime: 5 * 60_000, + }); // currentEnv is the SPA's name-of-env; permissions are keyed by // env UUID. We need to translate name → UUID via the envs list. // Fall back to "no access" when the lookup hasn't resolved yet. @@ -422,7 +431,7 @@ export function SideNav({ className, collapsed, onToggleCollapse }: SideNavProps > Settings - Service Config - + } ) : ( diff --git a/frontend/src/features/environments/EnvConfigPage.test.tsx b/frontend/src/features/environments/EnvConfigPage.test.tsx index 8ba2274c..e5d020d1 100644 --- a/frontend/src/features/environments/EnvConfigPage.test.tsx +++ b/frontend/src/features/environments/EnvConfigPage.test.tsx @@ -180,7 +180,7 @@ describe('EnvConfigPage', () => { data: '{"options":{"logger_plugin":"tls"}}', }); mockGetPostureProfiles.mockResolvedValue([]); - mockGetFeatures.mockResolvedValue({ posture: false, accelerated: false, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, file_explorer: false }); }); it('loads the fully rendered tab from the assembled config endpoint', async () => { @@ -224,7 +224,7 @@ describe('EnvConfigPage', () => { it('loads posture profiles only after opening the picker', async () => { const user = userEvent.setup(); - mockGetFeatures.mockResolvedValue({ posture: true, accelerated: false, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false }); renderWithProviders(); @@ -243,7 +243,7 @@ describe('EnvConfigPage', () => { it('distinguishes a profile load failure from an empty profile list', async () => { const user = userEvent.setup(); - mockGetFeatures.mockResolvedValue({ posture: true, accelerated: false, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false }); mockGetPostureProfiles.mockRejectedValue(new Error('profiles unavailable')); renderWithProviders(); diff --git a/frontend/src/features/nodes/NodeDetailPage.test.tsx b/frontend/src/features/nodes/NodeDetailPage.test.tsx index a21ec25a..1d29f12d 100644 --- a/frontend/src/features/nodes/NodeDetailPage.test.tsx +++ b/frontend/src/features/nodes/NodeDetailPage.test.tsx @@ -297,7 +297,7 @@ describe('NodeDetailPage', () => { total: [], }); mockListServiceSettings.mockResolvedValue([]); - mockGetFeatures.mockResolvedValue({ posture: false, accelerated: false, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, file_explorer: false }); mockGetNodePostureScore.mockResolvedValue({ node_uuid: 'abc12345-0000-0000-0000-000000000001', timestamp: '2026-07-16T09:05:00Z', @@ -381,7 +381,7 @@ describe('NodeDetailPage', () => { it('shows posture data for the selected node', async () => { const user = userEvent.setup(); - mockGetFeatures.mockResolvedValue({ posture: true, accelerated: false, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false }); mockGetNodePosture.mockResolvedValue([ { id: 10, @@ -425,7 +425,7 @@ describe('NodeDetailPage', () => { it('renders posture score when controls is null', async () => { const user = userEvent.setup(); - mockGetFeatures.mockResolvedValue({ posture: true, accelerated: false, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false }); mockGetNodePosture.mockResolvedValue([ { id: 10, @@ -513,7 +513,7 @@ describe('NodeDetailPage', () => { }); it('shows posture uptime in lifecycle details only when posture is enabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: true, accelerated: false, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false }); mockGetNode.mockResolvedValue( makeNode({ uptime: { @@ -597,7 +597,7 @@ describe('NodeDetailPage', () => { }); it('hides node uptime while posture is disabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: false, accelerated: false, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, file_explorer: false }); mockGetNode.mockResolvedValue( makeNode({ uptime: { @@ -622,7 +622,7 @@ describe('NodeDetailPage', () => { }); it('shows the console action only when accelerated queries are enabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: false, accelerated: true, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: true, file_explorer: false }); const router = makeTestRouter(); renderWithProviders(router); @@ -635,7 +635,7 @@ describe('NodeDetailPage', () => { }); it('hides the console action when accelerated queries are disabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: false, accelerated: false, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, file_explorer: false }); renderWithProviders(makeTestRouter()); @@ -647,7 +647,7 @@ describe('NodeDetailPage', () => { }); it('hides the console action from non-admin users even when accelerated queries are enabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: false, accelerated: true, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: true, file_explorer: false }); mockGetMe.mockResolvedValue({ admin: false, permissions: { @@ -665,7 +665,7 @@ describe('NodeDetailPage', () => { }); it('shows the file explorer tab when file explorer is enabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: false, accelerated: true, file_explorer: true }); + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: true, file_explorer: true }); renderWithProviders(makeTestRouter()); @@ -678,7 +678,7 @@ describe('NodeDetailPage', () => { it('lets the file explorer details panel stick to the page scroll container', async () => { const user = userEvent.setup(); - mockGetFeatures.mockResolvedValue({ posture: false, accelerated: true, file_explorer: true }); + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: true, file_explorer: true }); renderWithProviders(makeTestRouter()); @@ -694,7 +694,7 @@ describe('NodeDetailPage', () => { }); it('hides the file explorer tab when file explorer is disabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: false, accelerated: true, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: true, file_explorer: false }); renderWithProviders(makeTestRouter()); @@ -706,7 +706,7 @@ describe('NodeDetailPage', () => { }); it('wraps single-node actions away from the hostname block', async () => { - mockGetFeatures.mockResolvedValue({ posture: false, accelerated: true, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: true, file_explorer: false }); renderWithProviders(makeTestRouter()); diff --git a/frontend/src/features/nodes/NodesTablePage.test.tsx b/frontend/src/features/nodes/NodesTablePage.test.tsx index d0194ef3..582ccf2e 100644 --- a/frontend/src/features/nodes/NodesTablePage.test.tsx +++ b/frontend/src/features/nodes/NodesTablePage.test.tsx @@ -226,7 +226,7 @@ describe('NodesTablePage', () => { beforeEach(() => { vi.clearAllMocks(); mockListServiceSettings.mockResolvedValue([]); - mockGetFeatures.mockResolvedValue({ posture: false, accelerated: false, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, file_explorer: false }); mockGetStats.mockResolvedValue(makeStatsResponse()); mockGetNodeActivityTilesBatch.mockResolvedValue({ 'ABC12345-0000-0000-0000-000000000001': makeTileSeries(), @@ -391,7 +391,7 @@ describe('NodesTablePage', () => { }); it('shows uptime and posture risk badge when posture is enabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: true, accelerated: false, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: true, service_config: false, accelerated: false, file_explorer: false }); mockListNodes.mockResolvedValue( makeResponse({ items: [ @@ -502,7 +502,7 @@ describe('NodesTablePage', () => { }); it('hides posture quick signals when posture is disabled', async () => { - mockGetFeatures.mockResolvedValue({ posture: false, accelerated: false, file_explorer: false }); + mockGetFeatures.mockResolvedValue({ posture: false, service_config: false, accelerated: false, file_explorer: false }); mockListNodes.mockResolvedValue( makeResponse({ items: [ diff --git a/frontend/src/features/service-config/ServiceConfigPage.test.tsx b/frontend/src/features/service-config/ServiceConfigPage.test.tsx index d68cf416..8fd4ec00 100644 --- a/frontend/src/features/service-config/ServiceConfigPage.test.tsx +++ b/frontend/src/features/service-config/ServiceConfigPage.test.tsx @@ -29,6 +29,12 @@ vi.mock('$/api/service-config', () => ({ persistServiceConfig: (service: string) => mockPersist(service), })); +const mockGetFeatures = vi.fn(); + +vi.mock('$/api/features', () => ({ + getFeatures: () => mockGetFeatures(), +})); + vi.mock('$/api/client', () => ({ isAuthenticated: () => true, AuthError: class AuthError extends Error { @@ -103,6 +109,12 @@ describe('ServiceConfigPage', () => { // Pre-acknowledge the impact warning so it does not overlay the rest of // the suite; the warning has its own test below. window.sessionStorage.setItem('osctrl.service-config.warning-ack', '1'); + mockGetFeatures.mockResolvedValue({ + posture: false, + service_config: true, + accelerated: false, + file_explorer: false, + }); mockStatus.mockResolvedValue({ service: 'api', pending_changes: true, @@ -131,6 +143,24 @@ describe('ServiceConfigPage', () => { expect(screen.queryByRole('button', { name: 'I understand' })).not.toBeInTheDocument(); }); + it('asks the API for nothing when the service-config endpoints are disabled', async () => { + mockGetFeatures.mockResolvedValue({ + posture: false, + service_config: false, + accelerated: false, + file_explorer: false, + }); + mockList.mockResolvedValue([makeSection()]); + renderWithProviders(makeTestRouter()); + + await waitFor(() => { + expect(screen.getByText('Service configuration is not available.')).toBeInTheDocument(); + }); + expect(screen.getByText(/serviceConfigEnabled/)).toBeInTheDocument(); + expect(mockList).not.toHaveBeenCalled(); + expect(mockStatus).not.toHaveBeenCalled(); + }); + it('renders service config sections with their names and badges', async () => { mockList.mockResolvedValue([ makeSection(), @@ -164,7 +194,8 @@ describe('ServiceConfigPage', () => { expect(screen.getByRole('tab', { name: 'osctrl-tls' })).toBeInTheDocument(); }); expect(screen.getByRole('tab', { name: 'osctrl-api' })).toBeInTheDocument(); - expect(mockList).toHaveBeenCalledWith('tls'); + // Sections are only requested once /features confirms the switch is on. + await waitFor(() => expect(mockList).toHaveBeenCalledWith('tls')); }); it('shows empty state when no sections are returned', async () => { diff --git a/frontend/src/features/service-config/ServiceConfigPage.tsx b/frontend/src/features/service-config/ServiceConfigPage.tsx index 20443978..d85702e7 100644 --- a/frontend/src/features/service-config/ServiceConfigPage.tsx +++ b/frontend/src/features/service-config/ServiceConfigPage.tsx @@ -12,6 +12,7 @@ import { persistServiceConfig, type ServiceConfig, } from '$/api/service-config'; +import { getFeatures } from '$/api/features'; import { AuthError, ApiError } from '$/api/client'; import { cn } from '$/lib/cn'; import { Skeleton } from '$/components/data/Skeleton'; @@ -59,6 +60,8 @@ const FIELD_HELP: Record = { 'tls:service.TrustedProxies': 'Comma-separated CIDR list whose X-Real-IP / X-Forwarded-For headers utils.GetIP will trust. osctrl-tls is typically internet-facing for osquery node enrollment; keep empty unless you operate it behind a trusted reverse proxy that forwards client IPs. Empty (default) prevents header-spoofed enroll-rate-limit bypass and audit-log poisoning.', 'api:service.GeoIPDBPath': 'Path to a MaxMind GeoLite2-Country .mmdb file. When set, node IP addresses are resolved to ISO 3166-1 alpha-2 country codes and included in the node API response (shown as flag emojis in the SPA nodes table and node detail page). Empty (default) disables GeoIP entirely — no lookups, no country codes, no overhead. Download the free database from https://dev.maxmind.com/geoip/geolite2-free-geolocation-data — update weekly for best accuracy. Example: /data/GeoLite2-Country.mmdb', 'tls:service.GeoIPDBPath': 'Path to a MaxMind GeoLite2-Country .mmdb file. Currently consumed by osctrl-api node responses; kept here so the shared service section is complete across sample configs. Empty disables GeoIP.', + 'api:service.ServiceConfigEnabled': 'Master switch for this page and the /api/v1/service-config endpoints. It does not change how configuration loads: every boot seeds the YAML sections into the service_config table and resolves those rows back, so the services always run on the stored values. Turning it off and restarting removes the endpoints and hides this section — values can then only be changed in the database rows or the YAML file, and are picked up on the next restart.', + 'tls:service.ServiceConfigEnabled': 'Only osctrl-api serves the service-config endpoints, so this value is inert for osctrl-tls — it is kept so the service section round-trips unchanged. osctrl-tls seeds its YAML sections into the service_config table and resolves those rows at startup either way.', 'api:service.PostureEnabled': 'Enable the security & compliance posture system. When false (default), posture API endpoints are not registered and the SPA hides posture controls. When true, the API serves posture data from the shared database (collected by osctrl-tls).', 'tls:service.PostureEnabled': 'Enable the security & compliance posture system. When false (default), no posture data is collected, no posture API endpoints are available, and the posture tab is hidden in the SPA. When true, result logs from posture-prefixed scheduled queries are ingested and stored per node.', 'api:service.PostureQueryPrefix': 'Only used by osctrl-tls for ingestion; kept here so the service configuration shape is complete and can round-trip through the API.', @@ -462,6 +465,15 @@ export function ServiceConfigPage() { // survive reloads, if operators hit this. const [persistedThisSession, setPersistedThisSession] = useState(false); const qc = useQueryClient(); + // Server-side switch (service.serviceConfigEnabled). When it is off the + // service-config endpoints are not registered at all, so do not even ask + // for the sections — explain where the values live instead. + const { data: features } = useQuery({ + queryKey: ['features'], + queryFn: () => getFeatures(), + staleTime: 5 * 60_000, + }); + const configDisabled = features?.service_config === false; const { data, isLoading, @@ -473,6 +485,7 @@ export function ServiceConfigPage() { queryKey: ['service-config', service], queryFn: () => listServiceConfig(service), staleTime: 30_000, + enabled: !!features?.service_config, }); // Whether the service's own process can write its config file. Only that @@ -481,6 +494,7 @@ export function ServiceConfigPage() { queryKey: ['service-config-status', service], queryFn: () => getServiceConfigStatus(service), staleTime: 30_000, + enabled: !!features?.service_config, }); if (isError && error instanceof AuthError) { @@ -726,7 +740,19 @@ export function ServiceConfigPage() {
- {loading && ( + {configDisabled && ( + + + + } + title="Service configuration is not available." + description="osctrl-api runs with service.serviceConfigEnabled = false, so the /api/v1/service-config endpoints are not registered. Each service still seeds its YAML sections into the service_config table and resolves those rows at startup, so values can be changed directly in the database and are picked up on the next restart. Set serviceConfigEnabled: true (or --service-config-enabled / SERVICE_CONFIG_ENABLED=true) and restart osctrl-api to manage them here." + /> + )} + + {!configDisabled && loading && (
{Array.from({ length: 6 }).map((_, i) => ( @@ -734,7 +760,7 @@ export function ServiceConfigPage() {
)} - {hasError && !loading && ( + {!configDisabled && hasError && !loading && ( @@ -755,7 +781,7 @@ export function ServiceConfigPage() { /> )} - {!loading && !hasError && sections.length === 0 && ( + {!configDisabled && !loading && !hasError && sections.length === 0 && ( @@ -766,7 +792,7 @@ export function ServiceConfigPage() { /> )} - {!loading && !hasError && sections.length > 0 && ( + {!configDisabled && !loading && !hasError && sections.length > 0 && (
{sections.map((s) => ( - {showWarning && ( + {showWarning && !configDisabled && ( { try { diff --git a/pkg/config/flags.go b/pkg/config/flags.go index a3562286..32e27183 100644 --- a/pkg/config/flags.go +++ b/pkg/config/flags.go @@ -276,6 +276,13 @@ func initServiceFlags(params *ServiceParameters) []cli.Flag { Sources: cli.EnvVars("SERVICE_POSTURE_ENABLED"), Destination: ¶ms.Service.PostureEnabled, }, + &cli.BoolFlag{ + Name: "service-config-enabled", + Value: false, + Usage: "Serve the service-config API and show the matching section in the SPA. Disabled by default: the YAML sections are still seeded into the database at every boot and resolved back at startup, but none of the /api/v1/service-config routes are registered — change the rows or the YAML file directly instead.", + Sources: cli.EnvVars("SERVICE_CONFIG_ENABLED"), + Destination: ¶ms.Service.ServiceConfigEnabled, + }, &cli.StringFlag{ Name: "posture-query-prefix", Value: "osctrl:posture:", diff --git a/pkg/config/flags_test.go b/pkg/config/flags_test.go index d6885f2f..6cea8692 100644 --- a/pkg/config/flags_test.go +++ b/pkg/config/flags_test.go @@ -33,6 +33,32 @@ func TestServicePostureEnabledFlagDefaultsOff(t *testing.T) { } } +func TestServiceConfigEnabledFlagDefaultsOff(t *testing.T) { + params := &ServiceParameters{Service: &YAMLConfigurationService{}} + flags := initServiceFlags(params) + + if params.Service.ServiceConfigEnabled { + t.Fatalf("service config enabled default: got true want false") + } + + var found *cli.BoolFlag + for _, flag := range flags { + if f, ok := flag.(*cli.BoolFlag); ok && f.Name == "service-config-enabled" { + found = f + break + } + } + if found == nil { + t.Fatalf("missing service-config-enabled service flag") + } + if found.Value { + t.Fatalf("service-config-enabled flag default: got true want false") + } + if found.Destination != ¶ms.Service.ServiceConfigEnabled { + t.Fatalf("service-config-enabled flag destination does not wire Service.ServiceConfigEnabled") + } +} + func TestOsqueryAcceleratedFlagDefaultsOff(t *testing.T) { params := &ServiceParameters{Osquery: &YAMLConfigurationOsquery{}} flags := initOsqueryFlags(params) diff --git a/pkg/config/types.go b/pkg/config/types.go index e87b627a..cc44085e 100644 --- a/pkg/config/types.go +++ b/pkg/config/types.go @@ -124,8 +124,18 @@ type YAMLConfigurationService struct { // whose result logs are ingested as node posture data. Only used // when PostureEnabled is true. PostureQueryPrefix string `yaml:"postureQueryPrefix"` - Auth string `yaml:"auth"` - AuditLog bool `yaml:"auditLog"` + // ServiceConfigEnabled controls whether the service-config API and the + // matching SPA section exist. It does not change how configuration is + // loaded: every boot seeds the YAML sections into the database and + // then resolves the stored values back over them, so the services + // always read from the database rows. When false (default) none of the + // /api/v1/service-config routes are registered and the SPA hides the + // section — the rows can still be changed directly in the database, or + // in the YAML file, and are picked up on the next restart. Consumed by + // osctrl-api; osctrl-tls ignores it. + ServiceConfigEnabled bool `yaml:"serviceConfigEnabled"` + Auth string `yaml:"auth"` + AuditLog bool `yaml:"auditLog"` // TrustedProxies is a comma-separated list of CIDRs whose // X-Real-IP / X-Forwarded-For headers utils.GetIP will honor. // Default empty → forwarding headers are ignored and the