Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 6 additions & 1 deletion cmd/api/handlers/events.go
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ type ResourceChanged struct {
EnvironmentUUID string `json:"environment_uuid"`
Topic string `json:"topic"`
Name string `json:"name"`
Change string `json:"change,omitempty"`
}

// EventsHandler streams authorized query/carve invalidations.
Expand Down Expand Up @@ -162,7 +163,11 @@ func (h *HandlersApi) EventsHandler(w http.ResponseWriter, r *http.Request) {
return
}
for hint := range pending {
if err := write("resource.changed", ResourceChanged{1, env.UUID, hint.Topic, hint.Name}); err != nil {
change := hint.Change
if change == "" {
change = events.ChangeMetadata
}
if err := write("resource.changed", ResourceChanged{SchemaVersion: 1, EnvironmentUUID: env.UUID, Topic: hint.Topic, Name: hint.Name, Change: change}); err != nil {
return
}
delete(pending, hint)
Expand Down
6 changes: 4 additions & 2 deletions cmd/api/handlers/events_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -98,8 +98,10 @@ func TestEventStreamRevocationAndCleanup(t *testing.T) {
}
}
require.Contains(t, frame(), "stream.ready")
source.ch <- events.Hint{EnvironmentID: env.ID, Topic: events.Queries, Name: "query-1"}
require.Contains(t, frame(), "query-1")
source.ch <- events.Hint{EnvironmentID: env.ID, Topic: events.Queries, Name: "query-1", Change: events.ChangeResults}
queryFrame := frame()
require.Contains(t, queryFrame, "query-1")
require.Contains(t, queryFrame, `"change":"results"`)
if revokeToken {
valid.Store(false)
} else {
Expand Down
25 changes: 25 additions & 0 deletions cmd/tls/handlers/handlers.go
Original file line number Diff line number Diff line change
Expand Up @@ -191,6 +191,31 @@ func WithPosture(pm *posture.PostureManager) Option {
}
}

// osqueryVersion returns the osquery version this deployment wants nodes to
// run: the configured one when set, otherwise the version osctrl was built
// against. The --osquery-version flag ("Version of osquery to be used") had no
// reader at all, so pinning a version in the config changed nothing and nodes
// were told the compile-time value regardless.
func (h *HandlersTLS) osqueryVersion() string {
if h.OsqueryValues != nil {
if v := strings.TrimSpace(h.OsqueryValues.Version); v != "" {
return v
}
}
return defOsqueryVersion
}

// osquerySHA256 returns the configured digest of the osquery package, or an
// empty string when none is set. Empty is deliberate and safe: osctrld then
// falls back to its own --osquery-sha256 and, failing that, refuses to install
// an unverified package.
func (h *HandlersTLS) osquerySHA256() string {
if h.OsqueryValues == nil {
return ""
}
return strings.TrimSpace(h.OsqueryValues.SHA256)
}

// WithOsqueryValues to pass osquery configuration values
func WithOsqueryValues(values *config.YAMLConfigurationOsquery) Option {
return func(h *HandlersTLS) {
Expand Down
5 changes: 3 additions & 2 deletions cmd/tls/handlers/post.go
Original file line number Diff line number Diff line change
Expand Up @@ -1050,7 +1050,8 @@ func (h *HandlersTLS) VerifyHandler(w http.ResponseWriter, r *http.Request) {
response = types.VerifyResponse{
Certificate: env.Certificate,
Flags: flagsStr,
OsqueryVersion: defOsqueryVersion,
OsqueryVersion: h.osqueryVersion(),
OsquerySHA256: h.osquerySHA256(),
}
} else {
utils.HTTPResponse(w, "", http.StatusForbidden, []byte(""))
Expand Down Expand Up @@ -1257,7 +1258,7 @@ func (h *HandlersTLS) EnrollPackageHandler(w http.ResponseWriter, r *http.Reques
return
}
fDesc = "Enrolling MSI Package for Windows"
fName = genPackageFilename(env.Name, settings.PackageMsi, defOsqueryVersion, version.OsctrlVersion)
fName = genPackageFilename(env.Name, settings.PackageMsi, version.OsqueryVersion, version.OsctrlVersion)
fPath, err = environments.PackageFilePath(enrollPackagesPath, env.Name, env.MsiPackage)
}
if err != nil {
Expand Down
87 changes: 87 additions & 0 deletions cmd/tls/handlers/verify_sha256_test.go
Original file line number Diff line number Diff line change
@@ -0,0 +1,87 @@
package handlers

import (
"encoding/json"
"testing"

"github.com/jmpsec/osctrl/pkg/config"
"github.com/jmpsec/osctrl/pkg/types"
"github.com/jmpsec/osctrl/pkg/version"
"github.com/stretchr/testify/require"
)

// osctrld refuses to install an osquery package it cannot verify, so what this
// field carries — and when it is deliberately empty — decides whether a fleet
// verifies its downloads or falls back to a per-node digest.

func TestOsquerySHA256FromConfig(t *testing.T) {
digest := "9f86d081884c7d659a2feaa0c55ad015a3bf4f1b2b0b822cd15d6c15b0f00a08"
h := &HandlersTLS{OsqueryValues: &config.YAMLConfigurationOsquery{SHA256: digest}}
require.Equal(t, digest, h.osquerySHA256())
}

func TestOsquerySHA256IsTrimmed(t *testing.T) {
// A digest pasted into YAML picks up whitespace easily, and osctrld
// compares it against a hex string with no room for either side to trim.
h := &HandlersTLS{OsqueryValues: &config.YAMLConfigurationOsquery{SHA256: " abc123\n"}}
require.Equal(t, "abc123", h.osquerySHA256())
}

func TestOsquerySHA256EmptyWhenUnset(t *testing.T) {
// Empty is the safe answer, not a failure: osctrld then uses its own
// --osquery-sha256, or refuses to install. Never invent a digest.
require.Empty(t, (&HandlersTLS{OsqueryValues: &config.YAMLConfigurationOsquery{}}).osquerySHA256())
require.Empty(t, (&HandlersTLS{}).osquerySHA256(), "a nil osquery config must not panic")
}

// The osquery version in the verify response is what osctrld installs and
// upgrades to, so a configured pin that the handler ignores means the whole
// fleet quietly tracks whatever osctrl was compiled against.

func TestOsqueryVersionFromConfig(t *testing.T) {
h := &HandlersTLS{OsqueryValues: &config.YAMLConfigurationOsquery{Version: "5.19.0"}}
require.Equal(t, "5.19.0", h.osqueryVersion())
require.NotEqual(t, version.OsqueryVersion, h.osqueryVersion(),
"a configured pin must win over the build-time version")
}

func TestOsqueryVersionIsTrimmed(t *testing.T) {
h := &HandlersTLS{OsqueryValues: &config.YAMLConfigurationOsquery{Version: " 5.19.0\n"}}
require.Equal(t, "5.19.0", h.osqueryVersion())
}

func TestOsqueryVersionFallsBackToBuild(t *testing.T) {
// Unset and nil both mean "whatever this osctrl ships with" — never empty,
// which osctrld would compare against an installed version and misjudge.
require.Equal(t, version.OsqueryVersion,
(&HandlersTLS{OsqueryValues: &config.YAMLConfigurationOsquery{}}).osqueryVersion())
require.Equal(t, version.OsqueryVersion, (&HandlersTLS{}).osqueryVersion())
}

// TestVerifyResponseWireFormat pins the JSON contract osctrld parses. The field
// name is matched verbatim by osctrld's own VerifyResponse struct; renaming it
// here silently disables verification on every node.
func TestVerifyResponseWireFormat(t *testing.T) {
raw, err := json.Marshal(types.VerifyResponse{
Flags: "--flag",
Certificate: "cert",
OsqueryVersion: "5.23.1",
OsquerySHA256: "abc123",
})
require.NoError(t, err)

var wire map[string]any
require.NoError(t, json.Unmarshal(raw, &wire))
require.Equal(t, "abc123", wire["osquery_sha256"], "osctrld reads osquery_sha256")
require.Contains(t, wire, "flags")
require.Contains(t, wire, "certificate")
require.Contains(t, wire, "osquery_version")

// An unset digest still ships the key; osctrld treats "" as "no digest
// from the server" and falls back. Dropping the key entirely would be
// equivalent, but pinning the shape keeps the contract explicit.
raw, err = json.Marshal(types.VerifyResponse{OsqueryVersion: "5.23.1"})
require.NoError(t, err)
require.NoError(t, json.Unmarshal(raw, &wire))
require.Equal(t, "", wire["osquery_sha256"])
}
6 changes: 6 additions & 0 deletions deploy/config/tls.yml
Original file line number Diff line number Diff line change
Expand Up @@ -185,6 +185,12 @@ rateLimits:
osquery:
# Empty-query dispatch cache lifetime; <=0 uses 2m. New queries invalidate it.
queryDispatchTTL: 2m
# Expected SHA-256 of the osquery package nodes install. osctrld checks what
# it downloaded against this. Only set it when every node installs the same
# package: osquery ships a different artifact per format and architecture,
# and the verify request does not say which one a node will fetch. On a mixed
# fleet leave it empty and set --osquery-sha256 on the node instead.
sha256: ""
# osquery schema version used for table metadata.
version: 5.23.1
# JSON schema file with osquery table metadata.
Expand Down
25 changes: 18 additions & 7 deletions frontend/src/features/carves/CarveDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,12 +1,12 @@
import { useResourceUpdates } from '$/lib/live-updates';
import { resourceRefetchInterval, useResourceUpdates } from '$/lib/live-updates';
import { useParams, useNavigate, Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { usePageTitle } from '$/lib/usePageTitle';
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query';
import { getCarve, getCarveArchiveUrl, actOnCarve } from '$/api/carves';
import { listNodes } from '$/api/nodes';
import { AuthError } from '$/api/client';
import type { CarveFile } from '$/api/types';
import type { CarveDetail, CarveFile } from '$/api/types';
import { formatRelative } from '$/lib/time';
import { cn } from '$/lib/cn';
import { EmptyState } from '$/components/data/EmptyState';
Expand Down Expand Up @@ -66,8 +66,20 @@ function hasRealTimestamp(value?: string): boolean {
return typeof value === 'string' && value.length > 0 && !value.startsWith('0001-01-01');
}

function carveRefetchInterval(live: boolean, detail?: CarveDetail) {
const query = detail?.query;
if (!query) return resourceRefetchInterval(live);
const status = (query.carve_status || '').toUpperCase();
return resourceRefetchInterval(live, {
active: query.active || status === 'PENDING' || status === 'ACTIVE',
completed: query.completed || status === 'COMPLETED',
expired: query.expired || status === 'EXPIRED',
deleted: query.deleted || status === 'DELETED',
});
}

export function CarveDetailPage() {
useResourceUpdates('carves');
const carvesLive = useResourceUpdates('carves');
const { t } = useTranslation();
usePageTitle(t('pageTitle.carve'));
const { env, name } = useParams({ from: '/_app/env/$env/carves/$name' });
Expand All @@ -77,7 +89,7 @@ export function CarveDetailPage() {
queryKey: ['carve', env, name],
queryFn: () => getCarve(env, name),
staleTime: 15_000,
refetchInterval: 15_000,
refetchInterval: ({ state }) => carveRefetchInterval(carvesLive, state.data),
});
const qc = useQueryClient();
const completeMutation = useMutation({
Expand Down Expand Up @@ -223,10 +235,9 @@ export function CarveDetailPage() {
)}
</h2>
<div className="ml-auto flex items-center gap-2">
{/* Refresh button — mirrors the query detail page. Reloads the
{/* Refresh button mirrors the query detail page. Reloads the
carve, its carved files, the carves list, and the node lookup so
an operator watching blocks land can pull the latest state now
instead of waiting for the 15s poll. */}
an operator watching blocks land can pull the latest state now. */}
<button
type="button"
onClick={() => {
Expand Down
6 changes: 3 additions & 3 deletions frontend/src/features/carves/CarvesListPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useResourceUpdates } from '$/lib/live-updates';
import { resourceRefetchInterval, useResourceUpdates } from '$/lib/live-updates';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePageTitle } from '$/lib/usePageTitle';
Expand Down Expand Up @@ -55,7 +55,7 @@ function carveStatusTabs(t: ReturnType<typeof useTranslation>['t']): StatusTab<C
const PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const;

export function CarvesListPage() {
useResourceUpdates('carves');
const carvesLive = useResourceUpdates('carves');
const { t } = useTranslation();
usePageTitle(t('pageTitle.carves'));
const { env } = useParams({ from: '/_app/env/$env/carves' });
Expand Down Expand Up @@ -100,7 +100,7 @@ export function CarvesListPage() {
pageSize,
}),
staleTime: 15_000,
refetchInterval: 15_000,
refetchInterval: resourceRefetchInterval(carvesLive),
placeholderData: (prev: CarvesPagedResponse | undefined) => prev,
});

Expand Down
6 changes: 3 additions & 3 deletions frontend/src/features/queries/QueriesListPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useResourceUpdates } from '$/lib/live-updates';
import { resourceRefetchInterval, useResourceUpdates } from '$/lib/live-updates';
import { useState } from 'react';
import { useTranslation } from 'react-i18next';
import { usePageTitle } from '$/lib/usePageTitle';
Expand Down Expand Up @@ -53,7 +53,7 @@ const PAGE_SIZE_OPTIONS = [25, 50, 100, 200] as const;
// QueriesListPage
// ---------------------------------------------------------------------------
export function QueriesListPage() {
useResourceUpdates('queries');
const queriesLive = useResourceUpdates('queries');
const { t } = useTranslation();
usePageTitle(t('pageTitle.queries'));
const { env } = useParams({ from: '/_app/env/$env/queries' });
Expand Down Expand Up @@ -99,7 +99,7 @@ export function QueriesListPage() {
pageSize,
}),
staleTime: 15_000,
refetchInterval: 15_000,
refetchInterval: resourceRefetchInterval(queriesLive),
placeholderData: (prev: QueriesPagedResponse | undefined) => prev,
});

