208 lines
5.6 KiB
Go
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
|
|
}
|