Files
crowdsec-dashy/internal/handlers/configeditor.go
T

208 lines
5.6 KiB
Go

package handlers
import (
"bytes"
"context"
"fmt"
"io/fs"
"net/http"
"net/url"
"os"
"os/exec"
"path/filepath"
"regexp"
"strings"
"time"
)
var ansiEscape = regexp.MustCompile(`\x1b\[[0-9;]*[mGKHFJA-Z]`)
// ConfigEditorHandler allows browsing and editing CrowdSec YAML config files.
type ConfigEditorHandler struct {
deps Deps
}
func NewConfigEditorHandler(deps Deps) *ConfigEditorHandler {
return &ConfigEditorHandler{deps: deps}
}
type ConfigEditorData struct {
PageData
Files []string
File string // relative path within config dir
Content string
FetchErr string
TestOut string // output from crowdsec -t after save
}
func (h *ConfigEditorHandler) List(w http.ResponseWriter, r *http.Request) {
pd := NewPageData(r, "Config Editor", h.deps.CLIAvailable, h.deps.PollInterval)
if f := readFlash(r); f.Message != "" {
pd.Flash = f
}
if h.deps.CrowdsecConfigDir == "" {
h.deps.Renderer.Render(w, "config-editor", ConfigEditorData{
PageData: pd,
FetchErr: "crowdsec_config_dir is not set in app_config.conf.",
})
return
}
files, err := listYAMLFiles(h.deps.CrowdsecConfigDir)
if err != nil {
h.deps.Renderer.Render(w, "config-editor", ConfigEditorData{
PageData: pd,
FetchErr: "Cannot read config directory: " + err.Error(),
})
return
}
file := strings.TrimSpace(r.URL.Query().Get("file"))
data := ConfigEditorData{PageData: pd, Files: files}
if file != "" {
absPath, err := safeConfigPath(h.deps.CrowdsecConfigDir, file)
if err != nil {
data.FetchErr = err.Error()
h.deps.Renderer.Render(w, "config-editor", data)
return
}
raw, err := os.ReadFile(absPath)
if err != nil {
data.FetchErr = "Cannot read file: " + err.Error()
h.deps.Renderer.Render(w, "config-editor", data)
return
}
data.File = file
data.Content = string(raw)
}
h.deps.Renderer.Render(w, "config-editor", data)
}
func (h *ConfigEditorHandler) Save(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB max
if err := r.ParseForm(); err != nil {
http.Error(w, "bad request", http.StatusBadRequest)
return
}
if !checkCSRF(r) {
http.Error(w, "forbidden", http.StatusForbidden)
return
}
file := strings.TrimSpace(r.FormValue("file"))
content := r.FormValue("content")
if file == "" {
flashRedirect(w, r, "/config-editor", "error", "no file specified")
return
}
absPath, err := safeConfigPath(h.deps.CrowdsecConfigDir, file)
if err != nil {
flashRedirect(w, r, "/config-editor", "error", err.Error())
return
}
// Read existing content for rollback.
original, err := os.ReadFile(absPath)
if err != nil {
flashRedirect(w, r, "/config-editor?file="+url.QueryEscape(file), "error", "cannot read current file: "+err.Error())
return
}
// Write new content.
if err := os.WriteFile(absPath, []byte(content), 0600); err != nil {
flashRedirect(w, r, "/config-editor?file="+url.QueryEscape(file), "error", "write failed: "+err.Error())
return
}
// Test config if crowdsec binary is available.
if h.deps.CrowdsecBinPath != "" {
if _, statErr := os.Stat(h.deps.CrowdsecBinPath); statErr == nil {
testErr := runCrowdsecTest(h.deps.CrowdsecBinPath)
if testErr != nil {
// Revert.
_ = os.WriteFile(absPath, original, 0600)
cleanErr := ansiEscape.ReplaceAllString(testErr.Error(), "")
pd := NewPageData(r, "Config Editor", h.deps.CLIAvailable, h.deps.PollInterval)
pd = pd.WithFlash("error", "Config test failed — file reverted")
files, _ := listYAMLFiles(h.deps.CrowdsecConfigDir)
h.deps.Renderer.Render(w, "config-editor", ConfigEditorData{
PageData: pd,
Files: files,
File: file,
Content: content,
TestOut: cleanErr,
})
return
}
}
}
flashRedirect(w, r, "/config-editor?file="+url.QueryEscape(file), "success", file+" saved successfully")
}
// safeConfigPath validates and resolves a relative path within configDir.
// Returns error on path traversal or non-YAML extension.
func safeConfigPath(configDir, rel string) (string, error) {
if strings.ContainsAny(rel, "\x00") {
return "", fmt.Errorf("invalid path")
}
abs := filepath.Clean(filepath.Join(configDir, rel))
dir := filepath.Clean(configDir)
if !strings.HasPrefix(abs, dir+string(filepath.Separator)) {
return "", fmt.Errorf("access denied: path outside config directory")
}
ext := strings.ToLower(filepath.Ext(abs))
if ext != ".yaml" && ext != ".yml" {
return "", fmt.Errorf("only .yaml and .yml files are editable")
}
return abs, nil
}
// listYAMLFiles returns relative paths of all .yaml/.yml files under dir.
func listYAMLFiles(dir string) ([]string, error) {
var files []string
err := filepath.WalkDir(dir, func(path string, d fs.DirEntry, err error) error {
if err != nil {
return nil // skip unreadable entries
}
if d.IsDir() {
name := d.Name()
// Skip hidden dirs and large data dirs
if strings.HasPrefix(name, ".") || name == "hub" || name == "patterns" {
return filepath.SkipDir
}
return nil
}
ext := strings.ToLower(filepath.Ext(path))
if ext == ".yaml" || ext == ".yml" {
rel, _ := filepath.Rel(dir, path)
files = append(files, rel)
}
return nil
})
return files, err
}
// runCrowdsecTest runs crowdsec -t to validate config.
func runCrowdsecTest(binPath string) error {
ctx, cancel := context.WithTimeout(context.Background(), 15*time.Second)
defer cancel()
cmd := exec.CommandContext(ctx, binPath, "-t") //nolint:gosec — path from config, not user input
var stderr bytes.Buffer
cmd.Stderr = &stderr
if err := cmd.Run(); err != nil {
msg := strings.TrimSpace(stderr.String())
if msg == "" {
msg = err.Error()
}
return fmt.Errorf("%s", msg)
}
return nil
}