Watch
1
0
Fork
You've already forked RedFlag
0

feat: setup accepts operator-supplied signing keypair + validation

Extract serverSetupRequest type and resolveSetupSigningKeys(): when no keys
are provided the server generates a fresh Ed25519 pair (existing behaviour);
when a private key is provided it is validated and the public key derived from
it (public key may be omitted or supplied for cross-check). Mismatched pairs
are rejected 400. Remove configure-secrets route from welcome-mode router
(was only usable with Docker socket mounted, unreachable in that mode).
Add inferPublicURL() helper to fill publicURL from X-Forwarded-* headers when
the operator omits it. pq.QuoteLiteral() used for password in ALTER USER.
Tests: generate-when-missing, use-provided-pair, reject-mismatched-pair.
This commit is contained in:
Fimeg 2026-06-10 23:18:41 -04:00
commit 2da1e92fe9
16 changed files with 849 additions and 283 deletions

BIN
Screenshots/Overview.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 129 KiB

View file

@ -125,7 +125,6 @@ func startWelcomeModeServer() {
// Setup endpoint for web configuration
router.POST("/api/setup/configure", setupHandler.ConfigureServer)
router.POST("/api/setup/generate-keys", setupHandler.GenerateSigningKeys)
router.POST("/api/setup/configure-secrets", setupHandler.ConfigureSecrets)
if webui.Present() {
registerWebUI(router)

View file

@ -151,6 +151,9 @@ func TestGatedTargetAheadUsesPackageManagerVersionShape(t *testing.T) {
if gatedTargetAhead("dnf", "3:29.5.2-1.fc43", "3:29.5.2-1.fc43") {
t.Fatal("dnf exact same EVR should be treated as a no-op")
}
if gatedTargetAhead("dnf", "2:99.0.0-1.fc43", "3:1.0.0-1.fc43") {
t.Fatal("dnf lower epoch should not be treated as newer")
}
if !gatedTargetAhead("apt", "1.2.3-1ubuntu1~22.04", "1.2.3-1ubuntu1") {
t.Fatal("apt package versions should not be rejected by semver comparison")
}

View file

@ -145,18 +145,20 @@ func (h *ProcessHandler) ReportProcessScan(c *gin.Context) {
if relType.data == nil {
continue
}
// Check if the slice is empty (non-nil but zero-length)
// Marshal once, check for empty/null, then store as JSONB.
dataJSON, err := json.Marshal(relType.data)
if err != nil {
continue
}
if string(dataJSON) == "null" || string(dataJSON) == "[]" {
continue
if len(dataJSON) <= 2 || string(dataJSON) == "null" {
continue // "[]", "{}", or "null"
}
// Unmarshal into interface{} to get a generic Go value (slice or map),
// which JSONB (map[string]interface{}) can hold as a value.
var jb models.JSONB
if err := json.Unmarshal(dataJSON, &jb); err != nil {
continue
// Slice data won't unmarshal into a map — wrap it.
jb = models.JSONB{"_items": json.RawMessage(dataJSON)}
}
relatedEntries = append(relatedEntries, models.ProcessRelated{
@ -318,10 +320,11 @@ func (h *ProcessHandler) TriggerProcessScan(c *gin.Context) {
}
// Check for existing pending scan_processes command (dedup)
existingCmds, err := h.commandQueries.GetCommandsByAgentID(agentID)
// Use GetPendingCommands (small bounded set) instead of GetCommandsByAgentID (all history).
pendingCmds, err := h.commandQueries.GetPendingCommands(agentID)
if err == nil {
for _, cmd := range existingCmds {
if cmd.CommandType == "scan_processes" && (cmd.Status == "pending" || cmd.Status == "received") {
for _, cmd := range pendingCmds {
if cmd.CommandType == "scan_processes" {
c.JSON(http.StatusOK, gin.H{
"message": "Scan already in progress",
"command_id": cmd.ID.String(),

View file

@ -1,6 +1,7 @@
package handlers
import (
"bytes"
"crypto/ed25519"
"crypto/rand"
"database/sql"
@ -8,6 +9,7 @@ import (
"fmt"
"net/http"
"strconv"
"strings"
"github.com/Fimeg/RedFlag/server/internal/config"
"github.com/Fimeg/RedFlag/server/internal/services"
@ -21,6 +23,23 @@ type SetupHandler struct {
configPath string
}
type serverSetupRequest struct {
AdminUser string `json:"adminUser"`
AdminPass string `json:"adminPassword"`
DBHost string `json:"dbHost"`
DBPort string `json:"dbPort"`
DBName string `json:"dbName"`
DBUser string `json:"dbUser"`
DBPassword string `json:"dbPassword"`
ServerHost string `json:"serverHost"`
ServerPort string `json:"serverPort"`
MaxSeats string `json:"maxSeats"`
PublicURL string `json:"publicURL"`
SigningPrivateKey string `json:"signingPrivateKey"`
SigningPublicKey string `json:"signingPublicKey"`
}
func NewSetupHandler(configPath string) *SetupHandler {
return &SetupHandler{
configPath: configPath,
@ -44,7 +63,7 @@ func updatePostgresPassword(dbHost, dbPort, dbUser, currentPassword, newPassword
}
// Update the password
_, err = db.Exec("ALTER USER "+pq.QuoteIdentifier(dbUser)+" PASSWORD '"+newPassword+"'")
_, err = db.Exec("ALTER USER " + pq.QuoteIdentifier(dbUser) + " PASSWORD " + pq.QuoteLiteral(newPassword))
if err != nil {
return fmt.Errorf("failed to update PostgreSQL password: %v", err)
}
@ -53,20 +72,87 @@ func updatePostgresPassword(dbHost, dbPort, dbUser, currentPassword, newPassword
return nil
}
func firstForwardedHeaderValue(header string) string {
if header = strings.TrimSpace(header); header == "" {
return ""
}
return strings.TrimSpace(strings.Split(header, ",")[0])
}
func inferPublicURL(c *gin.Context) string {
scheme := firstForwardedHeaderValue(c.GetHeader("X-Forwarded-Proto"))
if scheme == "" {
if c.Request.TLS != nil {
scheme = "https"
} else {
scheme = "http"
}
}
host := firstForwardedHeaderValue(c.GetHeader("X-Forwarded-Host"))
if host == "" {
host = c.Request.Host
}
if host == "" {
return ""
}
return scheme + "://" + host
}
func resolveSetupSigningKeys(req serverSetupRequest) (map[string]string, bool, error) {
privateKeyHex := strings.TrimSpace(req.SigningPrivateKey)
publicKeyHex := strings.TrimSpace(req.SigningPublicKey)
if privateKeyHex == "" && publicKeyHex == "" {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
return nil, false, err
}
return map[string]string{
"public_key": hex.EncodeToString(publicKey),
"private_key": hex.EncodeToString(privateKey),
}, true, nil
}
if privateKeyHex == "" {
return nil, false, fmt.Errorf("signing private key is required when signing public key is provided")
}
privateKeyBytes, err := hex.DecodeString(privateKeyHex)
if err != nil {
return nil, false, fmt.Errorf("invalid signing private key encoding")
}
if len(privateKeyBytes) != ed25519.PrivateKeySize {
return nil, false, fmt.Errorf("invalid signing private key length")
}
privateKey := ed25519.PrivateKey(privateKeyBytes)
derivedPublicKey, ok := privateKey.Public().(ed25519.PublicKey)
if !ok || len(derivedPublicKey) != ed25519.PublicKeySize {
return nil, false, fmt.Errorf("invalid signing private key")
}
if publicKeyHex != "" {
publicKeyBytes, err := hex.DecodeString(publicKeyHex)
if err != nil {
return nil, false, fmt.Errorf("invalid signing public key encoding")
}
if len(publicKeyBytes) != ed25519.PublicKeySize {
return nil, false, fmt.Errorf("invalid signing public key length")
}
if !bytes.Equal(publicKeyBytes, derivedPublicKey) {
return nil, false, fmt.Errorf("signing public key does not match private key")
}
}
return map[string]string{
"public_key": hex.EncodeToString(derivedPublicKey),
"private_key": hex.EncodeToString(privateKey),
}, false, nil
}
// createSharedEnvContentForDisplay generates the .env file content for display
func createSharedEnvContentForDisplay(req struct {
AdminUser string `json:"adminUser"`
AdminPass string `json:"adminPassword"`
DBHost string `json:"dbHost"`
DBPort string `json:"dbPort"`
DBName string `json:"dbName"`
DBUser string `json:"dbUser"`
DBPassword string `json:"dbPassword"`
ServerHost string `json:"serverHost"`
ServerPort string `json:"serverPort"`
MaxSeats string `json:"maxSeats"`
PublicURL string `json:"publicURL"`
}, jwtSecret string, signingKeys map[string]string) (string, error) {
func createSharedEnvContentForDisplay(req serverSetupRequest, jwtSecret string, signingKeys map[string]string) (string, error) {
// Generate .env file content for user to copy
envContent := fmt.Sprintf(`# RedFlag Environment Configuration
# Generated by web setup on 2025-12-13
@ -358,19 +444,7 @@ func (h *SetupHandler) ShowSetupPage(c *gin.Context) {
// ConfigureServer handles the configuration submission
func (h *SetupHandler) ConfigureServer(c *gin.Context) {
var req struct {
AdminUser string `json:"adminUser"`
AdminPass string `json:"adminPassword"`
DBHost string `json:"dbHost"`
DBPort string `json:"dbPort"`
DBName string `json:"dbName"`
DBUser string `json:"dbUser"`
DBPassword string `json:"dbPassword"`
ServerHost string `json:"serverHost"`
ServerPort string `json:"serverPort"`
MaxSeats string `json:"maxSeats"`
PublicURL string `json:"publicURL"`
}
var req serverSetupRequest
if err := c.ShouldBindJSON(&req); err != nil {
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid request format"})
@ -388,6 +462,11 @@ func (h *SetupHandler) ConfigureServer(c *gin.Context) {
return
}
if req.ServerHost == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Server host is required"})
return
}
// Parse numeric values
dbPort, err := strconv.Atoi(req.DBPort)
if err != nil || dbPort <= 0 || dbPort > 65535 {
@ -407,6 +486,15 @@ func (h *SetupHandler) ConfigureServer(c *gin.Context) {
return
}
req.PublicURL = strings.TrimSpace(req.PublicURL)
if req.PublicURL == "" {
req.PublicURL = inferPublicURL(c)
}
if req.PublicURL == "" {
c.JSON(http.StatusBadRequest, gin.H{"error": "Agent-facing server URL is required"})
return
}
// Generate secure JWT secret (not derived from credentials for security)
jwtSecret, err := config.GenerateSecureToken()
if err != nil {
@ -414,31 +502,30 @@ func (h *SetupHandler) ConfigureServer(c *gin.Context) {
return
}
// SECURITY: Generate Ed25519 signing keypair (critical for v0.2.x)
fmt.Println("[START] Generating Ed25519 signing keypair for security...")
signingPublicKey, signingPrivateKey, err := ed25519.GenerateKey(rand.Reader)
// SECURITY: Resolve Ed25519 signing keypair (critical for v0.2.x)
fmt.Println("[START] Preparing Ed25519 signing keypair for security...")
signingKeys, generatedSigningKeys, err := resolveSetupSigningKeys(req)
if err != nil {
fmt.Printf("CRITICAL ERROR: Failed to generate signing keys: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{"error": "Failed to generate signing keys. Security features cannot be enabled."})
fmt.Printf("CRITICAL ERROR: Failed to prepare signing keys: %v\n", err)
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid signing keys. Security features cannot be enabled."})
return
}
signingKeys := map[string]string{
"public_key": hex.EncodeToString(signingPublicKey),
"private_key": hex.EncodeToString(signingPrivateKey),
if generatedSigningKeys {
fmt.Printf("[SUCCESS] Generated Ed25519 keypair - Fingerprint: %s\n", signingKeys["public_key"][:16])
} else {
fmt.Printf("[SUCCESS] Validated Ed25519 keypair - Fingerprint: %s\n", signingKeys["public_key"][:16])
}
fmt.Printf("[SUCCESS] Generated Ed25519 keypair - Fingerprint: %s\n", signingKeys["public_key"][:16])
fmt.Println("[WARNING] SECURITY WARNING: Backup the private key immediately or you will lose access to all agents!")
// Step 1: Update PostgreSQL password from bootstrap to user password
fmt.Println("Updating PostgreSQL password from bootstrap to user-provided password...")
bootstrapPassword := "redflag_bootstrap" // This matches our bootstrap .env
bootstrapPassword := "redflag_bootstrap" // This matches our bootstrap .env
if err := updatePostgresPassword(req.DBHost, req.DBPort, req.DBUser, bootstrapPassword, req.DBPassword); err != nil {
fmt.Printf("CRITICAL ERROR: Failed to update PostgreSQL password: %v\n", err)
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to update database password. Setup cannot continue.",
"error": "Failed to update database password. Setup cannot continue.",
"details": err.Error(),
"help": "Ensure PostgreSQL is accessible and the bootstrap password is correct. Check Docker logs for details.",
"help": "Ensure PostgreSQL is accessible and the bootstrap password is correct. Check Docker logs for details.",
})
return
}
@ -456,14 +543,14 @@ func (h *SetupHandler) ConfigureServer(c *gin.Context) {
}
c.JSON(http.StatusOK, gin.H{
"message": "Configuration generated successfully!",
"envContent": newEnvContent,
"restartMessage": "Please replace the bootstrap environment variables with the newly generated ones, then run: docker-compose down && docker-compose up -d",
"message": "Configuration generated successfully!",
"envContent": newEnvContent,
"restartMessage": "Please replace the bootstrap environment variables with the newly generated ones, then run: docker-compose down && docker-compose up -d",
"manualRestartRequired": true,
"manualRestartCommand": "docker-compose down && docker-compose up -d",
"configFilePath": "./config/.env",
"securityNotice": "[WARNING] A signing key has been generated. BACKUP THE PRIVATE KEY or you will lose access to all agents!",
"publicKeyFingerprint": signingKeys["public_key"][:16] + "...",
"manualRestartCommand": "docker-compose down && docker-compose up -d",
"configFilePath": "./config/.env",
"securityNotice": "[WARNING] A signing key has been generated. BACKUP THE PRIVATE KEY or you will lose access to all agents!",
"publicKeyFingerprint": signingKeys["public_key"][:16] + "...",
})
}
@ -507,13 +594,12 @@ func (h *SetupHandler) GenerateSigningKeys(c *gin.Context) {
})
}
// ConfigureSecrets creates all Docker secrets automatically
func (h *SetupHandler) ConfigureSecrets(c *gin.Context) {
// Check if Docker API is available
if !services.IsDockerAvailable() {
c.JSON(http.StatusServiceUnavailable, gin.H{
"error": "Docker API not available",
"error": "Docker API not available",
"message": "Docker socket is not mounted. Please ensure the server can access Docker daemon",
})
return
@ -523,7 +609,7 @@ func (h *SetupHandler) ConfigureSecrets(c *gin.Context) {
dockerSecrets, err := services.NewDockerSecretsService()
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to connect to Docker",
"error": "Failed to connect to Docker",
"details": err.Error(),
})
return
@ -558,7 +644,7 @@ func (h *SetupHandler) ConfigureSecrets(c *gin.Context) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
c.JSON(http.StatusInternalServerError, gin.H{
"error": "Failed to generate signing keys",
"error": "Failed to generate signing keys",
"details": err.Error(),
})
return

View file

@ -0,0 +1,66 @@
package handlers
import (
"crypto/ed25519"
"crypto/rand"
"encoding/hex"
"testing"
)
func TestResolveSetupSigningKeysGeneratesWhenMissing(t *testing.T) {
keys, generated, err := resolveSetupSigningKeys(serverSetupRequest{})
if err != nil {
t.Fatalf("resolveSetupSigningKeys returned error: %v", err)
}
if !generated {
t.Fatal("resolveSetupSigningKeys should report generated keys")
}
if got := len(keys["public_key"]); got != ed25519.PublicKeySize*2 {
t.Fatalf("public key hex length = %d", got)
}
if got := len(keys["private_key"]); got != ed25519.PrivateKeySize*2 {
t.Fatalf("private key hex length = %d", got)
}
}
func TestResolveSetupSigningKeysUsesProvidedPair(t *testing.T) {
publicKey, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey: %v", err)
}
keys, generated, err := resolveSetupSigningKeys(serverSetupRequest{
SigningPrivateKey: hex.EncodeToString(privateKey),
SigningPublicKey: hex.EncodeToString(publicKey),
})
if err != nil {
t.Fatalf("resolveSetupSigningKeys returned error: %v", err)
}
if generated {
t.Fatal("resolveSetupSigningKeys should not report generated keys")
}
if keys["private_key"] != hex.EncodeToString(privateKey) {
t.Fatal("private key was not preserved")
}
if keys["public_key"] != hex.EncodeToString(publicKey) {
t.Fatal("public key was not preserved")
}
}
func TestResolveSetupSigningKeysRejectsMismatchedPair(t *testing.T) {
publicKey, _, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey public: %v", err)
}
_, privateKey, err := ed25519.GenerateKey(rand.Reader)
if err != nil {
t.Fatalf("GenerateKey private: %v", err)
}
if _, _, err := resolveSetupSigningKeys(serverSetupRequest{
SigningPrivateKey: hex.EncodeToString(privateKey),
SigningPublicKey: hex.EncodeToString(publicKey),
}); err == nil {
t.Fatal("resolveSetupSigningKeys accepted mismatched keys")
}
}

View file

@ -988,16 +988,125 @@ func gatedTargetAhead(packageType, targetVersion, currentVersion string) bool {
}
switch strings.ToLower(strings.TrimSpace(packageType)) {
case "apt", "dnf":
// These use dpkg/RPM ordering, not semver. The server cannot safely sort
// them with utils.CompareVersions; exact equality is the only portable
// no-op guard here. Dry-run and exact hash binding decide installability.
case "apt":
// dpkg ordering is not semver, and Debian release suffixes such as
// 1ubuntu1~22.04 are easy to mis-sort without dpkg --compare-versions.
// Exact equality is the only portable no-op guard here. Dry-run and exact
// hash binding decide installability.
return target != current
case "dnf":
return rpmEVRAhead(target, current)
default:
return utils.CompareVersions(target, current) > 0
}
}
func rpmEVRAhead(target, current string) bool {
if target == current {
return false
}
targetEpoch, targetVersion, targetRelease := splitRPMEVR(target)
currentEpoch, currentVersion, currentRelease := splitRPMEVR(current)
if targetEpoch != currentEpoch {
return targetEpoch > currentEpoch
}
if cmp := rpmVersionCompare(targetVersion, currentVersion); cmp != 0 {
return cmp > 0
}
if cmp := rpmVersionCompare(targetRelease, currentRelease); cmp != 0 {
return cmp > 0
}
return true
}
func splitRPMEVR(evr string) (epoch int, version string, release string) {
version = strings.TrimSpace(evr)
if before, after, ok := strings.Cut(version, ":"); ok {
if parsed, err := strconv.Atoi(before); err == nil {
epoch = parsed
version = after
}
}
if before, after, ok := strings.Cut(version, "-"); ok {
version = before
release = after
}
return epoch, version, release
}
func rpmVersionCompare(a, b string) int {
for a != "" || b != "" {
a = trimRPMSeparators(a)
b = trimRPMSeparators(b)
if a == "" || b == "" {
return compareRPMEmpty(a, b)
}
aDigit := isASCIIDigit(a[0])
bDigit := isASCIIDigit(b[0])
if aDigit != bDigit {
if aDigit {
return 1
}
return -1
}
var aSeg, bSeg string
aSeg, a = nextRPMSegment(a, aDigit)
bSeg, b = nextRPMSegment(b, bDigit)
if aDigit {
aSeg = strings.TrimLeft(aSeg, "0")
bSeg = strings.TrimLeft(bSeg, "0")
if len(aSeg) != len(bSeg) {
if len(aSeg) > len(bSeg) {
return 1
}
return -1
}
}
if aSeg != bSeg {
if aSeg > bSeg {
return 1
}
return -1
}
}
return 0
}
func trimRPMSeparators(s string) string {
return strings.TrimLeftFunc(s, func(r rune) bool {
return !((r >= 'A' && r <= 'Z') || (r >= 'a' && r <= 'z') || (r >= '0' && r <= '9'))
})
}
func compareRPMEmpty(a, b string) int {
if a == b {
return 0
}
if a != "" {
return 1
}
return -1
}
func nextRPMSegment(s string, digit bool) (segment string, rest string) {
for i := 0; i < len(s); i++ {
if isASCIIDigit(s[i]) != digit || !isASCIIAlnum(s[i]) {
return s[:i], s[i:]
}
}
return s, ""
}
func isASCIIDigit(b byte) bool {
return b >= '0' && b <= '9'
}
func isASCIIAlnum(b byte) bool {
return isASCIIDigit(b) || (b >= 'A' && b <= 'Z') || (b >= 'a' && b <= 'z')
}
func (h *UpdateHandler) resolveInstallTarget(update *models.UpdateState) (version string, explicit bool) {
if update.SelectedVersion != nil {
selected := strings.TrimSpace(*update.SelectedVersion)
@ -1007,11 +1116,10 @@ func (h *UpdateHandler) resolveInstallTarget(update *models.UpdateState) (versio
if gated := h.resolveGatedTarget(update); gated != "" {
return gated, true
}
return selected, true
}
if source != selectedVersionSourceInstalledHold {
return selected, true
// Fall through to return selected below
}
// Not an installed hold (or hold with no gated target) — use selected version.
return selected, true
}
}
if gated := h.resolveGatedTarget(update); gated != "" {

View file

@ -3,6 +3,7 @@ package queries
import (
"context"
"database/sql"
"encoding/json"
"fmt"
"time"
@ -418,6 +419,32 @@ func (q *AgentQueries) CreateSystemEvent(event *models.SystemEvent) error {
return nil
}
// CreateSecurityEvent inserts a security event into the security_events table.
func (q *AgentQueries) CreateSecurityEvent(event *models.SecurityEvent) error {
detailsJSON, _ := json.Marshal(event.Details)
metadataJSON, _ := json.Marshal(event.Metadata)
query := `
INSERT INTO security_events (timestamp, level, event_type, agent_id, message, trace_id, ip_address, details, metadata)
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9)`
_, err := q.db.Exec(query,
event.Timestamp,
event.Level,
event.EventType,
event.AgentID,
event.Message,
event.TraceID,
event.IPAddress,
detailsJSON,
metadataJSON,
)
if err != nil {
return fmt.Errorf("failed to create security event: %w", err)
}
return nil
}
// GetAgentEvents retrieves system events for an agent with optional severity filtering
func (q *AgentQueries) GetAgentEvents(agentID uuid.UUID, severity string, limit int) ([]models.SystemEvent, error) {
query := `

17
web/package-lock.json generated
View file

@ -1396,13 +1396,16 @@
"license": "MIT"
},
"node_modules/baseline-browser-mapping": {
"version": "2.8.16",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.8.16.tgz",
"integrity": "sha512-OMu3BGQ4E7P1ErFsIPpbJh0qvDudM/UuJeHgkAvfWe+0HFJCXh+t/l8L6fVLR55RI/UbKrVLnAXZSVwd9ysWYw==",
"version": "2.10.35",
"resolved": "https://registry.npmjs.org/baseline-browser-mapping/-/baseline-browser-mapping-2.10.35.tgz",
"integrity": "sha512-honAfLBde0HAFLdNyBEfuuENkF6zR+ozxqxa/2zJKHBe1qzLqyTSeRKpdPEHAP03rlDGyQOPnCSxnVpVqQo9Mg==",
"dev": true,
"license": "Apache-2.0",
"bin": {
"baseline-browser-mapping": "dist/cli.js"
"baseline-browser-mapping": "dist/cli.cjs"
},
"engines": {
"node": ">=6.0.0"
}
},
"node_modules/binary-extensions": {
@ -1509,9 +1512,9 @@
}
},
"node_modules/caniuse-lite": {
"version": "1.0.30001750",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001750.tgz",
"integrity": "sha512-cuom0g5sdX6rw00qOoLNSFCJ9/mYIsuSOA+yzpDw8eopiFqcVwQvZHqov0vmEighRxX++cfC0Vg1G+1Iy/mSpQ==",
"version": "1.0.30001797",
"resolved": "https://registry.npmjs.org/caniuse-lite/-/caniuse-lite-1.0.30001797.tgz",
"integrity": "sha512-l8xKG+gwAIExZGl9FrF7KUwuOmk6wbEPC9Xoy/RtnWv1XG0Q4LFlagaLpUv3Kiza3W/wm27zy0yWJEieYKAP6w==",
"dev": true,
"funding": [
{

View file

@ -1,31 +1,33 @@
import React, { useEffect } from 'react';
import React, { lazy, Suspense, useEffect } from 'react';
import { Routes, Route, Navigate } from 'react-router-dom';
import { Toaster } from 'react-hot-toast';
import { useAuthStore, useUIStore } from '@/lib/store';
import { authApi } from '@/lib/api';
import ErrorBoundary from '@/components/ErrorBoundary';
import Layout from '@/components/Layout';
import Dashboard from '@/pages/Dashboard';
import Agents from '@/pages/Agents';
import Updates from '@/pages/Updates';
import PackageDetail from '@/pages/PackageDetail';
import Docker from '@/pages/Docker';
import LiveOperations from '@/pages/LiveOperations';
import History from '@/pages/History';
import Settings from '@/pages/Settings';
import TokenManagement from '@/pages/TokenManagement';
import RateLimiting from '@/pages/RateLimiting';
import AgentManagement from '@/pages/settings/AgentManagement';
import MaintenanceWindows from '@/pages/settings/MaintenanceWindows';
import UpstreamTracking from '@/pages/settings/UpstreamTracking';
import General from '@/pages/settings/General';
import AgentPolling from '@/pages/settings/AgentPolling';
import SecuritySettings from '@/pages/SecuritySettings';
import Login from '@/pages/Login';
import Setup from '@/pages/Setup';
import { WelcomeChecker } from '@/components/WelcomeChecker';
import { SetupCompletionChecker } from '@/components/SetupCompletionChecker';
const Dashboard = lazy(() => import('@/pages/Dashboard'));
const Agents = lazy(() => import('@/pages/Agents'));
const Updates = lazy(() => import('@/pages/Updates'));
const PackageDetail = lazy(() => import('@/pages/PackageDetail'));
const Docker = lazy(() => import('@/pages/Docker'));
const LiveOperations = lazy(() => import('@/pages/LiveOperations'));
const History = lazy(() => import('@/pages/History'));
const Settings = lazy(() => import('@/pages/Settings'));
const TokenManagement = lazy(() => import('@/pages/TokenManagement'));
const RateLimiting = lazy(() => import('@/pages/RateLimiting'));
const AgentManagement = lazy(() => import('@/pages/settings/AgentManagement'));
const MaintenanceWindows = lazy(() => import('@/pages/settings/MaintenanceWindows'));
const UpstreamTracking = lazy(() => import('@/pages/settings/UpstreamTracking'));
const General = lazy(() => import('@/pages/settings/General'));
const AgentPolling = lazy(() => import('@/pages/settings/AgentPolling'));
const ProcessExplorer = lazy(() => import('@/pages/settings/ProcessExplorer'));
const SecuritySettings = lazy(() => import('@/pages/SecuritySettings'));
const Login = lazy(() => import('@/pages/Login'));
const Setup = lazy(() => import('@/pages/Setup'));
// Protected route component
const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) => {
const { isAuthenticated } = useAuthStore();
@ -37,6 +39,12 @@ const ProtectedRoute: React.FC<{ children: React.ReactNode }> = ({ children }) =
return <>{children}</>;
};
const RouteFallback: React.FC = () => (
<div className="min-h-screen bg-gray-50 flex items-center justify-center">
<div className="animate-spin rounded-full h-10 w-10 border-b-2 border-indigo-600" />
</div>
);
const App: React.FC = () => {
const { isAuthenticated, token } = useAuthStore();
const { theme } = useUIStore();
@ -108,63 +116,66 @@ const App: React.FC = () => {
{/* App routes */}
<Routes>
{/* Setup route - shown when server needs configuration */}
<Route
path="/setup"
element={
<SetupCompletionChecker>
<Setup />
</SetupCompletionChecker>
}
/>
<Suspense fallback={<RouteFallback />}>
<Routes>
{/* Setup route - shown when server needs configuration */}
<Route
path="/setup"
element={
<SetupCompletionChecker>
<Setup />
</SetupCompletionChecker>
}
/>
{/* Login route */}
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
/>
{/* Login route */}
<Route
path="/login"
element={isAuthenticated ? <Navigate to="/" replace /> : <Login />}
/>
{/* Protected routes */}
<Route
path="/*"
element={
<WelcomeChecker>
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/agents" element={<Agents />} />
<Route path="/agents/:id" element={<Agents />} />
<Route path="/updates" element={<Updates />} />
<Route path="/updates/:id" element={<Updates />} />
<Route path="/updates/package/:type/:name" element={<PackageDetail />} />
<Route path="/docker" element={<Docker />} />
<Route path="/live" element={<Navigate to="/staging" replace />} />
<Route path="/staging" element={<LiveOperations />} />
<Route path="/history" element={<History />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/general" element={<General />} />
<Route path="/settings/tokens" element={<TokenManagement />} />
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
<Route path="/settings/agents" element={<AgentManagement />} />
<Route path="/settings/polling" element={<AgentPolling />} />
<Route path="/settings/security" element={<SecuritySettings />} />
<Route path="/settings/security/:tab" element={<SecuritySettings />} />
<Route path="/settings/maintenance-windows" element={<MaintenanceWindows />} />
<Route path="/settings/upstream" element={<UpstreamTracking />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</ProtectedRoute>
</WelcomeChecker>
}
/>
</Routes>
{/* Protected routes */}
<Route
path="/*"
element={
<WelcomeChecker>
<ProtectedRoute>
<Layout>
<Routes>
<Route path="/" element={<Dashboard />} />
<Route path="/dashboard" element={<Dashboard />} />
<Route path="/agents" element={<Agents />} />
<Route path="/agents/:id" element={<Agents />} />
<Route path="/updates" element={<Updates />} />
<Route path="/updates/:id" element={<Updates />} />
<Route path="/updates/package/:type/:name" element={<PackageDetail />} />
<Route path="/docker" element={<Docker />} />
<Route path="/live" element={<Navigate to="/staging" replace />} />
<Route path="/staging" element={<LiveOperations />} />
<Route path="/history" element={<History />} />
<Route path="/settings" element={<Settings />} />
<Route path="/settings/general" element={<General />} />
<Route path="/settings/tokens" element={<TokenManagement />} />
<Route path="/settings/rate-limiting" element={<RateLimiting />} />
<Route path="/settings/agents" element={<AgentManagement />} />
<Route path="/settings/polling" element={<AgentPolling />} />
<Route path="/settings/security" element={<SecuritySettings />} />
<Route path="/settings/security/:tab" element={<SecuritySettings />} />
<Route path="/settings/maintenance-windows" element={<MaintenanceWindows />} />
<Route path="/settings/upstream" element={<UpstreamTracking />} />
<Route path="/settings/process-explorer" element={<ProcessExplorer />} />
<Route path="*" element={<Navigate to="/" replace />} />
</Routes>
</Layout>
</ProtectedRoute>
</WelcomeChecker>
}
/>
</Routes>
</Suspense>
</div>
</ErrorBoundary>
);
};
export default App;
export default App;

View file

@ -1,8 +1,8 @@
import React, { useState, useMemo } from 'react';
import { Activity, Search, RefreshCw, ArrowUpDown, Clock, Users } from 'lucide-react';
import { Activity, Search, RefreshCw, ArrowUpDown } from 'lucide-react';
import { useProcessSnapshot, useTriggerProcessScan } from '@/hooks/useProcesses';
import { ProcessDetailModal } from '@/components/ProcessDetailModal';
import type { Process, ProcessFilter } from '@/types/process';
import type { ProcessFilter } from '@/types/process';
import { cn } from '@/lib/utils';
interface ProcessesTabProps {
@ -61,7 +61,7 @@ export const ProcessesTab: React.FC<ProcessesTabProps> = ({ agentId }) => {
return `${(ms / 1000).toFixed(1)}s`;
};
const SortHeader: React.FC<{ label; col: ProcessFilter['sort_by']; className?: string }> = ({
const SortHeader: React.FC<{ label: string; col: ProcessFilter['sort_by']; className?: string }> = ({
label,
col,
className,

View file

@ -0,0 +1,72 @@
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
import api from '../lib/api'
// Process explorer data collection caps.
// Stored server-side in the security_settings store under 'operational' category.
// Delivered to agents via the config endpoint so changes propagate fleet-wide.
export interface ProcessExplorerSettings {
max_open_files: number
max_sockets: number
max_pipes: number
max_memory_map: number
max_namespaces: number
max_env_keys: number
max_listening_ports: number
}
export const PROCESS_EXPLORER_DEFAULTS: ProcessExplorerSettings = {
max_open_files: 2000,
max_sockets: 500,
max_pipes: 500,
max_memory_map: 2000,
max_namespaces: 50,
max_env_keys: 200,
max_listening_ports: 100,
}
const toNumber = (v: unknown, fallback: number): number => {
const n = typeof v === 'string' ? parseInt(v, 10) : (v as number)
return Number.isFinite(n) && n >= 0 ? (n as number) : fallback
}
export function useProcessExplorerSettings() {
return useQuery({
queryKey: ['process-explorer-settings'],
queryFn: async (): Promise<ProcessExplorerSettings> => {
const { data } = await api.get('/security/settings')
const op = data?.settings?.operational ?? {}
return {
max_open_files: toNumber(op.process_explorer_max_open_files, PROCESS_EXPLORER_DEFAULTS.max_open_files),
max_sockets: toNumber(op.process_explorer_max_sockets, PROCESS_EXPLORER_DEFAULTS.max_sockets),
max_pipes: toNumber(op.process_explorer_max_pipes, PROCESS_EXPLORER_DEFAULTS.max_pipes),
max_memory_map: toNumber(op.process_explorer_max_memory_map, PROCESS_EXPLORER_DEFAULTS.max_memory_map),
max_namespaces: toNumber(op.process_explorer_max_namespaces, PROCESS_EXPLORER_DEFAULTS.max_namespaces),
max_env_keys: toNumber(op.process_explorer_max_env_keys, PROCESS_EXPLORER_DEFAULTS.max_env_keys),
max_listening_ports: toNumber(op.process_explorer_max_listening_ports, PROCESS_EXPLORER_DEFAULTS.max_listening_ports),
}
},
})
}
export interface ProcessExplorerUpdate {
key: keyof ProcessExplorerSettings
value: number
reason?: string
}
export function useUpdateProcessExplorerSetting() {
const queryClient = useQueryClient()
return useMutation({
mutationFn: async ({ key, value, reason }: ProcessExplorerUpdate): Promise<void> => {
// Keys are prefixed with "process_explorer_" in the security_settings store.
await api.put(`/security/settings/operational/process_explorer_${key}`, {
value,
reason: reason || 'Updated via Process Explorer settings',
})
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['process-explorer-settings'] })
},
})
}

View file

@ -1,4 +1,5 @@
import axios, { AxiosResponse } from 'axios';
import { useAuthStore } from './store';
import {
Agent,
UpdatePackage,
@ -76,7 +77,6 @@ api.interceptors.response.use(
const url: string = error.config?.url || '';
const isAuthEndpoint = url.includes('/auth/');
if (!isAuthEndpoint) {
const { useAuthStore } = await import('./store');
useAuthStore.getState().logout();
}
}
@ -610,7 +610,10 @@ export const setupApi = {
dbPassword: string;
serverHost: string;
serverPort: string;
publicURL: string;
maxSeats: string;
signingPrivateKey?: string;
signingPublicKey?: string;
}): Promise<{ message: string; jwtSecret?: string; envContent?: string; manualRestartRequired?: boolean; manualRestartCommand?: string; configFilePath?: string }> => {
const response = await setupApiInstance.post('/setup/configure', config);
return response.data;
@ -1165,4 +1168,4 @@ export const storageMetricsApi = {
// Named export for api instance
export { api };
export default api;
export default api;

View file

@ -125,6 +125,18 @@ const Settings: React.FC = () => {
<h3 className="font-semibold text-gray-900">Upstream Tracking</h3>
<p className="text-sm text-gray-600 mt-1">Compare deployed versions to canonical upstream releases</p>
</Link>
<Link
to="/settings/process-explorer"
className="card block hover:border-cyan-300 hover:shadow-sm transition-all"
>
<div className="flex items-center justify-between mb-4">
<Activity className="w-8 h-8 text-cyan-600" />
<ArrowRight className="w-5 h-5 text-gray-400" />
</div>
<h3 className="font-semibold text-gray-900">Process Explorer</h3>
<p className="text-sm text-gray-600 mt-1">Data collection caps for process drill-down scans</p>
</Link>
</div>
{/* Overview Statistics */}

View file

@ -15,6 +15,7 @@ interface SetupFormData {
dbPassword: string;
serverHost: string;
serverPort: string;
publicURL: string;
maxSeats: string;
}
@ -36,7 +37,6 @@ const Setup: React.FC = () => {
const [showDbPassword, setShowDbPassword] = useState(false);
const [signingKeys, setSigningKeys] = useState<SigningKeys | null>(null);
const [generatingKeys, setGeneratingKeys] = useState(false);
const [configType, setConfigType] = useState<'env' | 'swarm'>('env');
const [formData, setFormData] = useState<SetupFormData>({
adminUser: 'admin',
@ -48,6 +48,7 @@ const Setup: React.FC = () => {
dbPassword: 'redflag',
serverHost: '0.0.0.0',
serverPort: '8080',
publicURL: typeof window !== 'undefined' ? window.location.origin : '',
maxSeats: '50',
});
@ -131,6 +132,20 @@ const Setup: React.FC = () => {
setError('Server port must be between 1 and 65535');
return false;
}
if (!formData.publicURL.trim()) {
setError('Agent-facing server URL is required');
return false;
}
try {
const publicURL = new URL(formData.publicURL);
if (!['http:', 'https:'].includes(publicURL.protocol)) {
setError('Agent-facing server URL must start with http:// or https://');
return false;
}
} catch {
setError('Agent-facing server URL must be a valid URL');
return false;
}
const maxSeats = parseInt(formData.maxSeats);
if (isNaN(maxSeats) || maxSeats <= 0) {
setError('Maximum agent seats must be greater than 0');
@ -151,14 +166,13 @@ const Setup: React.FC = () => {
setIsLoading(true);
try {
const result = await setupApi.configure(formData);
const result = await setupApi.configure({
...formData,
signingPrivateKey: signingKeys?.private_key || '',
signingPublicKey: signingKeys?.public_key || '',
});
let configContent = '';
if (configType === 'env') {
configContent = generateEnvContent(result, signingKeys);
} else {
configContent = generateDockerSecretCommands(result, signingKeys);
}
const configContent = generateEnvContent(result);
setEnvContent(configContent || null);
setShowSuccess(true);
@ -174,56 +188,12 @@ const Setup: React.FC = () => {
}
};
const generateEnvContent = (result: any, _keys: SigningKeys | null): string => {
const generateEnvContent = (result: any): string => {
// Server-side createSharedEnvContentForDisplay already embeds the signing
// keys in envContent — appending here would duplicate the key (BUG-006).
return result.envContent || '';
};
const generateDockerSecretCommands = (result: any, keys: SigningKeys | null): string => {
if (!result.envContent) return '';
// Parse the envContent to extract values
const envLines = result.envContent.split('\n');
const envVars: Record<string, string> = {};
envLines.forEach((line: string) => {
const match = line.match(/^([^#=]+)=(.+)$/);
if (match) {
envVars[match[1].trim()] = match[2].trim();
}
});
// Add signing keys if available
if (keys) {
envVars['REDFLAG_SIGNING_PRIVATE_KEY'] = keys.private_key;
}
// Generate Docker secret commands
const commands = [
'# RedFlag Docker Secrets Configuration',
'# Generated by web setup on 2025-12-13',
'# [WARNING] SECURITY CRITICAL: Backup the signing key or you will lose access to all agents',
'#',
'# Run these commands on your Docker host to create the secrets:',
'#',
`printf '%s' '${envVars['REDFLAG_ADMIN_PASSWORD'] || ''}' | docker secret create redflag_admin_password -`,
`printf '%s' '${envVars['REDFLAG_JWT_SECRET'] || ''}' | docker secret create redflag_jwt_secret -`,
`printf '%s' '${envVars['REDFLAG_DB_PASSWORD'] || ''}' | docker secret create redflag_db_password -`,
`printf '%s' '${envVars['REDFLAG_SIGNING_PRIVATE_KEY'] || ''}' | docker secret create redflag_signing_private_key -`,
'',
'# After creating the secrets, restart your RedFlag server:',
'# docker compose down && docker compose up -d',
'',
'# Optional: Save these values securely (password manager, encrypted storage)',
`# Admin Password: ${envVars['REDFLAG_ADMIN_PASSWORD'] || ''}`,
`# JWT Secret: ${envVars['REDFLAG_JWT_SECRET'] || ''}`,
`# DB Password: ${envVars['REDFLAG_DB_PASSWORD'] || ''}`,
].join('\n');
return commands;
};
// Success screen with configuration display
if (showSuccess && envContent) {
return (
@ -271,21 +241,9 @@ const Setup: React.FC = () => {
{/* Configuration Content Section */}
{envContent && (
<div className="mb-6">
<div className="flex items-center justify-between mb-3">
<h3 className="text-lg font-semibold text-gray-900">
{configType === 'env' ? 'Environment Configuration (.env)' : 'Docker Swarm Secrets'}
</h3>
<div className="flex items-center space-x-2">
<span className="text-sm text-gray-600">.env</span>
<button
onClick={() => setConfigType(configType === 'env' ? 'swarm' : 'env')}
className={`toggle ${configType === 'swarm' ? 'toggle-on' : 'toggle-off'}`}
>
<span className={`toggle-knob ${configType === 'swarm' ? 'toggle-knob-on' : 'toggle-knob-off'}`} />
</button>
<span className="text-sm text-gray-600">Swarm</span>
</div>
</div>
<h3 className="text-lg font-semibold text-gray-900 mb-3">
Environment Configuration (.env)
</h3>
<div className="bg-gray-50 border border-gray-200 rounded-md p-4">
<textarea
@ -295,51 +253,25 @@ const Setup: React.FC = () => {
/>
</div>
{configType === 'env' ? (
<>
<button
onClick={() => {
navigator.clipboard.writeText(envContent);
toast.success('.env content copied to clipboard!');
}}
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Copy .env Content
</button>
<div className="mt-3 alert alert-info rounded-md p-3">
<p className="text-sm text-blue-800">
<strong>Next Steps:</strong> Save this content to <code className="code code-info">config/.env</code> and run <code className="code code-info">docker compose down && docker compose up -d</code> to apply the configuration.
</p>
</div>
<div className="mt-3 alert alert-warning rounded-md p-3">
<p className="text-sm text-yellow-800">
<strong>Security Note:</strong> The <code className="code code-warning">config/.env</code> file contains sensitive credentials. Ensure it has restricted permissions (<code className="code code-warning">chmod 600</code>) and is excluded from version control.
</p>
</div>
</>
) : (
<>
<button
onClick={() => {
navigator.clipboard.writeText(envContent);
toast.success('Docker secret commands copied to clipboard!');
}}
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Copy Docker Secret Commands
</button>
<div className="mt-3 alert alert-info rounded-md p-3">
<p className="text-sm text-blue-800">
<strong>Requirements:</strong> Docker Swarm mode is required. Run <code className="code code-info">docker swarm init</code> on your Docker host before creating secrets.
</p>
</div>
<div className="mt-3 alert alert-warning rounded-md p-3">
<p className="text-sm text-yellow-800">
<strong>Next Steps:</strong> Run the copied commands on your Docker host, then update <code className="code code-warning">docker-compose.yml</code> to mount the secrets and restart.
</p>
</div>
</>
)}
<button
onClick={() => {
navigator.clipboard.writeText(envContent);
toast.success('.env content copied to clipboard!');
}}
className="mt-3 w-full flex justify-center py-2 px-4 border border-transparent rounded-md text-sm font-medium text-white bg-green-600 hover:bg-green-700 focus:outline-none focus:ring-2 focus:ring-offset-2 focus:ring-green-500"
>
Copy .env Content
</button>
<div className="mt-3 alert alert-info rounded-md p-3">
<p className="text-sm text-blue-800">
<strong>Next Steps:</strong> Save this content to <code className="code code-info">config/.env</code> and run <code className="code code-info">docker compose down && docker compose up -d</code> to apply the configuration.
</p>
</div>
<div className="mt-3 alert alert-warning rounded-md p-3">
<p className="text-sm text-yellow-800">
<strong>Security Note:</strong> The <code className="code code-warning">config/.env</code> file contains sensitive credentials. Ensure it has restricted permissions (<code className="code code-warning">chmod 600</code>) and is excluded from version control.
</p>
</div>
</div>
)}
@ -347,23 +279,12 @@ const Setup: React.FC = () => {
{/* Next Steps */}
<div className="border-t border-gray-200 pt-6">
<h3 className="text-lg font-semibold text-gray-900 mb-3">Next Steps</h3>
{configType === 'env' ? (
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600">
<li>Copy the .env content using the green button above</li>
<li>Save it to <code className="code code-neutral">config/.env</code></li>
<li>Run <code className="code code-neutral">docker compose down && docker compose up -d</code></li>
<li>Login to the dashboard with your admin username and password</li>
</ol>
) : (
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600">
<li>Initialize Docker Swarm: <code className="code code-neutral">docker swarm init</code></li>
<li>Copy the Docker secret commands using the green button above</li>
<li>Run the commands on your Docker host to create the secrets</li>
<li>Update <code className="code code-neutral">docker-compose.yml</code> to mount the secrets</li>
<li>Restart RedFlag with <code className="code code-neutral">docker compose down && docker compose up -d</code></li>
<li>Login to the dashboard with your admin username and password</li>
</ol>
)}
<ol className="list-decimal list-inside space-y-2 text-sm text-gray-600">
<li>Copy the .env content using the green button above</li>
<li>Save it to <code className="code code-neutral">config/.env</code></li>
<li>Run <code className="code code-neutral">docker compose down && docker compose up -d</code></li>
<li>Login to the dashboard with your admin username and password</li>
</ol>
</div>
<div className="mt-6 pt-6 border-t border-gray-200 space-y-3">
@ -691,6 +612,22 @@ const Setup: React.FC = () => {
/>
<p className="mt-1 text-xs text-gray-500">Security limit for agent registration</p>
</div>
<div className="sm:col-span-2">
<label htmlFor="publicURL" className="block text-sm font-medium text-gray-700 mb-1">
Agent-facing Server URL
</label>
<input
type="url"
id="publicURL"
name="publicURL"
value={formData.publicURL}
onChange={handleInputChange}
className="block w-full px-3 py-2 border border-gray-300 rounded-md shadow-sm placeholder-gray-400 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-indigo-500 sm:text-sm"
placeholder="http://redflag.example.com:8080"
required
/>
<p className="mt-1 text-xs text-gray-500">Used in generated install commands and agent callbacks</p>
</div>
</div>
</div>
@ -721,4 +658,4 @@ const Setup: React.FC = () => {
);
};
export default Setup;
export default Setup;

View file

@ -0,0 +1,236 @@
import React from 'react';
import { Activity, Save } from 'lucide-react';
import {
useProcessExplorerSettings,
useUpdateProcessExplorerSetting,
PROCESS_EXPLORER_DEFAULTS,
ProcessExplorerSettings,
} from '../../hooks/useProcessExplorer';
interface Field {
key: keyof ProcessExplorerSettings;
label: string;
unit: string;
min: number;
max: number;
description: string;
}
const FIELDS: Field[] = [
{
key: 'max_open_files',
label: 'Open Files Cap',
unit: 'entries',
min: 0,
max: 50000,
description:
'Maximum open file descriptors returned per process. Set to 0 for no cap. Processes like databases may have thousands of open files.',
},
{
key: 'max_sockets',
label: 'Sockets Cap',
unit: 'entries',
min: 0,
max: 50000,
description:
'Maximum open sockets (TCP/UDP/UNIX) returned per process. Set to 0 for no cap.',
},
{
key: 'max_pipes',
label: 'Pipes Cap',
unit: 'entries',
min: 0,
max: 50000,
description:
'Maximum open pipes returned per process. Set to 0 for no cap.',
},
{
key: 'max_memory_map',
label: 'Memory Map Cap',
unit: 'entries',
min: 0,
max: 100000,
description:
'Maximum memory-mapped regions returned per process. Chrome can have 20,000+ entries. Set to 0 for no cap.',
},
{
key: 'max_namespaces',
label: 'Namespaces Cap',
unit: 'entries',
min: 0,
max: 1000,
description:
'Maximum Linux namespaces returned per process. Typically under 20. Set to 0 for no cap.',
},
{
key: 'max_env_keys',
label: 'Environment Keys Cap',
unit: 'keys',
min: 0,
max: 10000,
description:
'Maximum environment variable key names returned per process. Values are never transmitted (security). Set to 0 for no cap.',
},
{
key: 'max_listening_ports',
label: 'Listening Ports Cap',
unit: 'ports',
min: 0,
max: 10000,
description:
'Maximum TCP listening ports returned per process. Set to 0 for no cap.',
},
];
const ProcessExplorer: React.FC = () => {
const { data, isLoading } = useProcessExplorerSettings();
const updateSetting = useUpdateProcessExplorerSetting();
const [draft, setDraft] = React.useState<ProcessExplorerSettings>(PROCESS_EXPLORER_DEFAULTS);
const [saved, setSaved] = React.useState(false);
const [errors, setErrors] = React.useState<Partial<Record<keyof ProcessExplorerSettings, string>>>({});
React.useEffect(() => {
if (data) setDraft(data);
}, [data]);
const validate = (key: keyof ProcessExplorerSettings, value: number): string | null => {
const field = FIELDS.find((f) => f.key === key);
if (!field) return null;
if (!Number.isFinite(value) || value < field.min) return `Must be at least ${field.min}`;
if (value > field.max) return `Must be at most ${field.max}`;
return null;
};
const handleChange = (key: keyof ProcessExplorerSettings, raw: string) => {
const value = raw === '' ? 0 : parseInt(raw, 10);
setDraft((prev) => ({ ...prev, [key]: Number.isFinite(value) ? value : 0 }));
setErrors((prev) => ({ ...prev, [key]: validate(key, value) }));
setSaved(false);
};
const handleSave = async (key: keyof ProcessExplorerSettings) => {
const value = draft[key];
const error = validate(key, value);
if (error) return;
try {
await updateSetting.mutateAsync({ key, value });
setSaved(true);
} catch {
setErrors((prev) => ({ ...prev, [key]: 'Failed to save' }));
}
};
const handleSaveAll = async () => {
const allErrors: typeof errors = {};
for (const field of FIELDS) {
const error = validate(field.key, draft[field.key]);
if (error) allErrors[field.key] = error;
}
if (Object.keys(allErrors).length > 0) {
setErrors(allErrors);
return;
}
try {
await Promise.all(
FIELDS.map((field) =>
updateSetting.mutateAsync({ key: field.key, value: draft[field.key] })
)
);
setSaved(true);
} catch {
// individual errors shown inline
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center p-8 text-muted-foreground">
Loading process explorer settings...
</div>
);
}
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h3 className="text-lg font-semibold flex items-center gap-2">
<Activity className="h-5 w-5" />
Process Explorer
</h3>
<p className="text-sm text-muted-foreground mt-1">
Control how much data the agent collects per process during drill-down scans.
Set to 0 to disable a cap. Changes propagate to all agents on their next check-in.
</p>
</div>
<button
onClick={handleSaveAll}
className="inline-flex items-center gap-2 px-4 py-2 bg-primary text-primary-foreground rounded-md hover:bg-primary/90 text-sm"
>
<Save className="h-4 w-4" />
Save All
</button>
</div>
{saved && (
<div className="text-sm text-green-600 bg-green-50 border border-green-200 rounded-md p-3">
Settings saved. Agents will pick up changes on their next check-in.
</div>
)}
<div className="grid gap-4">
{FIELDS.map((field) => (
<div
key={field.key}
className="border rounded-lg p-4 space-y-2"
>
<div className="flex items-center justify-between">
<div>
<label className="text-sm font-medium">{field.label}</label>
<p className="text-xs text-muted-foreground mt-0.5">
{field.description}
</p>
</div>
<div className="flex items-center gap-2">
<input
type="number"
min={field.min}
max={field.max}
value={draft[field.key]}
onChange={(e) => handleChange(field.key, e.target.value)}
className="w-24 px-3 py-1.5 border rounded-md text-sm text-right"
/>
<span className="text-xs text-muted-foreground w-12">{field.unit}</span>
<button
onClick={() => handleSave(field.key)}
className="px-3 py-1.5 text-xs border rounded-md hover:bg-accent"
>
Save
</button>
</div>
</div>
{errors[field.key] && (
<p className="text-xs text-destructive">{errors[field.key]}</p>
)}
</div>
))}
</div>
<div className="text-xs text-muted-foreground border-t pt-4">
<p>
<strong>How it works:</strong> These caps are stored on the server and delivered to agents
via the config endpoint. When an agent performs a process drill-down scan, it respects
these limits to prevent multi-megabyte responses for processes with thousands of open
files or memory map entries.
</p>
<p className="mt-1">
<strong>Default values</strong> are tuned for typical server workloads. Set a cap to 0
to disable it (no limit, but the underlying /proc data is still bounded by the kernel).
</p>
</div>
</div>
);
};
export default ProcessExplorer;