295 lines
10 KiB
Go
295 lines
10 KiB
Go
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 performs syntax and structural validation of Traefik dynamic file-provider content.
|
|
// NOTE: This is NOT complete Traefik schema validation — it checks YAML syntax, top-level keys,
|
|
// and structural invariants (e.g., router requires rule+service, service requires loadBalancer/weighted,
|
|
// loadBalancer servers require url). Full Traefik CRD/schema validation (e.g., router rule grammar,
|
|
// middleware option types, TLS option values, unknown deeply-nested fields) is not exhaustive and
|
|
// should be considered syntax+structural validation. Traefik itself will still reject semantically
|
|
// invalid configs on reload; the GUI surfaces unified diff and Traefik status via /api/traefik/*.
|
|
// Rejects empty, dangerous, or structurally invalid configs.
|
|
// Allows only dynamic config top-level keys: http, tcp, udp, tls.
|
|
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
|
|
}
|