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:
parent
821bc00099
commit
2da1e92fe9
16 changed files with 849 additions and 283 deletions
BIN
Screenshots/Overview.png
Normal file
BIN
Screenshots/Overview.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 129 KiB |
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
66
server/internal/api/handlers/setup_keys_test.go
Normal file
66
server/internal/api/handlers/setup_keys_test.go
Normal 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")
|
||||
}
|
||||
}
|
||||
|
|
@ -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 != "" {
|
||||
|
|
|
|||
|
|
@ -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
17
web/package-lock.json
generated
|
|
@ -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": [
|
||||
{
|
||||
|
|
|
|||
155
web/src/App.tsx
155
web/src/App.tsx
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
72
web/src/hooks/useProcessExplorer.ts
Normal file
72
web/src/hooks/useProcessExplorer.ts
Normal 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'] })
|
||||
},
|
||||
})
|
||||
}
|
||||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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 */}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
236
web/src/pages/settings/ProcessExplorer.tsx
Normal file
236
web/src/pages/settings/ProcessExplorer.tsx
Normal 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;
|
||||
Loading…
Reference in a new issue