backup: pre-hardening baseline

This commit is contained in:
backup 2026-09-02 11:20:31 -05:00
commit 9e4c612dcb
57 changed files with 10393 additions and 0 deletions

View file

@ -0,0 +1,84 @@
package file
import (
"fmt"
"strings"
)
// UnifiedDiff returns a unified diff between old and new content
func UnifiedDiff(filename, oldContent, newContent string) string {
if oldContent == newContent {
return ""
}
oldLines := splitLines(oldContent)
newLines := splitLines(newContent)
// Simple line-based diff: collect added/removed
// Use LCS-style? For MVP use simple prefix diff
var b strings.Builder
fmt.Fprintf(&b, "--- a/%s\n+++ b/%s\n", filename, filename)
// Find common prefix/suffix for brevity
// Instead do full: show removed then added
oldSet := make(map[string]int)
for _, l := range oldLines {
oldSet[l]++
}
newSet := make(map[string]int)
for _, l := range newLines {
newSet[l]++
}
// Very simple: if old empty (new file), show all as +
if len(oldLines) == 0 || (len(oldLines) == 1 && oldLines[0] == "") {
for _, l := range newLines {
fmt.Fprintf(&b, "+%s\n", l)
}
return b.String()
}
if len(newLines) == 0 {
for _, l := range oldLines {
fmt.Fprintf(&b, "-%s\n", l)
}
return b.String()
}
// For MVP, do line-by-line with +/- for changed lines
// Use diff library would be better, but avoid extra dep: implement simple
max := len(oldLines)
if len(newLines) > max {
max = len(newLines)
}
// Show hunks: 3 context lines? Simple: show all
for i := 0; i < max; i++ {
var o, n string
hasO := i < len(oldLines)
hasN := i < len(newLines)
if hasO {
o = oldLines[i]
}
if hasN {
n = newLines[i]
}
if hasO && hasN && o == n {
fmt.Fprintf(&b, " %s\n", o)
} else {
if hasO {
fmt.Fprintf(&b, "-%s\n", o)
}
if hasN {
fmt.Fprintf(&b, "+%s\n", n)
}
}
}
return b.String()
}
func splitLines(s string) []string {
if s == "" {
return []string{}
}
// Normalize line endings
s = strings.ReplaceAll(s, "\r\n", "\n")
return strings.Split(s, "\n")
}

View file

@ -0,0 +1,25 @@
package file
import "sync"
// FileLocks provides per-filename mutexes to handle concurrent writes safely.
type FileLocks struct {
mu sync.Mutex
locks map[string]*sync.Mutex
}
func NewFileLocks() *FileLocks {
return &FileLocks{locks: make(map[string]*sync.Mutex)}
}
func (f *FileLocks) Lock(filename string) func() {
f.mu.Lock()
m, ok := f.locks[filename]
if !ok {
m = &sync.Mutex{}
f.locks[filename] = m
}
f.mu.Unlock()
m.Lock()
return func() { m.Unlock() }
}

View file

