Skip to content

Commit 93b702f

Browse files
committed
Preserve YAML list indentation when appending
Add logic to preserve and match existing YAML list indentation when appending entries. Introduce appendToYAMLList helper in whitelist handler and use it to append CrowdSec whitelist items instead of hardcoding 4-space indentation. Change Traefik append calls to pass list items as "- <value>" and update Client.AppendLineToFileInContainer to insert after the last "- " item in a YAML block, detect existing indentation, and apply it to the inserted line. Also update the function comment to reflect the new behavior.
1 parent ccb5d59 commit 93b702f

5 files changed

Lines changed: 103 additions & 32 deletions

File tree

internal/api/handlers/whitelist.go

Lines changed: 34 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,36 @@ import (
1515
"github.com/gin-gonic/gin"
1616
)
1717

18+
// appendToYAMLList appends a new list item under a YAML section key, using the
19+
// same indentation as existing list items in that block. Falls back to 4-space
20+
// indent if the block has no existing items.
21+
func appendToYAMLList(content, sectionKey, value string) string {
22+
lines := strings.Split(strings.TrimSpace(content), "\n")
23+
detectedIndent := " " // default 4 spaces
24+
sectionIndent := -1
25+
for _, line := range lines {
26+
trimmed := strings.TrimLeft(line, " \t")
27+
if strings.HasPrefix(trimmed, sectionKey) {
28+
sectionIndent = len(line) - len(trimmed)
29+
continue
30+
}
31+
if sectionIndent < 0 {
32+
continue
33+
}
34+
if trimmed == "" {
35+
continue
36+
}
37+
lineIndent := len(line) - len(trimmed)
38+
if lineIndent <= sectionIndent {
39+
break
40+
}
41+
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
42+
detectedIndent = line[:lineIndent]
43+
}
44+
}
45+
return strings.TrimSpace(content) + "\n" + detectedIndent + "- " + value + "\n"
46+
}
47+
1848
// =============================================================================
1949
// 3. WHITELIST MANAGEMENT
2050
// =============================================================================
@@ -134,8 +164,8 @@ whitelist:
134164
- %s
135165
`, req.IP)
136166
} else {
137-
// Append to existing whitelist
138-
whitelistContent = strings.TrimSpace(currentWL) + fmt.Sprintf("\n - %s\n", req.IP)
167+
// Append to existing whitelist, matching its existing indentation
168+
whitelistContent = appendToYAMLList(currentWL, "ip:", req.IP)
139169
}
140170

141171
err = dockerClient.WriteFileToContainer(cfg.CrowdsecContainerName, cfg.CrowdSecWhitelistPath, []byte(whitelistContent))
@@ -156,7 +186,7 @@ whitelist:
156186

157187
if req.AddToTraefik {
158188
// Update Traefik dynamic config
159-
err := dockerClient.AppendLineToFileInContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, "sourceRange:", " - "+req.IP)
189+
err := dockerClient.AppendLineToFileInContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, "sourceRange:", "- "+req.IP)
160190
if err != nil {
161191
errMsg := fmt.Sprintf("Failed to add IP to Traefik whitelist: %v", err)
162192
logger.Error(errMsg, "error", err)
@@ -231,7 +261,7 @@ whitelist:
231261
}
232262

233263
if req.AddToTraefik {
234-
err := dockerClient.AppendLineToFileInContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, "sourceRange:", " - "+req.CIDR)
264+
err := dockerClient.AppendLineToFileInContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, "sourceRange:", "- "+req.CIDR)
235265
if err != nil {
236266
errMsg := fmt.Sprintf("Failed to add CIDR to Traefik whitelist: %v", err)
237267
logger.Error(errMsg, "error", err)

internal/api/handlers/whitelist_ops.go

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -116,7 +116,7 @@ func AddToTraefikWhitelist(dockerClient *docker.Client, cfg *config.Config) gin.
116116

117117
logger.Info("Adding to Traefik whitelist", "ip", req.IP)
118118

119-
err := dockerClient.AppendLineToFileInContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, "sourceRange:", " - "+req.IP)
119+
err := dockerClient.AppendLineToFileInContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, "sourceRange:", "- "+req.IP)
120120
if err != nil {
121121
c.JSON(http.StatusInternalServerError, models.Response{
122122
Success: false,
@@ -272,7 +272,7 @@ whitelist:
272272
}
273273

274274
// Add to Traefik
275-
if err := dockerClient.AppendLineToFileInContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, "sourceRange:", " - "+ip); err == nil {
275+
if err := dockerClient.AppendLineToFileInContainer(cfg.TraefikContainerName, cfg.TraefikDynamicConfig, "sourceRange:", "- "+ip); err == nil {
276276
results["traefik"] = true
277277
}
278278

internal/docker/client.go

Lines changed: 52 additions & 11 deletions
Original file line numberDiff line numberDiff line change
@@ -364,29 +364,70 @@ func (c *Client) ReadFileFromContainer(containerName, filePath string) (string,
364364
}
365365

366366
// AppendLineToFileInContainer reads a file from a container, appends a line
367-
// after the first occurrence of `afterLine`, and writes it back. Uses the
368-
// Docker copy API so no shell interpolation occurs.
367+
// after the last list item in the block started by `afterLine`, and writes it
368+
// back. Uses the Docker copy API so no shell interpolation occurs.
369+
//
370+
// For YAML list blocks (e.g. sourceRange:), the new entry is inserted after the
371+
// last existing "- " item in the block so that indentation stays consistent.
369372
func (c *Client) AppendLineToFileInContainer(containerName, filePath, afterLine, newLine string) error {
370373
content, err := c.ReadFileFromContainer(containerName, filePath)
371374
if err != nil {
372375
return fmt.Errorf("failed to read file: %w", err)
373376
}
374377

375378
lines := strings.Split(content, "\n")
376-
var result []string
377-
inserted := false
378-
for _, line := range lines {
379-
result = append(result, line)
380-
if !inserted && strings.Contains(line, afterLine) {
381-
result = append(result, newLine)
382-
inserted = true
379+
380+
// Find the index of the section header.
381+
sectionIdx := -1
382+
for i, line := range lines {
383+
if strings.Contains(line, afterLine) {
384+
sectionIdx = i
385+
break
383386
}
384387
}
385-
386-
if !inserted {
388+
if sectionIdx == -1 {
387389
return fmt.Errorf("pattern %q not found in %s", afterLine, filePath)
388390
}
389391

392+
// Scan forward to find the last list item ("- ") in this block.
393+
// A line that is non-empty, not a list item, and has less or equal
394+
// indentation than the section header signals the end of the block.
395+
// The indentation of the first found list item is captured so the new
396+
// entry can match it exactly, regardless of what the caller passes.
397+
sectionIndent := len(lines[sectionIdx]) - len(strings.TrimLeft(lines[sectionIdx], " \t"))
398+
lastListIdx := sectionIdx // insert right after header if no items found
399+
detectedIndent := ""
400+
for i := sectionIdx + 1; i < len(lines); i++ {
401+
trimmed := strings.TrimLeft(lines[i], " \t")
402+
if trimmed == "" {
403+
continue
404+
}
405+
lineIndent := len(lines[i]) - len(trimmed)
406+
if lineIndent <= sectionIndent {
407+
// Left the block.
408+
break
409+
}
410+
if strings.HasPrefix(trimmed, "- ") || trimmed == "-" {
411+
lastListIdx = i
412+
if detectedIndent == "" {
413+
detectedIndent = lines[i][:lineIndent]
414+
}
415+
}
416+
}
417+
418+
// Apply detected indentation so the new entry always matches existing items.
419+
// If the block has no existing items, use newLine verbatim.
420+
insertLine := newLine
421+
if detectedIndent != "" {
422+
insertLine = detectedIndent + strings.TrimLeft(newLine, " \t")
423+
}
424+
425+
// Insert after the last list item (or after the header if none exist).
426+
result := make([]string, 0, len(lines)+1)
427+
result = append(result, lines[:lastListIdx+1]...)
428+
result = append(result, insertLine)
429+
result = append(result, lines[lastListIdx+1:]...)
430+
390431
return c.WriteFileToContainer(containerName, filePath, []byte(strings.Join(result, "\n")))
391432
}
392433

web/src/pages/Dashboard.tsx

Lines changed: 1 addition & 8 deletions
Original file line numberDiff line numberDiff line change
@@ -293,16 +293,9 @@ export default function Dashboard() {
293293
<div className="space-y-6">
294294
<PageHeader
295295
title="Dashboard"
296-
description="System overview, activity, and key metrics"
296+
description="System overview, activity, key metrics and threat posture"
297297
actions={<span className="text-xs text-muted-foreground">{lastUpdatedLabel}</span>}
298298
/>
299-
<div>
300-
<h1 className="text-3xl font-bold">Dashboard</h1>
301-
<p className="text-muted-foreground mt-1">
302-
System overview and threat posture
303-
</p>
304-
</div>
305-
306299
{/* Connection error banner */}
307300
{isError && (
308301
<Alert variant="destructive">

web/src/pages/Whitelist.tsx

Lines changed: 14 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -154,18 +154,25 @@ export default function Whitelist() {
154154
const ipError = useMemo(() => validateIP(manualIP), [manualIP])
155155
const cidrError = useMemo(() => validateCIDR(cidr), [cidr])
156156

157-
// Check for duplicates
157+
// Check for duplicates — only true when the value already exists in every
158+
// selected destination. If a toggle is off, that destination is ignored.
158159
const isIPDuplicate = useMemo(() => {
159160
if (!manualIP || !whitelistData) return false
160-
const allIPs = [...(whitelistData.crowdsec || []), ...(whitelistData.traefik || [])]
161-
return allIPs.includes(manualIP)
162-
}, [manualIP, whitelistData])
161+
const inCrowdSec = (whitelistData.crowdsec || []).includes(manualIP)
162+
const inTraefik = (whitelistData.traefik || []).includes(manualIP)
163+
const crowdSecSatisfied = !addToCrowdSec || inCrowdSec
164+
const traefikSatisfied = !addToTraefik || inTraefik
165+
return crowdSecSatisfied && traefikSatisfied
166+
}, [manualIP, whitelistData, addToCrowdSec, addToTraefik])
163167

164168
const isCIDRDuplicate = useMemo(() => {
165169
if (!cidr || !whitelistData) return false
166-
const allIPs = [...(whitelistData.crowdsec || []), ...(whitelistData.traefik || [])]
167-
return allIPs.includes(cidr)
168-
}, [cidr, whitelistData])
170+
const inCrowdSec = (whitelistData.crowdsec || []).includes(cidr)
171+
const inTraefik = (whitelistData.traefik || []).includes(cidr)
172+
const crowdSecSatisfied = !addToCrowdSec || inCrowdSec
173+
const traefikSatisfied = !addToTraefik || inTraefik
174+
return crowdSecSatisfied && traefikSatisfied
175+
}, [cidr, whitelistData, addToCrowdSec, addToTraefik])
169176

170177
const handleWhitelistManual = (e: React.FormEvent) => {
171178
e.preventDefault()

0 commit comments

Comments
 (0)