- 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
378 lines
11 KiB
Go
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
|
|
}
|