@ -0,0 +1,385 @@
package file
import (
"crypto/rand"
"database/sql"
"encoding/hex"
"fmt"
"os"
"path/filepath"
"strings"
"time"
"github.com/google/uuid"
)
type Service struct {
configDir string
db *sql.DB
locks *FileLocks
}
func NewService(configDir string, db *sql.DB) (*Service, error) {
abs, err := filepath.Abs(configDir)
if err != nil {
return nil, fmt.Errorf("resolve config dir: %w", err)
}
if err := os.MkdirAll(abs, 0o755); err != nil {
return nil, fmt.Errorf("create config dir: %w", err)
}
// Ensure backups dir exists
if err := os.MkdirAll(filepath.Join(abs, "backups"), 0o755); err != nil {
return nil, fmt.Errorf("create backups dir: %w", err)
}
return &Service{configDir: abs, db: db, locks: NewFileLocks()}, nil
}
func (s *Service) ConfigDir() string { return s.configDir }
// sanitizedPath returns absolute path inside configDir, after validating filename
func (s *Service) sanitizedPath(filename string) (string, error) {
if err := ValidateFilename(filename); err != nil {
return "", err
}
// No path separators already validated, but double-check
clean := filepath.Base(filename)
p := filepath.Join(s.configDir, clean)
// Ensure p is inside configDir
if !strings.HasPrefix(p, s.configDir+string(os.PathSeparator)) && p != s.configDir {
return "", fmt.Errorf("invalid path")
}
return p, nil
}
// FileMeta for listing without exposing paths
type FileMeta struct {
Filename string `json:"filename"`
Size int64 `json:"size"`
ModTime string `json:"modTime"` // RFC3339
Valid bool `json:"valid"`
ValidationErr string `json:"validationErr,omitempty"`
LastAction string `json:"lastAction,omitempty"` // apply/rollback
LastActionTime string `json:"lastActionTime,omitempty"`
}
// ListFiles lists files in configDir (excluding backups subdir and temp files)
func (s *Service) ListFiles() ([]string, error) {
entries, err := os.ReadDir(s.configDir)
if err != nil {
return nil, err
}
var files []string
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.Contains(name, ".tmp.") {
continue
}
if strings.HasSuffix(strings.ToLower(name), ".yml") || strings.HasSuffix(strings.ToLower(name), ".yaml") || strings.HasSuffix(strings.ToLower(name), ".toml") {
files = append(files, name)
}
}
return files, nil
}
// ListFilesWithMeta returns enriched metadata without exposing paths
func (s *Service) ListFilesWithMeta() ([]FileMeta, error) {
entries, err := os.ReadDir(s.configDir)
if err != nil {
return nil, err
}
var out []FileMeta
for _, e := range entries {
if e.IsDir() {
continue
}
name := e.Name()
if strings.Contains(name, ".tmp.") {
continue
}
lower := strings.ToLower(name)
if !(strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".toml")) {
continue
}
info, err := e.Info()
if err != nil {
continue
}
meta := FileMeta{
Filename: name,
Size: info.Size(),
ModTime: info.ModTime().UTC().Format(time.RFC3339),
Valid: true,
}
// Validate content for status
if b, err := os.ReadFile(filepath.Join(s.configDir, name)); err == nil {
if errs := ValidateContent(name, string(b)); len(errs) > 0 {
meta.Valid = false
meta.ValidationErr = errs[0].Error()
}
} else {
meta.Valid = false
meta.ValidationErr = err.Error()
}
// Last action from backups
var reason, createdAt string
err = s.db.QueryRow(`SELECT reason, created_at FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, name).Scan(&reason, &createdAt)
if err == nil {
meta.LastAction = reason
meta.LastActionTime = createdAt
}
out = append(out, meta)
}
return out, nil
}
// ReadFile reads raw content of a file in configDir
func (s *Service) ReadFile(filename string) (string, error) {
p, err := s.sanitizedPath(filename)
if err != nil {
return "", err
}
b, err := os.ReadFile(p)
if err != nil {
if os.IsNotExist(err) {
return "", fmt.Errorf("file not found: %s", filename)
}
return "", err
}
return string(b), nil
}
// Preview validates and diffs without writing
type PreviewResult struct {
Valid bool `json:"valid"`
Errors []ValidationError `json:"errors,omitempty"`
Diff string `json:"diff"`
Warnings []string `json:"warnings,omitempty"`
}
func (s *Service) Preview(filename, content string) PreviewResult {
errs := ValidateContent(filename, content)
if len(errs) > 0 {
return PreviewResult{Valid: false, Errors: errs}
}
// Diff vs current file (if exists)
oldContent := ""
if p, err := s.sanitizedPath(filename); err == nil {
if b, err := os.ReadFile(p); err == nil {
oldContent = string(b)
}
}
diff := UnifiedDiff(filename, oldContent, content)
return PreviewResult{Valid: true, Diff: diff}
}
// Apply validates, backs up, then atomically writes. Requires confirm=true caller.
func (s *Service) Apply(filename, content, userID string, confirm bool) (PreviewResult, error) {
if !confirm {
return PreviewResult{}, fmt.Errorf("confirmation required: set confirm:true")
}
errs := ValidateContent(filename, content)
if len(errs) > 0 {
return PreviewResult{Valid: false, Errors: errs}, nil
}
p, err := s.sanitizedPath(filename)
if err != nil {
return PreviewResult{}, err
}
unlock := s.locks.Lock(filename)
defer unlock()
// Backup current content
oldContent := ""
if b, err := os.ReadFile(p); err == nil {
oldContent = string(b)
} else if !os.IsNotExist(err) {
return PreviewResult{}, fmt.Errorf("read current file: %w", err)
}
diff := UnifiedDiff(filename, oldContent, content)
// Store backup in DB
backupID := uuid.New().String()
_, err = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
backupID, filename, oldContent, userID, "apply")
if err != nil {
return PreviewResult{}, fmt.Errorf("store backup: %w", err)
}
// Also filesystem backup
backupPath := filepath.Join(s.configDir, "backups", fmt.Sprintf("%s.%d.bak", filename, time.Now().Unix()))
_ = os.WriteFile(backupPath, []byte(oldContent), 0o644)
// Prune old filesystem backups (keep 20)
s.pruneFilesystemBackups(filename)
// Atomic write: temp file in same dir, fsync, rename
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return PreviewResult{}, fmt.Errorf("create temp file: %w", err)
}
if _, err := f.WriteString(content); err != nil {
f.Close()
os.Remove(tmpName)
return PreviewResult{}, fmt.Errorf("write temp: %w", err)
}
if err := f.Sync(); err != nil {
f.Close()
os.Remove(tmpName)
return PreviewResult{}, fmt.Errorf("fsync temp: %w", err)
}
f.Close()
if err := os.Rename(tmpName, p); err != nil {
os.Remove(tmpName)
return PreviewResult{}, fmt.Errorf("rename: %w", err)
}
// fsync directory
if d, err := os.Open(s.configDir); err == nil {
_ = d.Sync()
d.Close()
}
// Prune DB backups (keep 50 per file)
s.pruneDBBackups(filename)
return PreviewResult{Valid: true, Diff: diff}, nil
}
func (s *Service) pruneFilesystemBackups(filename string) {
pattern := filepath.Join(s.configDir, "backups", filename+".*.bak")
matches, _ := filepath.Glob(pattern)
if len(matches) <= 20 {
return
}
// Remove oldest (Glob returns sorted)
for _, m := range matches[:len(matches)-20] {
os.Remove(m)
}
}
func (s *Service) pruneDBBackups(filename string) {
// Keep 50 most recent
_, _ = s.db.Exec(`
DELETE FROM backups WHERE id IN (
SELECT id FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT -1 OFFSET 50
)`, filename)
}
// History lists backups for a file (or all if filename empty)
type BackupInfo struct {
ID string `json:"id"`
Filename string `json:"filename"`
CreatedAt string `json:"created_at"`
CreatedBy string `json:"created_by"`
Reason string `json:"reason"`
}
func (s *Service) History(filename string) ([]BackupInfo, error) {
var rows *sql.Rows
var err error
if filename != "" {
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 50`, filename)
} else {
rows, err = s.db.Query(`SELECT id, filename, created_at, created_by, reason FROM backups ORDER BY created_at DESC, rowid DESC LIMIT 100`)
}
if err != nil {
return nil, err
}
defer rows.Close()
var out []BackupInfo
for rows.Next() {
var b BackupInfo
if err := rows.Scan(&b.ID, &b.Filename, &b.CreatedAt, &b.CreatedBy, &b.Reason); err != nil {
return nil, err
}
out = append(out, b)
}
return out, nil
}
// Rollback restores specified backup (or most recent if backupID empty)
func (s *Service) Rollback(filename, backupID, userID string) (PreviewResult, error) {
if err := ValidateFilename(filename); err != nil {
return PreviewResult{}, err
}
p, err := s.sanitizedPath(filename)
if err != nil {
return PreviewResult{}, err
}
unlock := s.locks.Lock(filename)
defer unlock()
var content string
if backupID != "" {
err = s.db.QueryRow(`SELECT content FROM backups WHERE id=? AND filename=?`, backupID, filename).Scan(&content)
if err == sql.ErrNoRows {
return PreviewResult{}, fmt.Errorf("backup not found")
}
if err != nil {
return PreviewResult{}, err
}
} else {
// Most recent
err = s.db.QueryRow(`SELECT content FROM backups WHERE filename=? ORDER BY created_at DESC, rowid DESC LIMIT 1`, filename).Scan(&content)
if err == sql.ErrNoRows {
return PreviewResult{}, fmt.Errorf("no backup found for %s", filename)
}
if err != nil {
return PreviewResult{}, err
}
}
// Validate rollback content (allow empty = delete file)
if content != "" {
if errs := ValidateContent(filename, content); len(errs) > 0 {
return PreviewResult{}, fmt.Errorf("rollback content invalid: %s", errs[0].Error())
}
}
// Backup current before rollback
curContent := ""
if b, err := os.ReadFile(p); err == nil {
curContent = string(b)
}
rbID := uuid.New().String()
_, _ = s.db.Exec(`INSERT INTO backups (id, filename, content, created_by, reason) VALUES (?, ?, ?, ?, ?)`,
rbID, filename, curContent, userID, "rollback")
if content == "" {
// Original file was new: delete current file
_ = os.Remove(p)
} else {
tmpName := fmt.Sprintf("%s.tmp.%s", p, randHex(4))
f, err := os.OpenFile(tmpName, os.O_CREATE|os.O_WRONLY|os.O_TRUNC, 0o644)
if err != nil {
return PreviewResult{}, err
}
if _, err := f.WriteString(content); err != nil {
f.Close()
os.Remove(tmpName)
return PreviewResult{}, err
}
f.Sync()
f.Close()
if err := os.Rename(tmpName, p); err != nil {
os.Remove(tmpName)
return PreviewResult{}, err
}
if d, err := os.Open(s.configDir); err == nil {
_ = d.Sync()
d.Close()
}
}
diff := UnifiedDiff(filename, curContent, content)
return PreviewResult{Valid: true, Diff: diff}, nil
}
func randHex(n int) string {
b := make([]byte, n)
_, _ = rand.Read(b)
return hex.EncodeToString(b)
}

