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
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