Expand Down
15 changes: 7 additions & 8 deletions frontend/src/features/queries/QueryDetailPage.tsx
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { useResourceUpdates } from '$/lib/live-updates';
import { resourceRefetchInterval, useResourceUpdates } from '$/lib/live-updates';
import { useParams, useNavigate, useSearch, Link } from '@tanstack/react-router';
import { useTranslation } from 'react-i18next';
import { usePageTitle } from '$/lib/usePageTitle';
Expand Down Expand Up @@ -44,7 +44,7 @@ function ResultStatusBadge({ code }: { code: number }) {
const DEFAULT_PAGE_SIZE = 50;

export function QueryDetailPage() {
useResourceUpdates('queries');
const queriesLive = useResourceUpdates('queries');
const { t } = useTranslation();
usePageTitle(t('pageTitle.query'));
const { env, name } = useParams({ from: '/_app/env/$env/queries/$name' });
Expand All @@ -65,7 +65,7 @@ export function QueryDetailPage() {
queryKey: ['query', env, name],
queryFn: () => getQuery(env, name),
staleTime: 15_000,
refetchInterval: 15_000,
refetchInterval: ({ state }) => resourceRefetchInterval(queriesLive, state.data),
});

const qc = useQueryClient();
Expand All @@ -88,7 +88,7 @@ export function QueryDetailPage() {
queryKey: ['query-results', env, name, page, pageSize, since],
queryFn: () => listQueryResults({ env, name, page, pageSize, since }),
staleTime: 15_000,
refetchInterval: 15_000,
refetchInterval: () => resourceRefetchInterval(queriesLive, query),
enabled: !!query,
});

Expand Down Expand Up @@ -296,10 +296,9 @@ export function QueryDetailPage() {
)}
</h2>
<div className="ml-auto flex items-center gap-2">
{/* Refresh button — mirrors the legacy admin's "Refresh table" control.
The page also polls every 15s via TanStack Query's refetchInterval,
but an explicit button gives operators the same control they had
in legacy when watching a long-running distributed query land. */}
{/* Refresh button mirrors the legacy admin's "Refresh table" control.
Automatic refresh is adaptive, and the explicit button keeps the
same operator control when watching a distributed query land. */}
<button
type="button"
// Refresh everything: the query, its results, the queries list,
Expand Down
Loading
Loading