chore: remove generated hub index files from repo
This commit is contained in:
@@ -5,6 +5,7 @@ import { Card } from '../components/ui/Card'
|
||||
import { Input } from '../components/ui/Input'
|
||||
import { Switch } from '../components/ui/Switch'
|
||||
import { getSecurityStatus } from '../api/security'
|
||||
import { getFeatureFlags } from '../api/featureFlags'
|
||||
import { exportCrowdsecConfig, importCrowdsecConfig, listCrowdsecFiles, readCrowdsecFile, writeCrowdsecFile, listCrowdsecDecisions, banIP, unbanIP, CrowdSecDecision } from '../api/crowdsec'
|
||||
import { listCrowdsecPresets, pullCrowdsecPreset, applyCrowdsecPreset, getCrowdsecPresetCache } from '../api/presets'
|
||||
import { createBackup } from '../api/backups'
|
||||
@@ -12,12 +13,15 @@ import { updateSetting } from '../api/settings'
|
||||
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query'
|
||||
import { toast } from '../utils/toast'
|
||||
import { ConfigReloadOverlay } from '../components/LoadingStates'
|
||||
import { Shield, ShieldOff, Trash2 } from 'lucide-react'
|
||||
import { Shield, ShieldOff, Trash2, Search } from 'lucide-react'
|
||||
import { buildCrowdsecExportFilename, downloadCrowdsecExport, promptCrowdsecFilename } from '../utils/crowdsecExport'
|
||||
import { CROWDSEC_PRESETS, CrowdsecPreset } from '../data/crowdsecPresets'
|
||||
import { useConsoleStatus, useEnrollConsole } from '../hooks/useConsoleEnrollment'
|
||||
|
||||
export default function CrowdSecConfig() {
|
||||
const { data: status, isLoading, error } = useQuery({ queryKey: ['security-status'], queryFn: getSecurityStatus })
|
||||
const [searchQuery, setSearchQuery] = useState('')
|
||||
const [sortBy, setSortBy] = useState<'alpha' | 'type' | 'source'>('alpha')
|
||||
const [file, setFile] = useState<File | null>(null)
|
||||
const [selectedPath, setSelectedPath] = useState<string | null>(null)
|
||||
const [fileContent, setFileContent] = useState<string | null>(null)
|
||||
@@ -34,6 +38,15 @@ export default function CrowdSecConfig() {
|
||||
const [applyInfo, setApplyInfo] = useState<{ status?: string; backup?: string; reloadHint?: boolean; usedCscli?: boolean; cacheKey?: string } | null>(null)
|
||||
const queryClient = useQueryClient()
|
||||
const isLocalMode = !!status && status.crowdsec?.mode !== 'disabled'
|
||||
const { data: featureFlags } = useQuery({ queryKey: ['feature-flags'], queryFn: getFeatureFlags })
|
||||
const consoleEnrollmentEnabled = Boolean(featureFlags?.['feature.crowdsec.console_enrollment'])
|
||||
const [enrollmentToken, setEnrollmentToken] = useState('')
|
||||
const [consoleTenant, setConsoleTenant] = useState('')
|
||||
const [consoleAgentName, setConsoleAgentName] = useState((typeof window !== 'undefined' && window.location?.hostname) || 'charon-agent')
|
||||
const [consoleAck, setConsoleAck] = useState(false)
|
||||
const [consoleErrors, setConsoleErrors] = useState<{ token?: string; agent?: string; tenant?: string; ack?: string; submit?: string }>({})
|
||||
const consoleStatusQuery = useConsoleStatus(consoleEnrollmentEnabled)
|
||||
const enrollConsoleMutation = useEnrollConsole()
|
||||
|
||||
const backupMutation = useMutation({ mutationFn: () => createBackup() })
|
||||
const importMutation = useMutation({
|
||||
@@ -107,6 +120,35 @@ export default function CrowdSecConfig() {
|
||||
return CROWDSEC_PRESETS.map((preset) => ({ ...preset, requiresHub: false, available: true, cached: false, source: 'charon-curated' }))
|
||||
}, [presetsQuery.data])
|
||||
|
||||
const filteredPresets = useMemo(() => {
|
||||
let result = [...presetCatalog]
|
||||
|
||||
if (searchQuery) {
|
||||
const query = searchQuery.toLowerCase()
|
||||
result = result.filter(
|
||||
(p) =>
|
||||
p.title.toLowerCase().includes(query) ||
|
||||
p.description?.toLowerCase().includes(query) ||
|
||||
p.slug.toLowerCase().includes(query)
|
||||
)
|
||||
}
|
||||
|
||||
result.sort((a, b) => {
|
||||
if (sortBy === 'alpha') {
|
||||
return a.title.localeCompare(b.title)
|
||||
}
|
||||
if (sortBy === 'source') {
|
||||
const sourceA = a.source || 'z'
|
||||
const sourceB = b.source || 'z'
|
||||
if (sourceA !== sourceB) return sourceA.localeCompare(sourceB)
|
||||
return a.title.localeCompare(b.title)
|
||||
}
|
||||
return a.title.localeCompare(b.title)
|
||||
})
|
||||
|
||||
return result
|
||||
}, [presetCatalog, searchQuery, sortBy])
|
||||
|
||||
useEffect(() => {
|
||||
if (!presetCatalog.length) return
|
||||
if (!selectedPresetSlug || !presetCatalog.some((preset) => preset.slug === selectedPresetSlug)) {
|
||||
@@ -114,6 +156,15 @@ export default function CrowdSecConfig() {
|
||||
}
|
||||
}, [presetCatalog, selectedPresetSlug])
|
||||
|
||||
useEffect(() => {
|
||||
if (consoleStatusQuery.data?.agent_name) {
|
||||
setConsoleAgentName((prev) => prev || consoleStatusQuery.data?.agent_name || prev)
|
||||
}
|
||||
if (consoleStatusQuery.data?.tenant) {
|
||||
setConsoleTenant((prev) => prev || consoleStatusQuery.data?.tenant || prev)
|
||||
}
|
||||
}, [consoleStatusQuery.data?.agent_name, consoleStatusQuery.data?.tenant])
|
||||
|
||||
const selectedPreset = presetCatalog.find((preset) => preset.slug === selectedPresetSlug)
|
||||
const selectedPresetRequiresHub = selectedPreset?.requiresHub ?? false
|
||||
|
||||
@@ -174,6 +225,67 @@ export default function CrowdSecConfig() {
|
||||
}
|
||||
}
|
||||
|
||||
const normalizedConsoleStatus = consoleStatusQuery.data?.status === 'failed' ? 'degraded' : consoleStatusQuery.data?.status || 'not_enrolled'
|
||||
const isConsoleDegraded = normalizedConsoleStatus === 'degraded'
|
||||
const isConsolePending = enrollConsoleMutation.isPending || normalizedConsoleStatus === 'enrolling'
|
||||
const consoleStatusLabel = normalizedConsoleStatus.replace('_', ' ')
|
||||
const consoleTokenState = consoleStatusQuery.data ? (consoleStatusQuery.data.key_present ? 'Stored (masked)' : 'Not stored') : '—'
|
||||
const canRotateKey = normalizedConsoleStatus === 'enrolled' || normalizedConsoleStatus === 'degraded'
|
||||
const consoleDocsHref = 'https://wikid82.github.io/charon/security/'
|
||||
|
||||
const sanitizeSecret = (msg: string) => msg.replace(/\b[A-Za-z0-9]{10,64}\b/g, '***')
|
||||
|
||||
const sanitizeErrorMessage = (err: unknown) => {
|
||||
if (isAxiosError(err)) {
|
||||
return sanitizeSecret(err.response?.data?.error || err.message)
|
||||
}
|
||||
if (err instanceof Error) return sanitizeSecret(err.message)
|
||||
return 'Console enrollment failed'
|
||||
}
|
||||
|
||||
const validateConsoleEnrollment = (options?: { allowMissingTenant?: boolean; requireAck?: boolean }) => {
|
||||
const nextErrors: { token?: string; agent?: string; tenant?: string; ack?: string } = {}
|
||||
if (!enrollmentToken.trim()) {
|
||||
nextErrors.token = 'Enrollment token is required'
|
||||
}
|
||||
if (!consoleAgentName.trim()) {
|
||||
nextErrors.agent = 'Agent name is required'
|
||||
}
|
||||
if (!consoleTenant.trim() && !options?.allowMissingTenant) {
|
||||
nextErrors.tenant = 'Tenant / organization is required'
|
||||
}
|
||||
if (options?.requireAck && !consoleAck) {
|
||||
nextErrors.ack = 'You must acknowledge the console data-sharing notice'
|
||||
}
|
||||
setConsoleErrors(nextErrors)
|
||||
return Object.keys(nextErrors).length === 0
|
||||
}
|
||||
|
||||
const submitConsoleEnrollment = async (force = false) => {
|
||||
const allowMissingTenant = force && !consoleTenant.trim()
|
||||
const requireAck = normalizedConsoleStatus === 'not_enrolled'
|
||||
if (!validateConsoleEnrollment({ allowMissingTenant, requireAck })) return
|
||||
const tenantValue = consoleTenant.trim() || consoleStatusQuery.data?.tenant || consoleAgentName || 'charon-agent'
|
||||
try {
|
||||
await enrollConsoleMutation.mutateAsync({
|
||||
enrollment_key: enrollmentToken.trim(),
|
||||
tenant: tenantValue,
|
||||
agent_name: consoleAgentName.trim(),
|
||||
force,
|
||||
})
|
||||
setConsoleErrors({})
|
||||
setEnrollmentToken('')
|
||||
if (!consoleTenant.trim()) {
|
||||
setConsoleTenant(tenantValue)
|
||||
}
|
||||
toast.success(force ? 'Enrollment token rotated' : 'Enrollment submitted')
|
||||
} catch (err) {
|
||||
const message = sanitizeErrorMessage(err)
|
||||
setConsoleErrors((prev) => ({ ...prev, submit: message }))
|
||||
toast.error(message)
|
||||
}
|
||||
}
|
||||
|
||||
// Banned IPs queries and mutations
|
||||
const decisionsQuery = useQuery({
|
||||
queryKey: ['crowdsec-decisions'],
|
||||
@@ -435,6 +547,129 @@ export default function CrowdSecConfig() {
|
||||
</div>
|
||||
</Card>
|
||||
|
||||
{consoleEnrollmentEnabled && (
|
||||
<Card data-testid="console-enrollment-card">
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-md font-semibold">CrowdSec Console Enrollment</h3>
|
||||
<p className="text-sm text-gray-400">Register this engine with the CrowdSec console using an enrollment key. This flow is opt-in.</p>
|
||||
<p className="text-xs text-gray-500">
|
||||
Enrollment shares heartbeat metadata with crowdsec.net; secrets and configuration files are not sent.
|
||||
<a className="text-blue-400 hover:underline ml-1" href={consoleDocsHref} target="_blank" rel="noreferrer">View docs</a>
|
||||
</p>
|
||||
</div>
|
||||
<div className="text-right text-sm text-gray-300 space-y-1">
|
||||
<p className="font-semibold text-white capitalize" data-testid="console-status-label">Status: {consoleStatusLabel}</p>
|
||||
<p className="text-xs text-gray-500">Last heartbeat: {consoleStatusQuery.data?.last_heartbeat_at ? new Date(consoleStatusQuery.data.last_heartbeat_at).toLocaleString() : '—'}</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{consoleStatusQuery.data?.last_error && (
|
||||
<p className="text-xs text-yellow-300" data-testid="console-status-error">Last error: {sanitizeSecret(consoleStatusQuery.data.last_error)}</p>
|
||||
)}
|
||||
{consoleErrors.submit && (
|
||||
<p className="text-sm text-red-400" data-testid="console-enroll-error">{consoleErrors.submit}</p>
|
||||
)}
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Input
|
||||
label="crowdsec.net enroll token"
|
||||
type="password"
|
||||
value={enrollmentToken}
|
||||
onChange={(e) => setEnrollmentToken(e.target.value)}
|
||||
placeholder="Paste token or cscli console enroll <token>"
|
||||
helperText="Token is not displayed after submit. You may paste the full cscli command string."
|
||||
error={consoleErrors.token}
|
||||
errorTestId="console-enroll-error"
|
||||
data-testid="console-enrollment-token"
|
||||
/>
|
||||
<Input
|
||||
label="Agent name"
|
||||
value={consoleAgentName}
|
||||
onChange={(e) => setConsoleAgentName(e.target.value)}
|
||||
error={consoleErrors.agent}
|
||||
errorTestId="console-enroll-error"
|
||||
data-testid="console-agent-name"
|
||||
/>
|
||||
<Input
|
||||
label="Tenant / Organization"
|
||||
value={consoleTenant}
|
||||
onChange={(e) => setConsoleTenant(e.target.value)}
|
||||
helperText="Shown in the console when grouping agents."
|
||||
error={consoleErrors.tenant}
|
||||
errorTestId="console-enroll-error"
|
||||
data-testid="console-tenant"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-4 w-4 accent-blue-500"
|
||||
checked={consoleAck}
|
||||
onChange={(e) => setConsoleAck(e.target.checked)}
|
||||
disabled={isConsolePending}
|
||||
data-testid="console-ack-checkbox"
|
||||
/>
|
||||
<span className="text-sm text-gray-400">I understand this enrolls the engine with the CrowdSec console and shares heartbeat metadata.</span>
|
||||
</div>
|
||||
{consoleErrors.ack && <p className="text-sm text-red-400" data-testid="console-enroll-error">{consoleErrors.ack}</p>}
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
onClick={() => submitConsoleEnrollment(false)}
|
||||
disabled={isConsolePending}
|
||||
isLoading={enrollConsoleMutation.isPending}
|
||||
data-testid="console-enroll-btn"
|
||||
>
|
||||
Enroll
|
||||
</Button>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => submitConsoleEnrollment(true)}
|
||||
disabled={isConsolePending || !canRotateKey}
|
||||
isLoading={enrollConsoleMutation.isPending}
|
||||
data-testid="console-rotate-btn"
|
||||
>
|
||||
Rotate key
|
||||
</Button>
|
||||
{isConsoleDegraded && (
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => submitConsoleEnrollment(true)}
|
||||
disabled={isConsolePending}
|
||||
isLoading={enrollConsoleMutation.isPending}
|
||||
data-testid="console-retry-btn"
|
||||
>
|
||||
Retry enrollment
|
||||
</Button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-3 text-sm text-gray-400">
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Agent</p>
|
||||
<p className="text-white">{consoleStatusQuery.data?.agent_name || consoleAgentName || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Tenant</p>
|
||||
<p className="text-white">{consoleStatusQuery.data?.tenant || consoleTenant || '—'}</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-xs text-gray-500">Enrollment token</p>
|
||||
<p className="text-white" data-testid="console-token-state">{consoleTokenState}</p>
|
||||
</div>
|
||||
<div className="md:col-span-3 flex flex-wrap gap-4 text-xs text-gray-500">
|
||||
<span>Last attempt: {consoleStatusQuery.data?.last_attempt_at ? new Date(consoleStatusQuery.data.last_attempt_at).toLocaleString() : '—'}</span>
|
||||
<span>Enrolled at: {consoleStatusQuery.data?.enrolled_at ? new Date(consoleStatusQuery.data.enrolled_at).toLocaleString() : '—'}</span>
|
||||
{consoleStatusQuery.data?.correlation_id && <span>Correlation ID: {consoleStatusQuery.data.correlation_id}</span>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Card>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between gap-3 flex-wrap">
|
||||
@@ -459,39 +694,77 @@ export default function CrowdSecConfig() {
|
||||
|
||||
<Card>
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-start justify-between gap-3 flex-wrap">
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-md font-semibold">CrowdSec Presets</h3>
|
||||
<p className="text-sm text-gray-400">Select a curated preset, preview it, then apply with an automatic backup.</p>
|
||||
</div>
|
||||
<div className="flex items-center gap-2 flex-wrap">
|
||||
<select
|
||||
className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
value={selectedPresetSlug}
|
||||
onChange={(e) => setSelectedPresetSlug(e.target.value)}
|
||||
data-testid="preset-select"
|
||||
>
|
||||
{presetCatalog.map((preset) => (
|
||||
<option key={preset.slug} value={preset.slug}>{preset.title}</option>
|
||||
))}
|
||||
</select>
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => selectedPreset && pullPresetMutation.mutate(selectedPreset.slug)}
|
||||
disabled={!selectedPreset || pullPresetMutation.isPending}
|
||||
isLoading={pullPresetMutation.isPending}
|
||||
>
|
||||
Pull Preview
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApplyPreset}
|
||||
disabled={presetActionDisabled}
|
||||
isLoading={isApplyingPreset}
|
||||
data-testid="apply-preset-btn"
|
||||
>
|
||||
Apply Preset
|
||||
</Button>
|
||||
<div className="space-y-1">
|
||||
<h3 className="text-md font-semibold">CrowdSec Presets</h3>
|
||||
<p className="text-sm text-gray-400">Select a curated preset, preview it, then apply with an automatic backup.</p>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2">
|
||||
<div className="relative flex-1">
|
||||
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-400" />
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Search presets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-9 pr-3 py-2 text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
|
||||
/>
|
||||
</div>
|
||||
<select
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as any)}
|
||||
className="bg-gray-900 border border-gray-700 rounded-lg px-3 py-2 text-white"
|
||||
>
|
||||
<option value="alpha">Name (A-Z)</option>
|
||||
<option value="source">Source</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="border border-gray-700 rounded-lg max-h-60 overflow-y-auto bg-gray-900">
|
||||
{filteredPresets.length > 0 ? (
|
||||
filteredPresets.map((preset) => (
|
||||
<div
|
||||
key={preset.slug}
|
||||
onClick={() => setSelectedPresetSlug(preset.slug)}
|
||||
className={`p-3 cursor-pointer hover:bg-gray-800 border-b border-gray-800 last:border-0 ${
|
||||
selectedPresetSlug === preset.slug ? 'bg-blue-900/20 border-l-2 border-l-blue-500' : 'border-l-2 border-l-transparent'
|
||||
}`}
|
||||
>
|
||||
<div className="flex justify-between items-start mb-1">
|
||||
<span className="font-medium text-white">{preset.title}</span>
|
||||
{preset.source && (
|
||||
<span className={`text-xs px-2 py-0.5 rounded-full ${
|
||||
preset.source === 'charon-curated' ? 'bg-purple-900/50 text-purple-300' : 'bg-blue-900/50 text-blue-300'
|
||||
}`}>
|
||||
{preset.source === 'charon-curated' ? 'Curated' : 'Hub'}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<p className="text-xs text-gray-400 line-clamp-1">{preset.description}</p>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="p-4 text-center text-gray-500 text-sm">No presets found matching "{searchQuery}"</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2 flex-wrap justify-end">
|
||||
<Button
|
||||
variant="secondary"
|
||||
onClick={() => selectedPreset && pullPresetMutation.mutate(selectedPreset.slug)}
|
||||
disabled={!selectedPreset || pullPresetMutation.isPending}
|
||||
isLoading={pullPresetMutation.isPending}
|
||||
>
|
||||
Pull Preview
|
||||
</Button>
|
||||
<Button
|
||||
onClick={handleApplyPreset}
|
||||
disabled={presetActionDisabled}
|
||||
isLoading={isApplyingPreset}
|
||||
data-testid="apply-preset-btn"
|
||||
>
|
||||
Apply Preset
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{validationError && (
|
||||
@@ -551,7 +824,7 @@ export default function CrowdSecConfig() {
|
||||
<div className="rounded-lg border border-gray-800 bg-gray-900/70 p-3 text-xs text-gray-200" data-testid="preset-apply-info">
|
||||
<p>Status: {applyInfo.status || 'applied'}</p>
|
||||
{applyInfo.backup && <p>Backup: {applyInfo.backup}</p>}
|
||||
{applyInfo.reloadHint && <p>Reload: {applyInfo.reloadHint}</p>}
|
||||
{applyInfo.reloadHint && <p>Reload: Required</p>}
|
||||
{applyInfo.usedCscli !== undefined && <p>Method: {applyInfo.usedCscli ? 'cscli' : 'filesystem'}</p>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user