diff --git a/.gitignore b/.gitignore index 0c32f90c..d80ef49a 100644 --- a/.gitignore +++ b/.gitignore @@ -85,3 +85,6 @@ tools/bruno/collection.bru !CHANGELOG.md !SECURITY.md !frontend/**/*.md + +# Local-only planning / design notes (internal toolchain output) +docs/superpowers/ diff --git a/cmd/api/handlers/environments.go b/cmd/api/handlers/environments.go index 3f4eee0a..557223cb 100644 --- a/cmd/api/handlers/environments.go +++ b/cmd/api/handlers/environments.go @@ -1,7 +1,10 @@ package handlers import ( + "crypto/x509" + "encoding/base64" "encoding/json" + "encoding/pem" "fmt" "net/http" "strings" @@ -243,6 +246,14 @@ func (h *HandlersApi) EnvEnrollHandler(w http.ResponseWriter, r *http.Request) { returnData = env.Certificate case settings.DownloadFlags: returnData = env.Flags + case settings.DownloadFlagsLinux: + returnData = substitutePlatformPaths(env.Flags, env.Name, "/etc/osquery", "/") + case settings.DownloadFlagsMac: + returnData = substitutePlatformPaths(env.Flags, env.Name, "/private/var/osquery", "/") + case settings.DownloadFlagsWin: + returnData = substitutePlatformPaths(env.Flags, env.Name, "C:\\Program Files\\osquery", "\\") + case settings.DownloadFlagsFreeBSD: + returnData = substitutePlatformPaths(env.Flags, env.Name, "/usr/local/etc", "/") case environments.EnrollShell: returnData, err = environments.QuickAddOneLinerShell((env.Certificate != ""), env) if err != nil { @@ -657,3 +668,140 @@ func (h *HandlersApi) EnvActionsHandler(w http.ResponseWriter, r *http.Request) h.AuditLog.EnvAction(ctx[ctxUser], e.Action+" - "+e.Name, strings.Split(r.RemoteAddr, ":")[0], auditlog.NoEnvironment) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiGenericResponse{Message: msgReturn}) } + +// EnvConfigurationHandler - GET handler returning the assembled osquery +// configuration JSON for an environment. Returns the stored composed blob +// (options + schedule + packs + decorators + ATC). The composition is +// kept up to date by RefreshConfiguration, which fires from every parts +// mutation (UpdateOptions / UpdateSchedule / UpdatePacks / etc. in +// pkg/environments/osqueryconf.go), so reading the cached value is +// safe — the agents see the exact same blob. +// +// SECURITY: deliberately a pure read. The first cut of this handler +// called RefreshConfiguration on every GET, which turned the endpoint +// into a CSRF-via-GET shape and a hot-loop DB-write hazard when +// React-Query's stale refetch path hit it. The mutation path on the +// parts is the canonical place for the recompose. +func (h *HandlersApi) EnvConfigurationHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "error getting environment", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + if err.Error() == "record not found" { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + } else { + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, err) + } + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + h.denyEnv(w, r, ctx, env.ID, "permission check failed") + return + } + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiDataResponse{Data: env.Configuration}) +} + +// envCertUploadRequest is the body shape for EnvCertUploadHandler. The PEM +// is sent base64-encoded so the upload survives clients that mangle raw +// newlines (curl --data, browser fetch with a plain string body, etc.). +type envCertUploadRequest struct { + CertificateB64 string `json:"certificate_b64"` +} + +// EnvCertUploadHandler - POST handler to upload the enrollment certificate +// for an environment. Body: { "certificate_b64": "" }. The PEM +// must parse as one or more CERTIFICATE blocks and the leaf must be a real +// x509 cert — we don't accept "looks like base64 of something." Legacy +// admin's equivalent path skipped this validation; the SPA target gets it +// so a typo'd paste fails fast instead of breaking enrollment downloads. +func (h *HandlersApi) EnvCertUploadHandler(w http.ResponseWriter, r *http.Request) { + if h.DebugHTTPConfig.EnableHTTP { + utils.DebugHTTPDump(h.DebugHTTP, r, h.DebugHTTPConfig.ShowBody) + } + envVar := r.PathValue("env") + if envVar == "" { + apiErrorResponse(w, "error getting environment", http.StatusBadRequest, nil) + return + } + env, err := h.Envs.Get(envVar) + if err != nil { + if err.Error() == "record not found" { + apiErrorResponse(w, "environment not found", http.StatusNotFound, err) + } else { + apiErrorResponse(w, "error getting environment", http.StatusInternalServerError, err) + } + return + } + ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) + if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { + h.denyEnv(w, r, ctx, env.ID, "permission check failed") + return + } + // Cap the request body at 64 KiB. A realistic PEM chain is well under + // 16 KiB; nginx alone allows up to 20 MiB and the cert upload is the + // worst-case amplifier (one big base64 string blows up ~1.33x on JSON + // decode + another 0.75x on base64 decode). 64 KiB leaves headroom + // for legitimate multi-cert chains while preventing a privileged + // operator account from being turned into an OOM lever. + r.Body = http.MaxBytesReader(w, r.Body, 64<<10) + var req envCertUploadRequest + if err := json.NewDecoder(r.Body).Decode(&req); err != nil { + apiErrorResponse(w, "error parsing POST body", http.StatusBadRequest, err) + return + } + if req.CertificateB64 == "" { + apiErrorResponse(w, "empty certificate", http.StatusBadRequest, nil) + return + } + pemBytes, err := base64.StdEncoding.DecodeString(req.CertificateB64) + if err != nil { + apiErrorResponse(w, "error decoding certificate", http.StatusBadRequest, err) + return + } + block, _ := pem.Decode(pemBytes) + if block == nil || block.Type != "CERTIFICATE" { + apiErrorResponse(w, "invalid PEM: no CERTIFICATE block", http.StatusBadRequest, nil) + return + } + if _, err := x509.ParseCertificate(block.Bytes); err != nil { + apiErrorResponse(w, "invalid x509 certificate", http.StatusBadRequest, err) + return + } + if err := h.Envs.UpdateCertificate(env.UUID, string(pemBytes)); err != nil { + apiErrorResponse(w, "error saving certificate", http.StatusInternalServerError, err) + return + } + log.Debug().Msgf("Certificate updated for environment %s", env.Name) + h.AuditLog.EnvAction(ctx[ctxUser], "upload certificate for environment "+env.Name, strings.Split(r.RemoteAddr, ":")[0], env.ID) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.ApiGenericResponse{Message: "certificate uploaded successfully"}) +} + +// substitutePlatformPaths fills the __SECRET_FILE__ / __CERT_FILE__ placeholders +// in the env.Flags template with the canonical install paths for a given OS. +// This is the same substitution legacy admin's download path performs (see +// cmd/admin/handlers/utils.go generateFlags); centralising it here keeps the +// API's per-OS flag downloads producing the exact bytes operators expect to +// drop into /etc/osquery/osctrl-{env}.flags (or the platform equivalent). +// +// sep is the path separator the OS uses ("/" for everything except Windows). +// +// strings.ReplaceAll is deliberate even though the flag template ships with +// one occurrence of each placeholder today. If an operator ever edits the +// template to reference the secret/cert path twice (e.g. an additional +// --logger_tls_endpoint that needs the same path), the single-replace +// would silently leave the second placeholder unsubstituted — a footgun +// that costs a real outage. +func substitutePlatformPaths(flags, envName, dir, sep string) string { + secretPath := dir + sep + "osctrl-" + envName + ".secret" + certPath := dir + sep + "osctrl-" + envName + ".crt" + out := strings.ReplaceAll(flags, "__SECRET_FILE__", secretPath) + out = strings.ReplaceAll(out, "__CERT_FILE__", certPath) + return out +} diff --git a/cmd/api/handlers/environments_crud.go b/cmd/api/handlers/environments_crud.go index ee6bab0b..b5ce072e 100644 --- a/cmd/api/handlers/environments_crud.go +++ b/cmd/api/handlers/environments_crud.go @@ -7,6 +7,7 @@ import ( "net/http" "strings" + "github.com/jmpsec/osctrl/pkg/auditlog" "github.com/jmpsec/osctrl/pkg/environments" "github.com/jmpsec/osctrl/pkg/tags" "github.com/jmpsec/osctrl/pkg/types" @@ -27,7 +28,7 @@ func (h *HandlersApi) EnvironmentCreateHandler(w http.ResponseWriter, r *http.Re } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, auditlog.NoEnvironment, "permission check failed") return } var body types.EnvCreateRequest @@ -114,7 +115,7 @@ func (h *HandlersApi) EnvironmentUpdateHandler(w http.ResponseWriter, r *http.Re } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, auditlog.NoEnvironment, "permission check failed") return } envVar := r.PathValue("env") @@ -231,7 +232,7 @@ func (h *HandlersApi) EnvironmentDeleteHandler(w http.ResponseWriter, r *http.Re } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, users.NoEnvironment) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, auditlog.NoEnvironment, "permission check failed") return } envVar := r.PathValue("env") @@ -281,7 +282,7 @@ func (h *HandlersApi) EnvironmentConfigHandler(w http.ResponseWriter, r *http.Re } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } resp := types.EnvConfigResponse{ @@ -321,7 +322,7 @@ func (h *HandlersApi) EnvironmentConfigPatchHandler(w http.ResponseWriter, r *ht } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } var body types.EnvConfigPatchRequest @@ -434,7 +435,7 @@ func (h *HandlersApi) EnvironmentIntervalsPatchHandler(w http.ResponseWriter, r } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } var body types.EnvIntervalsPatchRequest @@ -503,7 +504,7 @@ func (h *HandlersApi) EnvironmentExpirationPatchHandler(w http.ResponseWriter, r } ctx := r.Context().Value(ContextKey(contextAPI)).(ContextValue) if !h.Users.CheckPermissions(ctx[ctxUser], users.AdminLevel, env.UUID) { - apiErrorResponse(w, "no access", http.StatusForbidden, fmt.Errorf("attempt to use API by user %s", ctx[ctxUser])) + h.denyEnv(w, r, ctx, env.ID, "permission check failed") return } var body types.EnvExpirationPatchRequest diff --git a/cmd/api/handlers/queries.go b/cmd/api/handlers/queries.go index bf52bda2..6ff97680 100644 --- a/cmd/api/handlers/queries.go +++ b/cmd/api/handlers/queries.go @@ -75,10 +75,34 @@ func (h *HandlersApi) QueryShowHandler(w http.ResponseWriter, r *http.Request) { } return } + // Targets — the creation-time scope (platform/uuid/hostname/tag rows + // stored in the query_targets table). The legacy admin shows them + // in a small Type/Value table on the query detail page; surface + // them here so the SPA can render the same. Best-effort: a fetch + // error doesn't fail the request — we still return the query. + type queryTarget struct { + Type string `json:"type"` + Value string `json:"value"` + } + targets := []queryTarget{} + if rows, terr := h.Queries.GetTargets(name); terr == nil { + for _, t := range rows { + targets = append(targets, queryTarget{Type: t.Type, Value: t.Value}) + } + } else { + log.Debug().Err(terr).Msgf("query targets fetch failed for %s", name) + } + resp := struct { + queries.DistributedQuery + Targets []queryTarget `json:"targets"` + }{ + DistributedQuery: query, + Targets: targets, + } // Serialize and serve JSON log.Debug().Msgf("Returned query %s", name) h.AuditLog.Visit(ctx[ctxUser], r.URL.Path, strings.Split(r.RemoteAddr, ":")[0], env.ID) - utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, query) + utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, resp) } // QueriesRunHandler - POST Handler to run a query diff --git a/cmd/api/handlers/users_profile.go b/cmd/api/handlers/users_profile.go index bae2ee27..5c9aa083 100644 --- a/cmd/api/handlers/users_profile.go +++ b/cmd/api/handlers/users_profile.go @@ -1,11 +1,14 @@ package handlers import ( + "crypto/rand" + "encoding/hex" "encoding/json" "errors" "fmt" "net/http" "strings" + "time" "github.com/jmpsec/osctrl/pkg/types" "github.com/jmpsec/osctrl/pkg/users" @@ -294,6 +297,44 @@ func (h *HandlersApi) RefreshUserTokenHandler(w http.ResponseWriter, r *http.Req apiErrorResponse(w, "error persisting token", http.StatusInternalServerError, err) return } + // Self-rotate: the JWT in the caller's osctrl_token cookie was just + // invalidated against handlerAuthCheck's APIToken match. Re-issue + // the session cookies with the new token (and a fresh CSRF) so the + // caller stays logged in. For other-user rotate, the caller's own + // session is unaffected — no cookie work needed. + if isSelf { + csrfBytes := make([]byte, 16) + if _, err := rand.Read(csrfBytes); err != nil { + apiErrorResponse(w, "error generating csrf token", http.StatusInternalServerError, err) + return + } + csrfToken := hex.EncodeToString(csrfBytes) + if err := h.Users.UpdateMetadata(strings.Split(r.RemoteAddr, ":")[0], r.UserAgent(), username, csrfToken); err != nil { + apiErrorResponse(w, "error persisting csrf token", http.StatusInternalServerError, err) + return + } + maxAge := int(time.Until(expires).Seconds()) + if maxAge > 0 { + http.SetCookie(w, &http.Cookie{ + Name: "osctrl_token", + Value: token, + Path: "/", + MaxAge: maxAge, + HttpOnly: true, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + http.SetCookie(w, &http.Cookie{ + Name: "osctrl_csrf", + Value: csrfToken, + Path: "/", + MaxAge: maxAge, + HttpOnly: false, + Secure: true, + SameSite: http.SameSiteLaxMode, + }) + } + } h.AuditLog.NewToken(username, strings.Split(r.RemoteAddr, ":")[0]) log.Debug().Msgf("refreshed API token for %s (requested by %s)", username, requester) utils.HTTPResponse(w, utils.JSONApplicationUTF8, http.StatusOK, types.TokenResponse{Token: token, Expires: expires}) diff --git a/cmd/api/main.go b/cmd/api/main.go index 421295ae..bd58f462 100644 --- a/cmd/api/main.go +++ b/cmd/api/main.go @@ -637,6 +637,12 @@ func osctrlAPIService() { muxAPI.Handle( "GET "+_apiPath(apiEnvironmentsPath)+"/{env}/enroll/{target}", handlerAuthCheck(http.HandlerFunc(handlersApi.EnvEnrollHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "GET "+_apiPath(apiEnvironmentsPath)+"/{env}/configuration/assembled", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvConfigurationHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) + muxAPI.Handle( + "POST "+_apiPath(apiEnvironmentsPath)+"/{env}/enroll/cert", + handlerAuthCheck(http.HandlerFunc(handlersApi.EnvCertUploadHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) muxAPI.Handle( "POST "+_apiPath(apiEnvironmentsPath)+"/{env}/enroll/{action}", handlerAuthCheck(http.HandlerFunc(handlersApi.EnvEnrollActionsHandler), flagParams.Service.Auth, flagParams.JWT.JWTSecret)) diff --git a/frontend/index.html b/frontend/index.html index 0d913ecb..9169c797 100644 --- a/frontend/index.html +++ b/frontend/index.html @@ -4,7 +4,7 @@ - + osctrl diff --git a/frontend/public/favicon.png b/frontend/public/favicon.png new file mode 100644 index 00000000..fc69cc17 Binary files /dev/null and b/frontend/public/favicon.png differ diff --git a/frontend/public/fonts/bai-jamjuree-500.woff2 b/frontend/public/fonts/bai-jamjuree-500.woff2 new file mode 100644 index 00000000..0779eba6 Binary files /dev/null and b/frontend/public/fonts/bai-jamjuree-500.woff2 differ diff --git a/frontend/public/fonts/bai-jamjuree-600.woff2 b/frontend/public/fonts/bai-jamjuree-600.woff2 new file mode 100644 index 00000000..bb1ec06e Binary files /dev/null and b/frontend/public/fonts/bai-jamjuree-600.woff2 differ diff --git a/frontend/public/fonts/bai-jamjuree-700.woff2 b/frontend/public/fonts/bai-jamjuree-700.woff2 new file mode 100644 index 00000000..f03d5368 Binary files /dev/null and b/frontend/public/fonts/bai-jamjuree-700.woff2 differ diff --git a/frontend/public/img/circuit.svg b/frontend/public/img/circuit.svg new file mode 100644 index 00000000..02217542 --- /dev/null +++ b/frontend/public/img/circuit.svg @@ -0,0 +1 @@ + diff --git a/frontend/public/img/osctrl-logo-dark.png b/frontend/public/img/osctrl-logo-dark.png new file mode 100644 index 00000000..ac534b25 Binary files /dev/null and b/frontend/public/img/osctrl-logo-dark.png differ diff --git a/frontend/public/img/osctrl-logo.png b/frontend/public/img/osctrl-logo.png new file mode 100644 index 00000000..b384e439 Binary files /dev/null and b/frontend/public/img/osctrl-logo.png differ diff --git a/frontend/public/img/osctrl-mark.svg b/frontend/public/img/osctrl-mark.svg new file mode 100644 index 00000000..2f377f79 --- /dev/null +++ b/frontend/public/img/osctrl-mark.svg @@ -0,0 +1,39 @@ + + + + +Created by potrace 1.16, written by Peter Selinger 2001-2019 + + + + + + + + diff --git a/frontend/src/api/client.ts b/frontend/src/api/client.ts index ca7297c2..e4c8cba4 100644 --- a/frontend/src/api/client.ts +++ b/frontend/src/api/client.ts @@ -14,6 +14,15 @@ export function getCsrfToken(): string | null { } export function isAuthenticated(): boolean { + // Mirror the apiFetch fallback: if memory is empty but the browser + // still holds the osctrl_csrf cookie, prime from it before answering + // false. Without this, every soft navigation through a route with a + // beforeLoad auth guard could bounce a logged-in user to /login the + // moment csrfTokenInMemory is reset (e.g., a brief race during token + // rotation, or any code path that called setCsrfToken(null)). + if (csrfTokenInMemory === null) { + primeCsrfFromCookie(); + } return csrfTokenInMemory !== null; } @@ -91,9 +100,20 @@ export async function apiFetch( headers.set('Accept', 'application/json'); } - const csrf = getCsrfToken(); - if (MUTATING_VERBS.has(method) && csrf) { - headers.set('X-CSRF-Token', csrf); + if (MUTATING_VERBS.has(method)) { + // Belt-and-braces: if the in-memory CSRF is null (we never primed + // from the cookie this boot, or a stale clear happened) but the + // browser still holds the osctrl_csrf cookie, re-prime now so + // mutating requests don't ship without the header. Without this, + // a single dropped prime turns every mutation into "csrf token + // missing or invalid" until the user logs out and back in. + if (!getCsrfToken()) { + primeCsrfFromCookie(); + } + const csrf = getCsrfToken(); + if (csrf) { + headers.set('X-CSRF-Token', csrf); + } } const res = await fetch(path, { diff --git a/frontend/src/api/enrollment.ts b/frontend/src/api/enrollment.ts index 6de0bf93..5bf14b94 100644 --- a/frontend/src/api/enrollment.ts +++ b/frontend/src/api/enrollment.ts @@ -22,7 +22,11 @@ import { apiFetch } from './client'; export type EnrollTarget = | 'secret' // raw enroll secret (string) | 'cert' // env certificate PEM - | 'flags' // raw osquery flags file content + | 'flags' // raw osquery flags file content (template — paths unsubstituted) + | 'flagsLinux' // flags file with __SECRET_FILE__/__CERT_FILE__ → /etc/osquery/... paths + | 'flagsMac' // flags file with paths → /private/var/osquery/... + | 'flagsWindows' // flags file with paths → C:\Program Files\osquery\... + | 'flagsFreeBSD' // flags file with paths → /usr/local/etc/... | 'enroll.sh' // bash one-liner installer | 'enroll.ps1'; // powershell one-liner installer @@ -114,3 +118,22 @@ export function removeAction( }, ); } + +// --------------------------------------------------------------------------- +// POST /environments/{env}/enroll/cert — upload a replacement enrollment cert. +// The API validates: base64 → PEM CERTIFICATE block → x509.ParseCertificate. +// Caller passes the raw PEM as a string; we base64-encode here so JSON-body +// quoting and newline handling stays clean (raw PEM strings break otherwise). +// --------------------------------------------------------------------------- +export function uploadCertificate(env: string, pem: string): Promise { + // btoa handles ASCII; a PEM is ASCII (base64 + dashes + newlines), so it's safe. + const b64 = btoa(pem); + return apiFetch( + `/api/v1/environments/${encodeURIComponent(env)}/enroll/cert`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ certificate_b64: b64 }), + }, + ); +} diff --git a/frontend/src/api/environments.ts b/frontend/src/api/environments.ts index e54a0d17..3c953987 100644 --- a/frontend/src/api/environments.ts +++ b/frontend/src/api/environments.ts @@ -146,6 +146,20 @@ export function getEnvironmentConfig(env: string): Promise { ); } +/** + * GET /api/v1/environments/{env}/configuration/assembled + * + * Returns the env's options + schedule + packs + decorators + ATC + * recomposed into the canonical osquery configuration blob — same bytes + * the TLS endpoint serves agents. Backend re-runs RefreshConfiguration + * before reading, so the result is always fresh. + */ +export function getEnvironmentAssembledConfig(env: string): Promise<{ data: string }> { + return apiFetch<{ data: string }>( + `/api/v1/environments/${encodeURIComponent(env)}/configuration/assembled`, + ); +} + /** PATCH /api/v1/environments/config/{env} — atomic JSON-validated patch. */ export function patchEnvironmentConfig( env: string, diff --git a/frontend/src/api/nodes.ts b/frontend/src/api/nodes.ts index 89c35b11..bde21bb2 100644 --- a/frontend/src/api/nodes.ts +++ b/frontend/src/api/nodes.ts @@ -45,6 +45,25 @@ export function getNode(env: string, uuid: string): Promise { ); } +/** + * POST /api/v1/nodes/{env}/delete — archive + delete a node. + * + * The backend's ArchiveDeleteByUUID always snapshots the node into the + * archive table before removing the live row, so the data is recoverable + * via the archive tables even though the row disappears from the active + * nodes list. AdminLevel-gated server-side. + */ +export function deleteNode(env: string, uuid: string): Promise<{ message: string }> { + return apiFetch<{ message: string }>( + `/api/v1/nodes/${encodeURIComponent(env)}/delete`, + { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ uuid }), + }, + ); +} + export function listNodeLogs( env: string, uuid: string, diff --git a/frontend/src/api/types.ts b/frontend/src/api/types.ts index c981da67..e4a3d69b 100644 --- a/frontend/src/api/types.ts +++ b/frontend/src/api/types.ts @@ -150,6 +150,17 @@ export interface DistributedQuery { extra_data: string; expiration: string; target: string; + /** + * Targets the query was launched against — populated by + * GET /queries/{env}/{name}; list endpoints leave it out so it may + * be undefined depending on the call site. + */ + targets?: QueryTargetRow[]; +} + +export interface QueryTargetRow { + type: string; + value: string; } export interface QueriesPagedResponse { diff --git a/frontend/src/components/atoms/DocsLink.tsx b/frontend/src/components/atoms/DocsLink.tsx new file mode 100644 index 00000000..4ed899f5 --- /dev/null +++ b/frontend/src/components/atoms/DocsLink.tsx @@ -0,0 +1,69 @@ +import { cn } from '$/lib/cn'; + +/** + * DocsLink — small "docs" affordance that opens an external documentation + * URL in a new tab. Designed to live in section headers next to a title. + * + * Visual: a "?" glyph + the word "docs" in dim mono-tabular text, both + * tucked into a low-contrast pill that brightens on hover. The link goes + * through `rel="noopener noreferrer"` since the target is third-party + * (typically osquery's read-the-docs site) and we don't want the docs + * tab to be able to navigate the SPA back via window.opener. + */ +export function DocsLink({ + href, + label = 'docs', + className, +}: { + href: string; + label?: string; + className?: string; +}) { + // The bare form rendered as un-clickable in the live + // build (suspected CSP/router interception). First attempt at a fix + // used window.open with a 'noopener,noreferrer' feature string as the + // 3rd arg — but Chromium treats any non-empty feature string as a + // popup request which collapses the target argument, so the new tab + // opened to about:blank. + // + // Correct path: let the + rel=noopener noreferrer + // do the work via assignment to window.location of an opened tab. + // Open with no feature string so target=_blank means "new tab", then + // explicitly null the opener for noopener semantics. + function handleClick(e: React.MouseEvent) { + // Let cmd/ctrl/shift/middle clicks fall through to the browser's + // own new-tab behaviour. Only the plain left-click goes through + // our window.open path. + if (e.metaKey || e.ctrlKey || e.shiftKey || e.button !== 0) return; + if (!href) return; + e.preventDefault(); + const win = window.open(href, '_blank'); + if (win) win.opener = null; + } + + return ( + + + + + + + {label} + + ); +} diff --git a/frontend/src/components/atoms/Logo.tsx b/frontend/src/components/atoms/Logo.tsx index 0ee1e12b..816b3561 100644 --- a/frontend/src/components/atoms/Logo.tsx +++ b/frontend/src/components/atoms/Logo.tsx @@ -6,23 +6,45 @@ interface LogoProps { decorative?: boolean; } +/** + * Original osctrl tower mark from cmd/admin/static/img/logo.png — the + * artwork legacy operators already recognise. Two PNG variants exist: + * + * /img/osctrl-logo.png — original (dark outline, light-blue + * cabin windows). Used in light theme. + * /img/osctrl-logo-dark.png — manually recolored (light outline, + * brand-info blue cabin windows). Used + * in dark theme. + * + * The .osctrl-logo CSS rule in base.css shows one or the other based + * on data-theme, no JS state needed. This replaces the previous + * filter:invert() hack which Tailwind's minifier kept breaking. + */ export function Logo({ size = 32, className, decorative = false }: LogoProps) { return ( - - - - - - - - + + + ); } diff --git a/frontend/src/components/chrome/SideNav.tsx b/frontend/src/components/chrome/SideNav.tsx index 2d745e7e..ee430faa 100644 --- a/frontend/src/components/chrome/SideNav.tsx +++ b/frontend/src/components/chrome/SideNav.tsx @@ -123,6 +123,7 @@ export function SideNav() { const carvesPath = `/_app/env/${currentEnv}/carves`; const tagsPath = `/_app/env/${currentEnv}/tags`; const enrollPath = `/_app/env/${currentEnv}/enroll`; + const configPath = `/_app/env/${currentEnv}/config`; // Distinguish "/queries" (and its subroutes) from "/saved-queries". const isSavedQueriesActive = pathname.startsWith(`/_app/env/${currentEnv}/saved-queries`); const isQueriesActive = @@ -130,6 +131,7 @@ export function SideNav() { const isCarvesActive = pathname.startsWith(`/_app/env/${currentEnv}/carves`); const isTagsActive = pathname.startsWith(`/_app/env/${currentEnv}/tags`); const isEnrollActive = pathname.startsWith(`/_app/env/${currentEnv}/enroll`); + const isConfigActive = pathname.startsWith(`/_app/env/${currentEnv}/config`); const isUsersActive = pathname.startsWith('/_app/users') || pathname === '/users'; const isProfileActive = pathname.startsWith('/_app/profile') || pathname === '/profile'; const isEnvironmentsActive = @@ -142,22 +144,26 @@ export function SideNav() { return (