feat(crowdsec): IDS ein-/ausschalten via Switch — v1.2.63
- system.go: ServiceToggle-Endpoint (POST /system/service-toggle) start/stop + enable/disable für crowdsec + crowdsec-firewall-bouncer - system.go: crowdsec + crowdsec-firewall-bouncer in servicesToCheck - postinst: sudoers-Einträge für systemctl start/stop/enable/disable beider CrowdSec-Units - UI: Switch im StatusStrip für Agent + Bouncer, getrennt schaltbar, disabled wenn CrowdSec nicht installiert oder Viewer-Rolle Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -118,6 +118,7 @@ func (h *SystemHandler) Register(rg *gin.RouterGroup) {
|
|||||||
g.POST("/haproxy-reload", h.HAProxyReload)
|
g.POST("/haproxy-reload", h.HAProxyReload)
|
||||||
g.POST("/render-configs", h.RenderConfigs)
|
g.POST("/render-configs", h.RenderConfigs)
|
||||||
g.POST("/service-restart", h.ServiceRestart)
|
g.POST("/service-restart", h.ServiceRestart)
|
||||||
|
g.POST("/service-toggle", h.ServiceToggle)
|
||||||
g.GET("/upgrade-status", h.UpgradeStatus)
|
g.GET("/upgrade-status", h.UpgradeStatus)
|
||||||
g.GET("/ipv6", h.IPv6)
|
g.GET("/ipv6", h.IPv6)
|
||||||
g.POST("/ipv6", h.SetIPv6)
|
g.POST("/ipv6", h.SetIPv6)
|
||||||
@@ -194,6 +195,8 @@ var servicesToCheck = []struct{ Label, Unit string }{
|
|||||||
{"chrony", "chrony"},
|
{"chrony", "chrony"},
|
||||||
{"squid", "squid"},
|
{"squid", "squid"},
|
||||||
{"postgresql", "postgresql"},
|
{"postgresql", "postgresql"},
|
||||||
|
{"crowdsec", "crowdsec"},
|
||||||
|
{"crowdsec-firewall-bouncer", "crowdsec-firewall-bouncer"},
|
||||||
}
|
}
|
||||||
|
|
||||||
type serviceStatus struct {
|
type serviceStatus struct {
|
||||||
@@ -632,6 +635,52 @@ func (h *SystemHandler) ServiceRestart(c *gin.Context) {
|
|||||||
response.OK(c, gin.H{"ok": true, "service": svc})
|
response.OK(c, gin.H{"ok": true, "service": svc})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// toggleAllowlist defines which services may be started/stopped via the UI.
|
||||||
|
var toggleAllowlist = map[string]bool{
|
||||||
|
"crowdsec": true,
|
||||||
|
"crowdsec-firewall-bouncer": true,
|
||||||
|
"squid": true,
|
||||||
|
"unbound": true,
|
||||||
|
}
|
||||||
|
|
||||||
|
// ServiceToggle starts or stops (and enables/disables) a service.
|
||||||
|
// Body: {"service": "crowdsec", "enabled": true}
|
||||||
|
func (h *SystemHandler) ServiceToggle(c *gin.Context) {
|
||||||
|
var req struct {
|
||||||
|
Service string `json:"service" binding:"required"`
|
||||||
|
Enabled bool `json:"enabled"`
|
||||||
|
}
|
||||||
|
if err := c.ShouldBindJSON(&req); err != nil {
|
||||||
|
response.Err(c, http.StatusBadRequest, simpleErr("service and enabled required"))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
svc := strings.TrimSpace(req.Service)
|
||||||
|
if !toggleAllowlist[svc] {
|
||||||
|
response.Err(c, http.StatusBadRequest, simpleErr("service not in toggle allowlist: "+svc))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
unit := svc + ".service"
|
||||||
|
action := "stop"
|
||||||
|
sysdAction := "disable"
|
||||||
|
if req.Enabled {
|
||||||
|
action = "start"
|
||||||
|
sysdAction = "enable"
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", sysdAction, unit).CombinedOutput(); err != nil {
|
||||||
|
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if out, err := exec.Command("sudo", "-n", "/usr/bin/systemctl", action, unit).CombinedOutput(); err != nil {
|
||||||
|
response.Err(c, http.StatusInternalServerError, simpleErr(strings.TrimSpace(string(out))+": "+err.Error()))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if h.Audit != nil {
|
||||||
|
_ = h.Audit.Log(c.Request.Context(), actorOf(c), "system.service_toggle",
|
||||||
|
svc, gin.H{"service": svc, "enabled": req.Enabled}, h.NodeID)
|
||||||
|
}
|
||||||
|
response.OK(c, gin.H{"ok": true, "service": svc, "enabled": req.Enabled})
|
||||||
|
}
|
||||||
|
|
||||||
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
|
// RenderConfigs erzwingt ein Re-Render aller Service-Configs aus dem
|
||||||
// aktuellen DB-State. Läuft haproxy + alle ExtraReloaders (nftables,
|
// aktuellen DB-State. Läuft haproxy + alle ExtraReloaders (nftables,
|
||||||
// wireguard, squid, unbound, chrony) durch. Fehler werden gesammelt
|
// wireguard, squid, unbound, chrony) durch. Fehler werden gesammelt
|
||||||
|
|||||||
@@ -8,6 +8,7 @@ import {
|
|||||||
Popconfirm,
|
Popconfirm,
|
||||||
Select,
|
Select,
|
||||||
Space,
|
Space,
|
||||||
|
Switch,
|
||||||
Tag,
|
Tag,
|
||||||
Tabs,
|
Tabs,
|
||||||
Table,
|
Table,
|
||||||
@@ -22,6 +23,7 @@ import {
|
|||||||
} from '@ant-design/icons'
|
} from '@ant-design/icons'
|
||||||
import { useTranslation } from 'react-i18next'
|
import { useTranslation } from 'react-i18next'
|
||||||
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
import { useMutation, useQuery, useQueryClient } from '@tanstack/react-query'
|
||||||
|
import { useAuthStore } from '../../stores/auth'
|
||||||
|
|
||||||
import apiClient, { isEnvelope } from '../../api/client'
|
import apiClient, { isEnvelope } from '../../api/client'
|
||||||
import PageHeader from '../../components/PageHeader'
|
import PageHeader from '../../components/PageHeader'
|
||||||
@@ -73,27 +75,45 @@ async function fetchCollections(): Promise<HubItem[]> {
|
|||||||
return (r.data.data as { collections: HubItem[] }).collections ?? []
|
return (r.data.data as { collections: HubItem[] }).collections ?? []
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function toggleService(service: string, enabled: boolean): Promise<void> {
|
||||||
|
await apiClient.post('/system/service-toggle', { service, enabled })
|
||||||
|
}
|
||||||
|
|
||||||
// ---------- Status strip ----------------------------------------------------
|
// ---------- Status strip ----------------------------------------------------
|
||||||
|
|
||||||
function StatusStrip({ status }: { status: CrowdSecStatus | undefined }) {
|
function StatusStrip({ status, onToggle }: { status: CrowdSecStatus | undefined; onToggle: () => void }) {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const isViewer = useAuthStore((s) => s.user?.role) === 'viewer'
|
||||||
|
|
||||||
const agentColor = status?.agent_running ? 'green' : 'red'
|
const toggleAgent = useMutation({
|
||||||
const bouncerColor = status?.bouncer_running ? 'green' : 'red'
|
mutationFn: (enabled: boolean) => toggleService('crowdsec', enabled),
|
||||||
const agentLabel = status?.agent_running ? t('cs.status.running') : t('cs.status.stopped')
|
onSuccess: onToggle,
|
||||||
const bouncerLabel = status?.bouncer_running ? t('cs.status.running') : t('cs.status.stopped')
|
})
|
||||||
|
const toggleBouncer = useMutation({
|
||||||
|
mutationFn: (enabled: boolean) => toggleService('crowdsec-firewall-bouncer', enabled),
|
||||||
|
onSuccess: onToggle,
|
||||||
|
})
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="fw-kpi-strip">
|
<div className="fw-kpi-strip">
|
||||||
<div className="fw-kpi-card">
|
<div className="fw-kpi-card">
|
||||||
<div className="fw-kpi-label">{t('cs.status.agent')}</div>
|
<div className="fw-kpi-label">{t('cs.status.agent')}</div>
|
||||||
<div className="fw-kpi-value">
|
<div className="fw-kpi-value">
|
||||||
|
<Space size={8}>
|
||||||
<Tag
|
<Tag
|
||||||
icon={status?.agent_running ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
icon={status?.agent_running ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||||||
color={agentColor}
|
color={status?.agent_running ? 'green' : 'red'}
|
||||||
>
|
>
|
||||||
{agentLabel}
|
{status?.agent_running ? t('cs.status.running') : t('cs.status.stopped')}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={status?.agent_running ?? false}
|
||||||
|
disabled={isViewer || !status?.installed}
|
||||||
|
loading={toggleAgent.isPending}
|
||||||
|
onChange={(checked) => toggleAgent.mutate(checked)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
{status?.version && (
|
{status?.version && (
|
||||||
<div className="fw-kpi-sub">{status.version}</div>
|
<div className="fw-kpi-sub">{status.version}</div>
|
||||||
@@ -102,12 +122,21 @@ function StatusStrip({ status }: { status: CrowdSecStatus | undefined }) {
|
|||||||
<div className="fw-kpi-card">
|
<div className="fw-kpi-card">
|
||||||
<div className="fw-kpi-label">{t('cs.status.bouncer')}</div>
|
<div className="fw-kpi-label">{t('cs.status.bouncer')}</div>
|
||||||
<div className="fw-kpi-value">
|
<div className="fw-kpi-value">
|
||||||
|
<Space size={8}>
|
||||||
<Tag
|
<Tag
|
||||||
icon={status?.bouncer_running ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
icon={status?.bouncer_running ? <CheckCircleOutlined /> : <CloseCircleOutlined />}
|
||||||
color={bouncerColor}
|
color={status?.bouncer_running ? 'green' : 'red'}
|
||||||
>
|
>
|
||||||
{bouncerLabel}
|
{status?.bouncer_running ? t('cs.status.running') : t('cs.status.stopped')}
|
||||||
</Tag>
|
</Tag>
|
||||||
|
<Switch
|
||||||
|
size="small"
|
||||||
|
checked={status?.bouncer_running ?? false}
|
||||||
|
disabled={isViewer || !status?.installed}
|
||||||
|
loading={toggleBouncer.isPending}
|
||||||
|
onChange={(checked) => toggleBouncer.mutate(checked)}
|
||||||
|
/>
|
||||||
|
</Space>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="fw-kpi-card">
|
<div className="fw-kpi-card">
|
||||||
@@ -496,6 +525,7 @@ function CollectionsTab() {
|
|||||||
|
|
||||||
export default function CrowdSecPage() {
|
export default function CrowdSecPage() {
|
||||||
const { t } = useTranslation()
|
const { t } = useTranslation()
|
||||||
|
const queryClient = useQueryClient()
|
||||||
|
|
||||||
const { data: status } = useQuery({
|
const { data: status } = useQuery({
|
||||||
queryKey: ['crowdsec', 'status'],
|
queryKey: ['crowdsec', 'status'],
|
||||||
@@ -503,6 +533,10 @@ export default function CrowdSecPage() {
|
|||||||
refetchInterval: 10_000,
|
refetchInterval: 10_000,
|
||||||
})
|
})
|
||||||
|
|
||||||
|
const invalidateStatus = () => {
|
||||||
|
void queryClient.invalidateQueries({ queryKey: ['crowdsec', 'status'] })
|
||||||
|
}
|
||||||
|
|
||||||
const tabs = [
|
const tabs = [
|
||||||
{ key: 'decisions', label: t('cs.tabs.decisions'), children: <DecisionsTab /> },
|
{ key: 'decisions', label: t('cs.tabs.decisions'), children: <DecisionsTab /> },
|
||||||
{ key: 'alerts', label: t('cs.tabs.alerts'), children: <AlertsTab /> },
|
{ key: 'alerts', label: t('cs.tabs.alerts'), children: <AlertsTab /> },
|
||||||
@@ -526,7 +560,7 @@ export default function CrowdSecPage() {
|
|||||||
className="mb-2"
|
className="mb-2"
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
<StatusStrip status={status} />
|
<StatusStrip status={status} onToggle={invalidateStatus} />
|
||||||
<Tabs items={tabs} defaultActiveKey="decisions" type="card" />
|
<Tabs items={tabs} defaultActiveKey="decisions" type="card" />
|
||||||
</div>
|
</div>
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -165,6 +165,15 @@ edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli collections list -o json
|
|||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli collections install *
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli collections install *
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli collections remove *
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli collections remove *
|
||||||
edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli version
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/cscli version
|
||||||
|
# CrowdSec service toggle (start/stop/enable/disable)
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl start crowdsec.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop crowdsec.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable crowdsec.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable crowdsec.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl start crowdsec-firewall-bouncer.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl stop crowdsec-firewall-bouncer.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl enable crowdsec-firewall-bouncer.service
|
||||||
|
edgeguard ALL=(root) NOPASSWD: /usr/bin/systemctl disable crowdsec-firewall-bouncer.service
|
||||||
SUDOERS
|
SUDOERS
|
||||||
|
|
||||||
# ── Distro-Conf-Includes für die per-Service Renderer ─────────
|
# ── Distro-Conf-Includes für die per-Service Renderer ─────────
|
||||||
|
|||||||
Reference in New Issue
Block a user