View file

@ -0,0 +1,345 @@
package file
import (
"database/sql"
"os"
"path/filepath"
"sync"
"testing"
_ "github.com/mattn/go-sqlite3"
)
func newTestService(t *testing.T) (*Service, string, func()) {
t.Helper()
dir, err := os.MkdirTemp("", "traefik-gui-test-*")
if err != nil {
t.Fatalf("temp dir: %v", err)
}
db, err := sql.Open("sqlite3", filepath.Join(dir, "test.db")+"?_foreign_keys=on")
if err != nil {
t.Fatalf("open db: %v", err)
}
// create backups table
_, err = db.Exec(`CREATE TABLE backups (
id TEXT PRIMARY KEY,
filename TEXT NOT NULL,
content TEXT NOT NULL,
created_at DATETIME NOT NULL DEFAULT CURRENT_TIMESTAMP,
created_by TEXT NOT NULL,
reason TEXT NOT NULL
)`)
if err != nil {
t.Fatalf("create backups: %v", err)
}
svc, err := NewService(filepath.Join(dir, "dynamic"), db)
if err != nil {
t.Fatalf("new service: %v", err)
}
cleanup := func() {
db.Close()
os.RemoveAll(dir)
}
return svc, dir, cleanup
}
const validYAML = `http:
routers:
test-router:
rule: "Host(` + "`test.example.com`" + `)"
service: test-service
entryPoints: ["web"]
services:
test-service:
loadBalancer:
servers:
- url: "http://127.0.0.1:8080"
`
const validYAML2 = `http:
routers:
test-router2:
rule: "Host(` + "`test2.example.com`" + `)"
service: test-service2
entryPoints: ["web"]
services:
test-service2:
loadBalancer:
servers:
- url: "http://127.0.0.1:8081"
`
func TestValidateContent_Valid(t *testing.T) {
errs := ValidateContent("app.yml", validYAML)
if len(errs) != 0 {
t.Fatalf("expected no errors, got %v", errs)
}
}
func TestValidateContent_InvalidYAML(t *testing.T) {
invalid := "http:\n routers: [\ninvalid yaml"
errs := ValidateContent("app.yml", invalid)
if len(errs) == 0 {
t.Fatal("expected validation errors for invalid yaml")
}
}
func TestValidateContent_Empty(t *testing.T) {
errs := ValidateContent("app.yml", " ")
if len(errs) == 0 {
t.Fatal("expected error for empty content")
}
}
func TestValidateContent_NoTopLevel(t *testing.T) {
errs := ValidateContent("app.yml", "foo: bar\nbaz: qux\n")
if len(errs) == 0 {
t.Fatal("expected error for missing http/tcp/udp/tls")
}
}
func TestValidateContent_DangerousFilename(t *testing.T) {
errs := ValidateContent("../evil.yml", validYAML)
if len(errs) == 0 {
t.Fatal("expected error for path traversal filename")
}
errs = ValidateContent("app.txt", validYAML)
if len(errs) == 0 {
t.Fatal("expected error for wrong extension")
}
}
func TestPreview_Diff(t *testing.T) {
svc, _, cleanup := newTestService(t)
defer cleanup()
// Preview new file (no existing)
res := svc.Preview("app.yml", validYAML)
if !res.Valid {
t.Fatalf("preview should be valid, got errs %v", res.Errors)
}
if res.Diff == "" {
t.Fatal("expected diff for new file")
}
// Preview with same content -> no diff? Actually diff will be content vs empty, so diff present
// Second: after apply, preview same content should have empty diff
_, err := svc.Apply("app.yml", validYAML, "test-user", true)
if err != nil {
t.Fatalf("apply: %v", err)
}
res2 := svc.Preview("app.yml", validYAML)
if !res2.Valid {
t.Fatalf("preview2 valid %v", res2.Errors)
}
// Same content should give empty diff
if res2.Diff != "" {
t.Fatalf("expected empty diff for same content, got %q", res2.Diff)
}
// Different content should give diff
res3 := svc.Preview("app.yml", validYAML2)
if res3.Diff == "" {
t.Fatal("expected diff for changed content")
}
}
func TestAtomicWrite(t *testing.T) {
svc, _, cleanup := newTestService(t)
defer cleanup()
// Apply valid
res, err := svc.Apply("app.yml", validYAML, "user1", true)
if err != nil {
t.Fatalf("apply err %v", err)
}
if !res.Valid {
t.Fatalf("not valid %v", res.Errors)
}
// Read back
content, err := svc.ReadFile("app.yml")
if err != nil {
t.Fatalf("read %v", err)
}
if content != validYAML {
t.Fatalf("content mismatch")
}
// Ensure no temp files left
files, _ := svc.ListFiles()
for _, f := range files {
if len(f) > 4 && f[len(f)-4:] == ".tmp" {
t.Fatalf("temp file left: %s", f)
}
}
// Ensure temp files not present on disk
entries, _ := os.ReadDir(svc.ConfigDir())
for _, e := range entries {
if len(e.Name()) > 4 && contains(e.Name(), ".tmp.") {
t.Fatalf("temp file on disk: %s", e.Name())
}
}
}
func contains(s, sub string) bool {
return len(s) >= len(sub) && (func() bool {
for i := 0; i <= len(s)-len(sub); i++ {
if s[i:i+len(sub)] == sub {
return true
}
}
return false
})()
}
func TestRejectInvalid(t *testing.T) {
svc, _, cleanup := newTestService(t)
defer cleanup()
// Try apply invalid YAML - should not write
res, err := svc.Apply("bad.yml", "http:\n bad: [\n", "user1", true)
if err != nil {
t.Fatalf("apply should return preview error, not err %v", err)
}
if res.Valid {
t.Fatal("invalid yaml should be rejected")
}
// Ensure file not created
if _, err := svc.ReadFile("bad.yml"); err == nil {
t.Fatal("invalid file should not be created")
}
}
func TestRejectEmpty(t *testing.T) {
svc, _, cleanup := newTestService(t)
defer cleanup()
res, _ := svc.Apply("empty.yml", " ", "user1", true)
if res.Valid {
t.Fatal("empty should be rejected")
}
}
func TestRejectNoConfirm(t *testing.T) {
svc, _, cleanup := newTestService(t)
defer cleanup()
_, err := svc.Apply("app.yml", validYAML, "user1", false)
if err == nil {
t.Fatal("expected error for missing confirm")
}
}
func TestRollback(t *testing.T) {
svc, _, cleanup := newTestService(t)
defer cleanup()
// Apply v1
_, err := svc.Apply("app.yml", validYAML, "user1", true)
if err != nil {
t.Fatalf("apply v1 %v", err)
}
// Apply v2
_, err = svc.Apply("app.yml", validYAML2, "user1", true)
if err != nil {
t.Fatalf("apply v2 %v", err)
}
// Verify v2 present
content, _ := svc.ReadFile("app.yml")
if content != validYAML2 {
t.Fatalf("expected v2")
}
// Rollback to previous (v1)
_, err = svc.Rollback("app.yml", "", "user1")
if err != nil {
t.Fatalf("rollback %v", err)
}
content, _ = svc.ReadFile("app.yml")
if content != validYAML {
t.Fatalf("expected rollback to v1, got %q", content)
}
// Check history has entries
hist, err := svc.History("app.yml")
if err != nil {
t.Fatalf("history %v", err)
}
if len(hist) < 2 {
t.Fatalf("expected at least 2 history entries, got %d", len(hist))
}
}
func TestRollbackSpecificBackup(t *testing.T) {
svc, _, cleanup := newTestService(t)
defer cleanup()
svc.Apply("app.yml", validYAML, "user1", true)
svc.Apply("app.yml", validYAML2, "user1", true)
hist, _ := svc.History("app.yml")
if len(hist) < 2 {
t.Fatalf("need 2 backups")
}
// hist[0] is most recent (before v2), hist[1] is before v1 (empty)
// Rollback to specific backup: choose oldest that has content validYAML
// The most recent backup content is validYAML (before v2)
_, err := svc.Rollback("app.yml", hist[0].ID, "user1")
if err != nil {
t.Fatalf("rollback specific %v", err)
}
content, _ := svc.ReadFile("app.yml")
if content != validYAML {
t.Fatalf("expected v1 after specific rollback")
}
}
func TestConcurrentUpdates(t *testing.T) {
svc, _, cleanup := newTestService(t)
defer cleanup()
// Start with valid
svc.Apply("concurrent.yml", validYAML, "user1", true)
var wg sync.WaitGroup
errs := make([]error, 10)
for i := 0; i < 10; i++ {
wg.Add(1)
go func(idx int) {
defer wg.Done()
content := validYAML
if idx%2 == 0 {
content = validYAML2
}
_, err := svc.Apply("concurrent.yml", content, "user1", true)
errs[idx] = err
}(i)
}
wg.Wait()
for i, e := range errs {
if e != nil {
t.Fatalf("concurrent apply %d failed: %v", i, e)
}
}
// Final content should be one of the two
content, _ := svc.ReadFile("concurrent.yml")
if content != validYAML && content != validYAML2 {
t.Fatalf("unexpected final content")
}
// History should have 11 entries (initial + 10)
hist, _ := svc.History("concurrent.yml")
if len(hist) != 11 {
t.Fatalf("expected 11 history, got %d", len(hist))
}
}
func TestFileOwnershipAndValidation(t *testing.T) {
svc, _, cleanup := newTestService(t)
defer cleanup()
// Try to write outside directory via traversal — should fail validation
res, _ := svc.Apply("../evil.yml", validYAML, "user1", true)
if res.Valid {
t.Fatal("expected invalid for traversal")
}
// Try wrong extension
res, _ = svc.Apply("evil.txt", validYAML, "user1", true)
if res.Valid {
t.Fatal("expected invalid for wrong extension")
}
// Try empty filename
res2 := svc.Preview("", validYAML)
if res2.Valid {
t.Fatal("expected invalid for empty filename")
}
}

