Files
Charon/backend/internal/caddy/importer.go
GitHub Actions af8384046c chore: implement instruction compliance remediation
- Replace Go interface{} with any (Go 1.18+ standard)
- Add database indexes to frequently queried model fields
- Add JSDoc documentation to frontend API client methods
- Remove deprecated docker-compose version keys
- Add concurrency groups to all 25 GitHub Actions workflows
- Add YAML front matter and fix H1→H2 headings in docs

Coverage: Backend 85.5%, Frontend 87.73%
Security: No vulnerabilities detected

Refs: docs/plans/instruction_compliance_spec.md
2025-12-21 04:08:42 +00:00

378 lines
11 KiB
Go

package caddy
import (
"encoding/json"
"errors"
"fmt"
"net"
"os"
"os/exec"
"path/filepath"
"strings"
"github.com/Wikid82/charon/backend/internal/models"
)
// Executor defines an interface for executing shell commands.
type Executor interface {
Execute(name string, args ...string) ([]byte, error)
}
// DefaultExecutor implements Executor using os/exec.
type DefaultExecutor struct{}
func (e *DefaultExecutor) Execute(name string, args ...string) ([]byte, error) {
return exec.Command(name, args...).Output()
}
// CaddyConfig represents the root structure of Caddy's JSON config.
type CaddyConfig struct {
Apps *CaddyApps `json:"apps,omitempty"`
}
// CaddyApps contains application-specific configurations.
type CaddyApps struct {
HTTP *CaddyHTTP `json:"http,omitempty"`
}
// CaddyHTTP represents the HTTP app configuration.
type CaddyHTTP struct {
Servers map[string]*CaddyServer `json:"servers,omitempty"`
}
// CaddyServer represents a single server configuration.
type CaddyServer struct {
Listen []string `json:"listen,omitempty"`
Routes []*CaddyRoute `json:"routes,omitempty"`
TLSConnectionPolicies any `json:"tls_connection_policies,omitempty"`
}
// CaddyRoute represents a single route with matchers and handlers.
type CaddyRoute struct {
Match []*CaddyMatcher `json:"match,omitempty"`
Handle []*CaddyHandler `json:"handle,omitempty"`
}
// CaddyMatcher represents route matching criteria.
type CaddyMatcher struct {
Host []string `json:"host,omitempty"`
}
// CaddyHandler represents a handler in the route.
type CaddyHandler struct {
Handler string `json:"handler"`
Upstreams any `json:"upstreams,omitempty"`
Headers any `json:"headers,omitempty"`
Routes any `json:"routes,omitempty"` // For subroute handlers
}
// ParsedHost represents a single host detected during Caddyfile import.
type ParsedHost struct {
DomainNames string `json:"domain_names"`
ForwardScheme string `json:"forward_scheme"`
ForwardHost string `json:"forward_host"`
ForwardPort int `json:"forward_port"`
SSLForced bool `json:"ssl_forced"`
WebsocketSupport bool `json:"websocket_support"`
RawJSON string `json:"raw_json"` // Original Caddy JSON for this route
Warnings []string `json:"warnings"` // Unsupported features
}
// ImportResult contains parsed hosts and detected conflicts.
type ImportResult struct {
Hosts []ParsedHost `json:"hosts"`
Conflicts []string `json:"conflicts"`
Errors []string `json:"errors"`
}
// Importer handles Caddyfile parsing and conversion to Charon models.
type Importer struct {
caddyBinaryPath string
executor Executor
}
// NewImporter creates a new Caddyfile importer.
func NewImporter(binaryPath string) *Importer {
if binaryPath == "" {
binaryPath = "caddy" // Default to PATH
}
return &Importer{
caddyBinaryPath: binaryPath,
executor: &DefaultExecutor{},
}
}
// forceSplitFallback used in tests to exercise the fallback branch
var forceSplitFallback bool
// ParseCaddyfile reads a Caddyfile and converts it to Caddy JSON.
func (i *Importer) ParseCaddyfile(caddyfilePath string) ([]byte, error) {
// Sanitize the incoming path to detect forbidden traversal sequences.
clean := filepath.Clean(caddyfilePath)
if clean == "" || clean == "." {
return nil, fmt.Errorf("invalid caddyfile path")
}
if strings.Contains(clean, ".."+string(os.PathSeparator)) || strings.HasPrefix(clean, "..") {
return nil, fmt.Errorf("invalid caddyfile path")
}
if _, err := os.Stat(clean); os.IsNotExist(err) {
return nil, fmt.Errorf("caddyfile not found: %s", clean)
}
output, err := i.executor.Execute(i.caddyBinaryPath, "adapt", "--config", clean, "--adapter", "caddyfile")
if err != nil {
return nil, fmt.Errorf("caddy adapt failed: %w (output: %s)", err, string(output))
}
return output, nil
}
// extractHandlers recursively extracts handlers from a list, flattening subroutes.
func (i *Importer) extractHandlers(handles []*CaddyHandler) []*CaddyHandler {
var result []*CaddyHandler
for _, handler := range handles {
// Regular handler, add it directly
if handler.Handler != "subroute" {
result = append(result, handler)
continue
}
// It's a subroute; extract handlers from its first route
routes, ok := handler.Routes.([]any)
if !ok || len(routes) == 0 {
continue
}
subroute, ok := routes[0].(map[string]any)
if !ok {
continue
}
subhandles, ok := subroute["handle"].([]any)
if !ok {
continue
}
// Convert the subhandles to CaddyHandler objects
for _, sh := range subhandles {
shMap, ok := sh.(map[string]any)
if !ok {
continue
}
subHandler := &CaddyHandler{}
if handlerType, ok := shMap["handler"].(string); ok {
subHandler.Handler = handlerType
}
if upstreams, ok := shMap["upstreams"]; ok {
subHandler.Upstreams = upstreams
}
if headers, ok := shMap["headers"]; ok {
subHandler.Headers = headers
}
result = append(result, subHandler)
}
}
return result
}
// ExtractHosts parses Caddy JSON and extracts proxy host information.
func (i *Importer) ExtractHosts(caddyJSON []byte) (*ImportResult, error) {
var config CaddyConfig
if err := json.Unmarshal(caddyJSON, &config); err != nil {
return nil, fmt.Errorf("parsing caddy json: %w", err)
}
result := &ImportResult{
Hosts: []ParsedHost{},
Conflicts: []string{},
Errors: []string{},
}
if config.Apps == nil || config.Apps.HTTP == nil || config.Apps.HTTP.Servers == nil {
return result, nil // Empty config
}
seenDomains := make(map[string]bool)
for serverName, server := range config.Apps.HTTP.Servers {
// Detect if this server uses SSL based on listen address or TLS policies
serverUsesSSL := server.TLSConnectionPolicies != nil
for _, listenAddr := range server.Listen {
// Check if listening on :443 or any HTTPS port indicator
if strings.Contains(listenAddr, ":443") || strings.HasSuffix(listenAddr, "443") {
serverUsesSSL = true
break
}
}
for routeIdx, route := range server.Routes {
for _, match := range route.Match {
for _, hostMatcher := range match.Host {
domain := hostMatcher
// Check for duplicate domains (report domain names only)
if seenDomains[domain] {
result.Conflicts = append(result.Conflicts, domain)
continue
}
seenDomains[domain] = true
// Extract reverse proxy handler
host := ParsedHost{
DomainNames: domain,
SSLForced: strings.HasPrefix(domain, "https") || serverUsesSSL,
}
// Find reverse_proxy handler (may be nested in subroute)
handlers := i.extractHandlers(route.Handle)
for _, handler := range handlers {
if handler.Handler == "reverse_proxy" {
upstreams, _ := handler.Upstreams.([]any)
if len(upstreams) > 0 {
if upstream, ok := upstreams[0].(map[string]any); ok {
dial, _ := upstream["dial"].(string)
if dial != "" {
hostStr, portStr, err := net.SplitHostPort(dial)
if err == nil && !forceSplitFallback {
host.ForwardHost = hostStr
if _, err := fmt.Sscanf(portStr, "%d", &host.ForwardPort); err != nil {
host.ForwardPort = 80
}
} else {
// Fallback: assume dial is just the host or has some other format
// Try to handle simple "host:port" manually if net.SplitHostPort failed for some reason
// or assume it's just a host
parts := strings.Split(dial, ":")
if len(parts) == 2 {
host.ForwardHost = parts[0]
if _, err := fmt.Sscanf(parts[1], "%d", &host.ForwardPort); err != nil {
host.ForwardPort = 80
}
} else {
host.ForwardHost = dial
host.ForwardPort = 80
}
}
}
}
}
// Check for websocket support
if headers, ok := handler.Headers.(map[string]any); ok {
if upgrade, ok := headers["Upgrade"].([]any); ok {
for _, v := range upgrade {
if v == "websocket" {
host.WebsocketSupport = true
break
}
}
}
}
// Default scheme
host.ForwardScheme = "http"
if host.SSLForced {
host.ForwardScheme = "https"
}
}
// Detect unsupported features
if handler.Handler == "rewrite" {
host.Warnings = append(host.Warnings, "Rewrite rules not supported - manual configuration required")
}
if handler.Handler == "file_server" {
host.Warnings = append(host.Warnings, "File server directives not supported")
}
}
// Store raw JSON for this route
routeJSON, _ := json.Marshal(map[string]any{
"server": serverName,
"route": routeIdx,
"data": route,
})
host.RawJSON = string(routeJSON)
result.Hosts = append(result.Hosts, host)
}
}
}
}
return result, nil
}
// ImportFile performs complete import: parse Caddyfile and extract hosts.
func (i *Importer) ImportFile(caddyfilePath string) (*ImportResult, error) {
caddyJSON, err := i.ParseCaddyfile(caddyfilePath)
if err != nil {
return nil, err
}
return i.ExtractHosts(caddyJSON)
}
// ConvertToProxyHosts converts parsed hosts to ProxyHost models.
func ConvertToProxyHosts(parsedHosts []ParsedHost) []models.ProxyHost {
hosts := make([]models.ProxyHost, 0, len(parsedHosts))
for _, parsed := range parsedHosts {
if parsed.ForwardHost == "" || parsed.ForwardPort == 0 {
continue // Skip invalid entries
}
hosts = append(hosts, models.ProxyHost{
Name: parsed.DomainNames, // Can be customized by user during review
DomainNames: parsed.DomainNames,
ForwardScheme: parsed.ForwardScheme,
ForwardHost: parsed.ForwardHost,
ForwardPort: parsed.ForwardPort,
SSLForced: parsed.SSLForced,
WebsocketSupport: parsed.WebsocketSupport,
})
}
return hosts
}
// ValidateCaddyBinary checks if the Caddy binary is available.
func (i *Importer) ValidateCaddyBinary() error {
_, err := i.executor.Execute(i.caddyBinaryPath, "version")
if err != nil {
return errors.New("caddy binary not found or not executable")
}
return nil
}
// BackupCaddyfile creates a timestamped backup of the original Caddyfile.
func BackupCaddyfile(originalPath, backupDir string) (string, error) {
if err := os.MkdirAll(backupDir, 0o755); err != nil {
return "", fmt.Errorf("creating backup directory: %w", err)
}
timestamp := fmt.Sprintf("%d", os.Getpid()) // Simple timestamp placeholder
// Ensure the backup path is contained within backupDir to prevent path traversal
backupFile := fmt.Sprintf("Caddyfile.%s.backup", timestamp)
// Create a safe join with backupDir
backupPath := filepath.Join(backupDir, backupFile)
// Validate the original path: avoid traversal elements pointing outside backupDir
clean := filepath.Clean(originalPath)
if clean == "" || clean == "." {
return "", fmt.Errorf("invalid original path")
}
if strings.Contains(clean, ".."+string(os.PathSeparator)) || strings.HasPrefix(clean, "..") {
return "", fmt.Errorf("invalid original path")
}
input, err := os.ReadFile(clean)
if err != nil {
return "", fmt.Errorf("reading original file: %w", err)
}
if err := os.WriteFile(backupPath, input, 0o644); err != nil {
return "", fmt.Errorf("writing backup: %w", err)
}
return backupPath, nil
}