View file

@ -0,0 +1,290 @@
package file
import (
"fmt"
"strings"
"gopkg.in/yaml.v3"
)
// ValidationError with line info
type ValidationError struct {
Message string `json:"message"`
Line int `json:"line,omitempty"`
Column int `json:"column,omitempty"`
}
func (e ValidationError) Error() string {
if e.Line > 0 {
return fmt.Sprintf("line %d col %d: %s", e.Line, e.Column, e.Message)
}
return e.Message
}
// ValidateContent checks YAML content before write.
// Rejects empty, dangerous, or structurally invalid configs.
// Allows only dynamic config top-level keys: http, tcp, udp, tls.
// Additionally validates nested router/service/middleware/TLS structure to match Traefik v3.7 dynamic schema.
func ValidateContent(filename, content string) []ValidationError {
var errs []ValidationError
trimmed := strings.TrimSpace(content)
if trimmed == "" {
errs = append(errs, ValidationError{Message: "content must not be empty"})
return errs
}
if err := ValidateFilename(filename); err != nil {
errs = append(errs, ValidationError{Message: err.Error()})
return errs
}
// TOML files: only syntax check via extension, full schema validated as YAML for MVP.
// If filename is .toml, require non-empty and no traversal already checked; skip YAML schema for now.
isTOML := strings.HasSuffix(strings.ToLower(filename), ".toml")
if isTOML {
if len(content) > 1*1024*1024 {
errs = append(errs, ValidationError{Message: "content too large (>1MB)"})
}
// Basic TOML sanity: must contain '=' and not be pure YAML mapping without equals?
// Accept any non-empty TOML for MVP, but reject obvious YAML-only constructs without '='
return errs
}
// YAML syntax check with line extraction
var raw map[string]interface{}
var node yaml.Node
if err := yaml.Unmarshal([]byte(content), &raw); err != nil {
// Try to extract line/col via yaml.Node
if err2 := yaml.Unmarshal([]byte(content), &node); err2 == nil {
// fallthrough handled by raw error
}
if ye, ok := err.(*yaml.TypeError); ok {
for _, msg := range ye.Errors {
errs = append(errs, ValidationError{Message: msg})
}
} else {
// Parse line from error string like "yaml: line 3: ..."
msg := err.Error()
line, col := parseYAMLLineCol(msg)
errs = append(errs, ValidationError{Message: msg, Line: line, Column: col})
}
return errs
}
if raw == nil {
errs = append(errs, ValidationError{Message: "YAML must be a mapping"})
return errs
}
allowedTop := map[string]bool{"http": true, "tcp": true, "udp": true, "tls": true}
hasAllowed := false
for k := range raw {
if allowedTop[k] {
hasAllowed = true
} else {
errs = append(errs, ValidationError{Message: fmt.Sprintf("unknown top-level key %q: allowed keys are http, tcp, udp, tls", k)})
}
}
if !hasAllowed {
errs = append(errs, ValidationError{Message: "config must contain at least one of: http, tcp, udp, tls"})
}
if len(content) > 1*1024*1024 {
errs = append(errs, ValidationError{Message: "content too large (>1MB)"})
}
// Deep schema validation matching Traefik v3.7 dynamic config
errs = append(errs, validateHTTPBlock(raw["http"])...)
errs = append(errs, validateTCPBlock(raw["tcp"])...)
errs = append(errs, validateUDPBlock(raw["udp"])...)
errs = append(errs, validateTLSBlock(raw["tls"])...)
return errs
}
func parseYAMLLineCol(msg string) (int, int) {
// Example: "yaml: line 3: did not find expected ','"
var line, col int
_, _ = fmt.Sscanf(msg, "yaml: line %d: ", &line)
// Column rarely present in gopkg.in/yaml.v3 errors; leave 0
return line, col
}
func validateHTTPBlock(raw interface{}) []ValidationError {
if raw == nil {
return nil
}
m, ok := raw.(map[string]interface{})
if !ok {
return []ValidationError{{Message: "http must be a mapping"}}
}
var errs []ValidationError
allowed := map[string]bool{"routers": true, "services": true, "middlewares": true, "serversTransports": true, "models": true}
for k := range m {
if !allowed[k] {
errs = append(errs, ValidationError{Message: fmt.Sprintf("http: unknown key %q (allowed: routers, services, middlewares, serversTransports, models)", k)})
}
}
if routers, ok := m["routers"]; ok {
if rm, ok := routers.(map[string]interface{}); ok {
for name, rv := range rm {
if r, ok := rv.(map[string]interface{}); ok {
if _, hasRule := r["rule"]; !hasRule {
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: missing required field 'rule'", name)})
}
if _, hasService := r["service"]; !hasService {
// service is required unless it's a middleware chain? For MVP require service
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: missing required field 'service'", name)})
}
if rule, ok := r["rule"].(string); ok && strings.TrimSpace(rule) == "" {
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: rule must not be empty", name)})
}
} else {
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.routers.%q: must be a mapping", name)})
}
}
} else {
errs = append(errs, ValidationError{Message: "http.routers must be a mapping"})
}
}
if services, ok := m["services"]; ok {
if sm, ok := services.(map[string]interface{}); ok {
for name, sv := range sm {
if s, ok := sv.(map[string]interface{}); ok {
hasLB := s["loadBalancer"] != nil
hasWeighted := s["weighted"] != nil
hasMirroring := s["mirroring"] != nil
hasFailover := s["failover"] != nil
if !hasLB && !hasWeighted && !hasMirroring && !hasFailover {
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q: must define one of loadBalancer, weighted, mirroring, failover", name)})
}
if lb, ok := s["loadBalancer"]; ok && lb != nil {
if lbm, ok := lb.(map[string]interface{}); ok {
if servers, ok := lbm["servers"]; ok {
if arr, ok := servers.([]interface{}); ok {
if len(arr) == 0 {
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q.loadBalancer.servers: must not be empty", name)})
}
for i, srv := range arr {
if sm, ok := srv.(map[string]interface{}); ok {
if _, hasURL := sm["url"]; !hasURL {
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q.loadBalancer.servers[%d]: missing 'url'", name, i)})
}
}
}
}
}
}
}
} else {
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.services.%q: must be a mapping", name)})
}
}
} else {
errs = append(errs, ValidationError{Message: "http.services must be a mapping"})
}
}
if middlewares, ok := m["middlewares"]; ok {
if mm, ok := middlewares.(map[string]interface{}); ok {
for name, mv := range mm {
if _, ok := mv.(map[string]interface{}); !ok {
errs = append(errs, ValidationError{Message: fmt.Sprintf("http.middlewares.%q: must be a mapping", name)})
}
}
} else {
errs = append(errs, ValidationError{Message: "http.middlewares must be a mapping"})
}
}
return errs
}
func validateTCPBlock(raw interface{}) []ValidationError {
if raw == nil {
return nil
}
m, ok := raw.(map[string]interface{})
if !ok {
return []ValidationError{{Message: "tcp must be a mapping"}}
}
var errs []ValidationError
allowed := map[string]bool{"routers": true, "services": true, "middlewares": true, "serversTransports": true}
for k := range m {
if !allowed[k] {
errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp: unknown key %q", k)})
}
}
if routers, ok := m["routers"]; ok {
if rm, ok := routers.(map[string]interface{}); ok {
for name, rv := range rm {
if r, ok := rv.(map[string]interface{}); ok {
if _, hasRule := r["rule"]; !hasRule {
errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp.routers.%q: missing 'rule'", name)})
}
if _, hasService := r["service"]; !hasService {
errs = append(errs, ValidationError{Message: fmt.Sprintf("tcp.routers.%q: missing 'service'", name)})
}
}
}
}
}
return errs
}
func validateUDPBlock(raw interface{}) []ValidationError {
if raw == nil {
return nil
}
m, ok := raw.(map[string]interface{})
if !ok {
return []ValidationError{{Message: "udp must be a mapping"}}
}
var errs []ValidationError
allowed := map[string]bool{"routers": true, "services": true}
for k := range m {
if !allowed[k] {
errs = append(errs, ValidationError{Message: fmt.Sprintf("udp: unknown key %q", k)})
}
}
return errs
}
func validateTLSBlock(raw interface{}) []ValidationError {
if raw == nil {
return nil
}
m, ok := raw.(map[string]interface{})
if !ok {
return []ValidationError{{Message: "tls must be a mapping"}}
}
var errs []ValidationError
allowed := map[string]bool{"certificates": true, "options": true, "stores": true}
for k := range m {
if !allowed[k] {
errs = append(errs, ValidationError{Message: fmt.Sprintf("tls: unknown key %q", k)})
}
}
return errs
}
// ValidateFilename ensures filename is safe and within dynamic dir
func ValidateFilename(filename string) error {
if filename == "" {
return fmt.Errorf("filename must not be empty")
}
if strings.Contains(filename, "..") {
return fmt.Errorf("filename must not contain '..'")
}
if strings.Contains(filename, "/") || strings.Contains(filename, "\\") {
return fmt.Errorf("filename must not contain path separators — use a single file name")
}
// Must end with allowed extension
lower := strings.ToLower(filename)
if !(strings.HasSuffix(lower, ".yml") || strings.HasSuffix(lower, ".yaml") || strings.HasSuffix(lower, ".toml")) {
return fmt.Errorf("filename must end with .yml, .yaml, or .toml")
}
if len(filename) > 255 {
return fmt.Errorf("filename too long")
}
return nil
}