chore: remove cached
This commit is contained in:
@@ -1,101 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"io"
|
||||
"net/http"
|
||||
"time"
|
||||
)
|
||||
|
||||
// Client wraps the Caddy admin API.
|
||||
type Client struct {
|
||||
baseURL string
|
||||
httpClient *http.Client
|
||||
}
|
||||
|
||||
// NewClient creates a Caddy API client.
|
||||
func NewClient(adminAPIURL string) *Client {
|
||||
return &Client{
|
||||
baseURL: adminAPIURL,
|
||||
httpClient: &http.Client{
|
||||
Timeout: 30 * time.Second,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Load atomically replaces Caddy's entire configuration.
|
||||
// This is the primary method for applying configuration changes.
|
||||
func (c *Client) Load(ctx context.Context, config *Config) error {
|
||||
body, err := json.Marshal(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodPost, c.baseURL+"/load", bytes.NewReader(body))
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
req.Header.Set("Content-Type", "application/json")
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return fmt.Errorf("caddy returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// GetConfig retrieves the current running configuration from Caddy.
|
||||
func (c *Client) GetConfig(ctx context.Context) (*Config, error) {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", nil)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("execute request: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
bodyBytes, _ := io.ReadAll(resp.Body)
|
||||
return nil, fmt.Errorf("caddy returned status %d: %s", resp.StatusCode, string(bodyBytes))
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := json.NewDecoder(resp.Body).Decode(&config); err != nil {
|
||||
return nil, fmt.Errorf("decode response: %w", err)
|
||||
}
|
||||
|
||||
return &config, nil
|
||||
}
|
||||
|
||||
// Ping checks if Caddy admin API is reachable.
|
||||
func (c *Client) Ping(ctx context.Context) error {
|
||||
req, err := http.NewRequestWithContext(ctx, http.MethodGet, c.baseURL+"/config/", nil)
|
||||
if err != nil {
|
||||
return fmt.Errorf("create request: %w", err)
|
||||
}
|
||||
|
||||
resp, err := c.httpClient.Do(req)
|
||||
if err != nil {
|
||||
return fmt.Errorf("caddy unreachable: %w", err)
|
||||
}
|
||||
defer resp.Body.Close()
|
||||
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return fmt.Errorf("caddy returned status %d", resp.StatusCode)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,95 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestClient_Load_Success(t *testing.T) {
|
||||
// Mock Caddy admin API
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/load", r.URL.Path)
|
||||
require.Equal(t, http.MethodPost, r.Method)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
config, _ := GenerateConfig([]models.ProxyHost{
|
||||
{
|
||||
UUID: "test",
|
||||
DomainNames: "test.com",
|
||||
ForwardHost: "app",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
},
|
||||
}, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
|
||||
err := client.Load(context.Background(), config)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClient_Load_Failure(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
w.Write([]byte(`{"error": "invalid config"}`))
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
config := &Config{}
|
||||
|
||||
err := client.Load(context.Background(), config)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "400")
|
||||
}
|
||||
|
||||
func TestClient_GetConfig_Success(t *testing.T) {
|
||||
testConfig := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{
|
||||
"test": {Listen: []string{":80"}},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
require.Equal(t, "/config/", r.URL.Path)
|
||||
require.Equal(t, http.MethodGet, r.Method)
|
||||
w.WriteHeader(http.StatusOK)
|
||||
json.NewEncoder(w).Encode(testConfig)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
config, err := client.GetConfig(context.Background())
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
require.NotNil(t, config.Apps.HTTP)
|
||||
}
|
||||
|
||||
func TestClient_Ping_Success(t *testing.T) {
|
||||
server := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer server.Close()
|
||||
|
||||
client := NewClient(server.URL)
|
||||
err := client.Ping(context.Background())
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestClient_Ping_Unreachable(t *testing.T) {
|
||||
client := NewClient("http://localhost:9999")
|
||||
err := client.Ping(context.Background())
|
||||
require.Error(t, err)
|
||||
}
|
||||
@@ -1,255 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
// GenerateConfig creates a Caddy JSON configuration from proxy hosts.
|
||||
// This is the core transformation layer from our database model to Caddy config.
|
||||
func GenerateConfig(hosts []models.ProxyHost, storageDir string, acmeEmail string, frontendDir string, sslProvider string) (*Config, error) {
|
||||
// Define log file paths
|
||||
// We assume storageDir is like ".../data/caddy/data", so we go up to ".../data/logs"
|
||||
// storageDir is .../data/caddy/data
|
||||
// Dir -> .../data/caddy
|
||||
// Dir -> .../data
|
||||
logDir := filepath.Join(filepath.Dir(filepath.Dir(storageDir)), "logs")
|
||||
logFile := filepath.Join(logDir, "access.log")
|
||||
|
||||
config := &Config{
|
||||
Logging: &LoggingConfig{
|
||||
Logs: map[string]*LogConfig{
|
||||
"access": {
|
||||
Level: "DEBUG",
|
||||
Writer: &WriterConfig{
|
||||
Output: "file",
|
||||
Filename: logFile,
|
||||
Roll: true,
|
||||
RollSize: 10, // 10 MB
|
||||
RollKeep: 5, // Keep 5 files
|
||||
RollKeepDays: 7, // Keep for 7 days
|
||||
},
|
||||
Encoder: &EncoderConfig{
|
||||
Format: "json",
|
||||
},
|
||||
Include: []string{"http.log.access.access_log"},
|
||||
},
|
||||
},
|
||||
},
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{},
|
||||
},
|
||||
},
|
||||
Storage: Storage{
|
||||
System: "file_system",
|
||||
Root: storageDir,
|
||||
},
|
||||
}
|
||||
|
||||
if acmeEmail != "" {
|
||||
var issuers []interface{}
|
||||
|
||||
// Configure issuers based on provider preference
|
||||
switch sslProvider {
|
||||
case "letsencrypt":
|
||||
issuers = append(issuers, map[string]interface{}{
|
||||
"module": "acme",
|
||||
"email": acmeEmail,
|
||||
})
|
||||
case "zerossl":
|
||||
issuers = append(issuers, map[string]interface{}{
|
||||
"module": "zerossl",
|
||||
})
|
||||
default: // "both" or empty
|
||||
issuers = append(issuers, map[string]interface{}{
|
||||
"module": "acme",
|
||||
"email": acmeEmail,
|
||||
})
|
||||
issuers = append(issuers, map[string]interface{}{
|
||||
"module": "zerossl",
|
||||
})
|
||||
}
|
||||
|
||||
config.Apps.TLS = &TLSApp{
|
||||
Automation: &AutomationConfig{
|
||||
Policies: []*AutomationPolicy{
|
||||
{
|
||||
IssuersRaw: issuers,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Collect custom certificates
|
||||
customCerts := make(map[uint]models.SSLCertificate)
|
||||
for _, host := range hosts {
|
||||
if host.CertificateID != nil && host.Certificate != nil {
|
||||
customCerts[*host.CertificateID] = *host.Certificate
|
||||
}
|
||||
}
|
||||
|
||||
if len(customCerts) > 0 {
|
||||
var loadPEM []LoadPEMConfig
|
||||
for _, cert := range customCerts {
|
||||
loadPEM = append(loadPEM, LoadPEMConfig{
|
||||
Certificate: cert.Certificate,
|
||||
Key: cert.PrivateKey,
|
||||
Tags: []string{cert.UUID},
|
||||
})
|
||||
}
|
||||
|
||||
if config.Apps.TLS == nil {
|
||||
config.Apps.TLS = &TLSApp{}
|
||||
}
|
||||
config.Apps.TLS.Certificates = &CertificatesConfig{
|
||||
LoadPEM: loadPEM,
|
||||
}
|
||||
}
|
||||
|
||||
if len(hosts) == 0 && frontendDir == "" {
|
||||
return config, nil
|
||||
}
|
||||
|
||||
// Initialize routes slice
|
||||
routes := make([]*Route, 0)
|
||||
|
||||
// Track processed domains to prevent duplicates (Ghost Host fix)
|
||||
processedDomains := make(map[string]bool)
|
||||
|
||||
// Sort hosts by UpdatedAt desc to prefer newer configs in case of duplicates
|
||||
// Note: This assumes the input slice is already sorted or we don't care about order beyond duplicates
|
||||
// The caller (ApplyConfig) fetches all hosts. We should probably sort them here or there.
|
||||
// For now, we'll just process them. If we encounter a duplicate domain, we skip it.
|
||||
// To ensure we keep the *latest* one, we should iterate in reverse or sort.
|
||||
// But ApplyConfig uses db.Find(&hosts), which usually returns by ID asc.
|
||||
// So later IDs (newer) come last.
|
||||
// We want to keep the NEWER one.
|
||||
// So we should iterate backwards? Or just overwrite?
|
||||
// Caddy config structure is a list of servers/routes.
|
||||
// If we have multiple routes matching the same host, Caddy uses the first one?
|
||||
// Actually, Caddy matches routes in order.
|
||||
// If we emit two routes for "example.com", the first one will catch it.
|
||||
// So we want the NEWEST one to be FIRST in the list?
|
||||
// Or we want to only emit ONE route for "example.com".
|
||||
// If we emit only one, it should be the newest one.
|
||||
// So we should process hosts from newest to oldest, and skip duplicates.
|
||||
|
||||
// Let's iterate in reverse order (assuming input is ID ASC)
|
||||
for i := len(hosts) - 1; i >= 0; i-- {
|
||||
host := hosts[i]
|
||||
|
||||
if !host.Enabled {
|
||||
continue
|
||||
}
|
||||
|
||||
if host.DomainNames == "" {
|
||||
// Log warning?
|
||||
continue
|
||||
}
|
||||
|
||||
// Parse comma-separated domains
|
||||
rawDomains := strings.Split(host.DomainNames, ",")
|
||||
var uniqueDomains []string
|
||||
|
||||
for _, d := range rawDomains {
|
||||
d = strings.TrimSpace(d)
|
||||
d = strings.ToLower(d) // Normalize to lowercase
|
||||
if d == "" {
|
||||
continue
|
||||
}
|
||||
if processedDomains[d] {
|
||||
fmt.Printf("Warning: Skipping duplicate domain %s for host %s (Ghost Host detection)\n", d, host.UUID)
|
||||
continue
|
||||
}
|
||||
processedDomains[d] = true
|
||||
uniqueDomains = append(uniqueDomains, d)
|
||||
}
|
||||
|
||||
if len(uniqueDomains) == 0 {
|
||||
continue
|
||||
}
|
||||
|
||||
// Build handlers for this host
|
||||
handlers := make([]Handler, 0)
|
||||
|
||||
// Add HSTS header if enabled
|
||||
if host.HSTSEnabled {
|
||||
hstsValue := "max-age=31536000"
|
||||
if host.HSTSSubdomains {
|
||||
hstsValue += "; includeSubDomains"
|
||||
}
|
||||
handlers = append(handlers, HeaderHandler(map[string][]string{
|
||||
"Strict-Transport-Security": {hstsValue},
|
||||
}))
|
||||
}
|
||||
|
||||
// Add exploit blocking if enabled
|
||||
if host.BlockExploits {
|
||||
handlers = append(handlers, BlockExploitsHandler())
|
||||
}
|
||||
|
||||
// Handle custom locations first (more specific routes)
|
||||
for _, loc := range host.Locations {
|
||||
dial := fmt.Sprintf("%s:%d", loc.ForwardHost, loc.ForwardPort)
|
||||
locRoute := &Route{
|
||||
Match: []Match{
|
||||
{
|
||||
Host: uniqueDomains,
|
||||
Path: []string{loc.Path, loc.Path + "/*"},
|
||||
},
|
||||
},
|
||||
Handle: []Handler{
|
||||
ReverseProxyHandler(dial, host.WebsocketSupport),
|
||||
},
|
||||
Terminal: true,
|
||||
}
|
||||
routes = append(routes, locRoute)
|
||||
}
|
||||
|
||||
// Main proxy handler
|
||||
dial := fmt.Sprintf("%s:%d", host.ForwardHost, host.ForwardPort)
|
||||
mainHandlers := append(handlers, ReverseProxyHandler(dial, host.WebsocketSupport))
|
||||
|
||||
route := &Route{
|
||||
Match: []Match{
|
||||
{Host: uniqueDomains},
|
||||
},
|
||||
Handle: mainHandlers,
|
||||
Terminal: true,
|
||||
}
|
||||
|
||||
routes = append(routes, route)
|
||||
}
|
||||
|
||||
// Add catch-all 404 handler
|
||||
// This matches any request that wasn't handled by previous routes
|
||||
if frontendDir != "" {
|
||||
catchAllRoute := &Route{
|
||||
Handle: []Handler{
|
||||
RewriteHandler("/unknown.html"),
|
||||
FileServerHandler(frontendDir),
|
||||
},
|
||||
Terminal: true,
|
||||
}
|
||||
routes = append(routes, catchAllRoute)
|
||||
}
|
||||
|
||||
config.Apps.HTTP.Servers["cpm_server"] = &Server{
|
||||
Listen: []string{":80", ":443"},
|
||||
Routes: routes,
|
||||
AutoHTTPS: &AutoHTTPSConfig{
|
||||
Disable: false,
|
||||
DisableRedir: false,
|
||||
},
|
||||
Logs: &ServerLogs{
|
||||
DefaultLoggerName: "access_log",
|
||||
},
|
||||
}
|
||||
|
||||
return config, nil
|
||||
}
|
||||
@@ -1,192 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestGenerateConfig_Empty(t *testing.T) {
|
||||
config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
require.NotNil(t, config.Apps.HTTP)
|
||||
require.Empty(t, config.Apps.HTTP.Servers)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_SingleHost(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "test-uuid",
|
||||
Name: "Media",
|
||||
DomainNames: "media.example.com",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "media",
|
||||
ForwardPort: 32400,
|
||||
SSLForced: true,
|
||||
WebsocketSupport: false,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
require.NotNil(t, config.Apps.HTTP)
|
||||
require.Len(t, config.Apps.HTTP.Servers, 1)
|
||||
|
||||
server := config.Apps.HTTP.Servers["cpm_server"]
|
||||
require.NotNil(t, server)
|
||||
require.Contains(t, server.Listen, ":80")
|
||||
require.Contains(t, server.Listen, ":443")
|
||||
require.Len(t, server.Routes, 1)
|
||||
|
||||
route := server.Routes[0]
|
||||
require.Len(t, route.Match, 1)
|
||||
require.Equal(t, []string{"media.example.com"}, route.Match[0].Host)
|
||||
require.Len(t, route.Handle, 1)
|
||||
require.True(t, route.Terminal)
|
||||
|
||||
handler := route.Handle[0]
|
||||
require.Equal(t, "reverse_proxy", handler["handler"])
|
||||
}
|
||||
|
||||
func TestGenerateConfig_MultipleHosts(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "uuid-1",
|
||||
DomainNames: "site1.example.com",
|
||||
ForwardHost: "app1",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
},
|
||||
{
|
||||
UUID: "uuid-2",
|
||||
DomainNames: "site2.example.com",
|
||||
ForwardHost: "app2",
|
||||
ForwardPort: 8081,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
require.Len(t, config.Apps.HTTP.Servers["cpm_server"].Routes, 2)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_WebSocketEnabled(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "uuid-ws",
|
||||
DomainNames: "ws.example.com",
|
||||
ForwardHost: "wsapp",
|
||||
ForwardPort: 3000,
|
||||
WebsocketSupport: true,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
route := config.Apps.HTTP.Servers["cpm_server"].Routes[0]
|
||||
handler := route.Handle[0]
|
||||
|
||||
// Check WebSocket headers are present
|
||||
require.NotNil(t, handler["headers"])
|
||||
}
|
||||
|
||||
func TestGenerateConfig_EmptyDomain(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "bad-uuid",
|
||||
DomainNames: "",
|
||||
ForwardHost: "app",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
// Should produce empty routes (or just catch-all if frontendDir was set, but it's empty here)
|
||||
require.Empty(t, config.Apps.HTTP.Servers["cpm_server"].Routes)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_Logging(t *testing.T) {
|
||||
hosts := []models.ProxyHost{}
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
|
||||
// Verify logging configuration
|
||||
require.NotNil(t, config.Logging)
|
||||
require.NotNil(t, config.Logging.Logs)
|
||||
require.NotNil(t, config.Logging.Logs["access"])
|
||||
require.Equal(t, "DEBUG", config.Logging.Logs["access"].Level)
|
||||
require.Contains(t, config.Logging.Logs["access"].Writer.Filename, "access.log")
|
||||
require.Equal(t, 10, config.Logging.Logs["access"].Writer.RollSize)
|
||||
require.Equal(t, 5, config.Logging.Logs["access"].Writer.RollKeep)
|
||||
require.Equal(t, 7, config.Logging.Logs["access"].Writer.RollKeepDays)
|
||||
}
|
||||
|
||||
func TestGenerateConfig_Advanced(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "advanced-uuid",
|
||||
Name: "Advanced",
|
||||
DomainNames: "advanced.example.com",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "advanced",
|
||||
ForwardPort: 8080,
|
||||
SSLForced: true,
|
||||
HSTSEnabled: true,
|
||||
HSTSSubdomains: true,
|
||||
BlockExploits: true,
|
||||
Enabled: true,
|
||||
Locations: []models.Location{
|
||||
{
|
||||
Path: "/api",
|
||||
ForwardHost: "api-service",
|
||||
ForwardPort: 9000,
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, config)
|
||||
|
||||
server := config.Apps.HTTP.Servers["cpm_server"]
|
||||
require.NotNil(t, server)
|
||||
// Should have 2 routes: 1 for location /api, 1 for main domain
|
||||
require.Len(t, server.Routes, 2)
|
||||
|
||||
// Check Location Route (should be first as it is more specific)
|
||||
locRoute := server.Routes[0]
|
||||
require.Equal(t, []string{"/api", "/api/*"}, locRoute.Match[0].Path)
|
||||
require.Equal(t, []string{"advanced.example.com"}, locRoute.Match[0].Host)
|
||||
|
||||
// Check Main Route
|
||||
mainRoute := server.Routes[1]
|
||||
require.Nil(t, mainRoute.Match[0].Path) // No path means all paths
|
||||
require.Equal(t, []string{"advanced.example.com"}, mainRoute.Match[0].Host)
|
||||
|
||||
// Check HSTS and BlockExploits handlers in main route
|
||||
// Handlers are: [HSTS, BlockExploits, ReverseProxy]
|
||||
// But wait, BlockExploitsHandler implementation details?
|
||||
// Let's just check count for now or inspect types if possible.
|
||||
// Based on code:
|
||||
// handlers = append(handlers, HeaderHandler(...)) // HSTS
|
||||
// handlers = append(handlers, BlockExploitsHandler()) // BlockExploits
|
||||
// mainHandlers = append(handlers, ReverseProxyHandler(...))
|
||||
|
||||
require.Len(t, mainRoute.Handle, 3)
|
||||
|
||||
// Check HSTS
|
||||
hstsHandler := mainRoute.Handle[0]
|
||||
require.Equal(t, "headers", hstsHandler["handler"])
|
||||
// We can't easily check the map content without casting, but we know it's there.
|
||||
}
|
||||
@@ -1,294 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"errors"
|
||||
"fmt"
|
||||
"net"
|
||||
"os"
|
||||
"os/exec"
|
||||
"path/filepath"
|
||||
"strings"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/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 {
|
||||
Routes []*CaddyRoute `json:"routes,omitempty"`
|
||||
TLSConnectionPolicies interface{} `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 interface{} `json:"upstreams,omitempty"`
|
||||
Headers interface{} `json:"headers,omitempty"`
|
||||
}
|
||||
|
||||
// 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 CPM+ 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{},
|
||||
}
|
||||
}
|
||||
|
||||
// ParseCaddyfile reads a Caddyfile and converts it to Caddy JSON.
|
||||
func (i *Importer) ParseCaddyfile(caddyfilePath string) ([]byte, error) {
|
||||
if _, err := os.Stat(caddyfilePath); os.IsNotExist(err) {
|
||||
return nil, fmt.Errorf("caddyfile not found: %s", caddyfilePath)
|
||||
}
|
||||
|
||||
output, err := i.executor.Execute(i.caddyBinaryPath, "adapt", "--config", caddyfilePath, "--adapter", "caddyfile")
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("caddy adapt failed: %w (output: %s)", err, string(output))
|
||||
}
|
||||
|
||||
return output, nil
|
||||
}
|
||||
|
||||
// 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 {
|
||||
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") || server.TLSConnectionPolicies != nil,
|
||||
}
|
||||
|
||||
// Find reverse_proxy handler
|
||||
for _, handler := range route.Handle {
|
||||
if handler.Handler == "reverse_proxy" {
|
||||
upstreams, _ := handler.Upstreams.([]interface{})
|
||||
if len(upstreams) > 0 {
|
||||
if upstream, ok := upstreams[0].(map[string]interface{}); ok {
|
||||
dial, _ := upstream["dial"].(string)
|
||||
if dial != "" {
|
||||
hostStr, portStr, err := net.SplitHostPort(dial)
|
||||
if err == nil {
|
||||
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]interface{}); ok {
|
||||
if upgrade, ok := headers["Upgrade"].([]interface{}); 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]interface{}{
|
||||
"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, 0755); err != nil {
|
||||
return "", fmt.Errorf("creating backup directory: %w", err)
|
||||
}
|
||||
|
||||
timestamp := fmt.Sprintf("%d", os.Getpid()) // Simple timestamp placeholder
|
||||
backupPath := filepath.Join(backupDir, fmt.Sprintf("Caddyfile.%s.backup", timestamp))
|
||||
|
||||
input, err := os.ReadFile(originalPath)
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("reading original file: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(backupPath, input, 0644); err != nil {
|
||||
return "", fmt.Errorf("writing backup: %w", err)
|
||||
}
|
||||
|
||||
return backupPath, nil
|
||||
}
|
||||
@@ -1,277 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestNewImporter(t *testing.T) {
|
||||
importer := NewImporter("/usr/bin/caddy")
|
||||
assert.NotNil(t, importer)
|
||||
assert.Equal(t, "/usr/bin/caddy", importer.caddyBinaryPath)
|
||||
|
||||
importerDefault := NewImporter("")
|
||||
assert.NotNil(t, importerDefault)
|
||||
assert.Equal(t, "caddy", importerDefault.caddyBinaryPath)
|
||||
}
|
||||
|
||||
func TestImporter_ParseCaddyfile_NotFound(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
_, err := importer.ParseCaddyfile("non-existent-file")
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "caddyfile not found")
|
||||
}
|
||||
|
||||
type MockExecutor struct {
|
||||
Output []byte
|
||||
Err error
|
||||
}
|
||||
|
||||
func (m *MockExecutor) Execute(name string, args ...string) ([]byte, error) {
|
||||
return m.Output, m.Err
|
||||
}
|
||||
|
||||
func TestImporter_ParseCaddyfile_Success(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
mockExecutor := &MockExecutor{
|
||||
Output: []byte(`{"apps": {"http": {"servers": {}}}}`),
|
||||
Err: nil,
|
||||
}
|
||||
importer.executor = mockExecutor
|
||||
|
||||
// Create a dummy file to bypass os.Stat check
|
||||
tmpFile := filepath.Join(t.TempDir(), "Caddyfile")
|
||||
err := os.WriteFile(tmpFile, []byte("foo"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
output, err := importer.ParseCaddyfile(tmpFile)
|
||||
assert.NoError(t, err)
|
||||
assert.JSONEq(t, `{"apps": {"http": {"servers": {}}}}`, string(output))
|
||||
}
|
||||
|
||||
func TestImporter_ParseCaddyfile_Failure(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
mockExecutor := &MockExecutor{
|
||||
Output: []byte("syntax error"),
|
||||
Err: assert.AnError,
|
||||
}
|
||||
importer.executor = mockExecutor
|
||||
|
||||
// Create a dummy file
|
||||
tmpFile := filepath.Join(t.TempDir(), "Caddyfile")
|
||||
err := os.WriteFile(tmpFile, []byte("foo"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
_, err = importer.ParseCaddyfile(tmpFile)
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "caddy adapt failed")
|
||||
}
|
||||
|
||||
func TestImporter_ExtractHosts(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
|
||||
// Test Case 1: Empty Config
|
||||
emptyJSON := []byte(`{}`)
|
||||
result, err := importer.ExtractHosts(emptyJSON)
|
||||
assert.NoError(t, err)
|
||||
assert.Empty(t, result.Hosts)
|
||||
|
||||
// Test Case 2: Invalid JSON
|
||||
invalidJSON := []byte(`{invalid`)
|
||||
_, err = importer.ExtractHosts(invalidJSON)
|
||||
assert.Error(t, err)
|
||||
|
||||
// Test Case 3: Valid Config with Reverse Proxy
|
||||
validJSON := []byte(`{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [{"host": ["example.com"]}],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": [{"dial": "127.0.0.1:8080"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
result, err = importer.ExtractHosts(validJSON)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result.Hosts, 1)
|
||||
assert.Equal(t, "example.com", result.Hosts[0].DomainNames)
|
||||
assert.Equal(t, "127.0.0.1", result.Hosts[0].ForwardHost)
|
||||
assert.Equal(t, 8080, result.Hosts[0].ForwardPort)
|
||||
|
||||
// Test Case 4: Duplicate Domain
|
||||
duplicateJSON := []byte(`{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [{"host": ["example.com"]}],
|
||||
"handle": [{"handler": "reverse_proxy"}]
|
||||
},
|
||||
{
|
||||
"match": [{"host": ["example.com"]}],
|
||||
"handle": [{"handler": "reverse_proxy"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
result, err = importer.ExtractHosts(duplicateJSON)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result.Hosts, 1)
|
||||
assert.Len(t, result.Conflicts, 1)
|
||||
assert.Equal(t, "example.com", result.Conflicts[0])
|
||||
|
||||
// Test Case 5: Unsupported Features
|
||||
unsupportedJSON := []byte(`{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [{"host": ["files.example.com"]}],
|
||||
"handle": [
|
||||
{"handler": "file_server"},
|
||||
{"handler": "rewrite"}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`)
|
||||
result, err = importer.ExtractHosts(unsupportedJSON)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result.Hosts, 1)
|
||||
assert.Len(t, result.Hosts[0].Warnings, 2)
|
||||
assert.Contains(t, result.Hosts[0].Warnings, "File server directives not supported")
|
||||
assert.Contains(t, result.Hosts[0].Warnings, "Rewrite rules not supported - manual configuration required")
|
||||
}
|
||||
|
||||
func TestImporter_ImportFile(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
mockExecutor := &MockExecutor{
|
||||
Output: []byte(`{
|
||||
"apps": {
|
||||
"http": {
|
||||
"servers": {
|
||||
"srv0": {
|
||||
"routes": [
|
||||
{
|
||||
"match": [{"host": ["example.com"]}],
|
||||
"handle": [
|
||||
{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": [{"dial": "127.0.0.1:8080"}]
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}`),
|
||||
Err: nil,
|
||||
}
|
||||
importer.executor = mockExecutor
|
||||
|
||||
// Create a dummy file
|
||||
tmpFile := filepath.Join(t.TempDir(), "Caddyfile")
|
||||
err := os.WriteFile(tmpFile, []byte("foo"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
result, err := importer.ImportFile(tmpFile)
|
||||
assert.NoError(t, err)
|
||||
assert.Len(t, result.Hosts, 1)
|
||||
assert.Equal(t, "example.com", result.Hosts[0].DomainNames)
|
||||
}
|
||||
|
||||
func TestConvertToProxyHosts(t *testing.T) {
|
||||
parsedHosts := []ParsedHost{
|
||||
{
|
||||
DomainNames: "example.com",
|
||||
ForwardScheme: "http",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
SSLForced: true,
|
||||
WebsocketSupport: true,
|
||||
},
|
||||
{
|
||||
DomainNames: "invalid.com",
|
||||
ForwardHost: "", // Invalid
|
||||
},
|
||||
}
|
||||
|
||||
hosts := ConvertToProxyHosts(parsedHosts)
|
||||
assert.Len(t, hosts, 1)
|
||||
assert.Equal(t, "example.com", hosts[0].DomainNames)
|
||||
assert.Equal(t, "127.0.0.1", hosts[0].ForwardHost)
|
||||
assert.Equal(t, 8080, hosts[0].ForwardPort)
|
||||
assert.True(t, hosts[0].SSLForced)
|
||||
assert.True(t, hosts[0].WebsocketSupport)
|
||||
}
|
||||
|
||||
func TestImporter_ValidateCaddyBinary(t *testing.T) {
|
||||
importer := NewImporter("caddy")
|
||||
|
||||
// Success
|
||||
importer.executor = &MockExecutor{Output: []byte("v2.0.0"), Err: nil}
|
||||
err := importer.ValidateCaddyBinary()
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Failure
|
||||
importer.executor = &MockExecutor{Output: nil, Err: assert.AnError}
|
||||
err = importer.ValidateCaddyBinary()
|
||||
assert.Error(t, err)
|
||||
assert.Equal(t, "caddy binary not found or not executable", err.Error())
|
||||
}
|
||||
|
||||
func TestBackupCaddyfile(t *testing.T) {
|
||||
tmpDir := t.TempDir()
|
||||
originalFile := filepath.Join(tmpDir, "Caddyfile")
|
||||
err := os.WriteFile(originalFile, []byte("original content"), 0644)
|
||||
assert.NoError(t, err)
|
||||
|
||||
backupDir := filepath.Join(tmpDir, "backups")
|
||||
|
||||
// Success
|
||||
backupPath, err := BackupCaddyfile(originalFile, backupDir)
|
||||
assert.NoError(t, err)
|
||||
assert.FileExists(t, backupPath)
|
||||
|
||||
content, err := os.ReadFile(backupPath)
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "original content", string(content))
|
||||
|
||||
// Failure - Source not found
|
||||
_, err = BackupCaddyfile("non-existent", backupDir)
|
||||
assert.Error(t, err)
|
||||
}
|
||||
|
||||
func TestDefaultExecutor_Execute(t *testing.T) {
|
||||
executor := &DefaultExecutor{}
|
||||
output, err := executor.Execute("echo", "hello")
|
||||
assert.NoError(t, err)
|
||||
assert.Equal(t, "hello\n", string(output))
|
||||
}
|
||||
@@ -1,221 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"crypto/sha256"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"sort"
|
||||
"time"
|
||||
|
||||
"gorm.io/gorm"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
// Manager orchestrates Caddy configuration lifecycle: generate, validate, apply, rollback.
|
||||
type Manager struct {
|
||||
client *Client
|
||||
db *gorm.DB
|
||||
configDir string
|
||||
frontendDir string
|
||||
}
|
||||
|
||||
// NewManager creates a configuration manager.
|
||||
func NewManager(client *Client, db *gorm.DB, configDir string, frontendDir string) *Manager {
|
||||
return &Manager{
|
||||
client: client,
|
||||
db: db,
|
||||
configDir: configDir,
|
||||
frontendDir: frontendDir,
|
||||
}
|
||||
}
|
||||
|
||||
// ApplyConfig generates configuration from database, validates it, applies to Caddy with rollback on failure.
|
||||
func (m *Manager) ApplyConfig(ctx context.Context) error {
|
||||
// Fetch all proxy hosts from database
|
||||
var hosts []models.ProxyHost
|
||||
if err := m.db.Preload("Locations").Preload("Certificate").Find(&hosts).Error; err != nil {
|
||||
return fmt.Errorf("fetch proxy hosts: %w", err)
|
||||
}
|
||||
|
||||
// Fetch ACME email setting
|
||||
var acmeEmailSetting models.Setting
|
||||
var acmeEmail string
|
||||
if err := m.db.Where("key = ?", "caddy.acme_email").First(&acmeEmailSetting).Error; err == nil {
|
||||
acmeEmail = acmeEmailSetting.Value
|
||||
}
|
||||
|
||||
// Fetch SSL Provider setting
|
||||
var sslProviderSetting models.Setting
|
||||
var sslProvider string
|
||||
if err := m.db.Where("key = ?", "caddy.ssl_provider").First(&sslProviderSetting).Error; err == nil {
|
||||
sslProvider = sslProviderSetting.Value
|
||||
}
|
||||
|
||||
// Generate Caddy config
|
||||
config, err := GenerateConfig(hosts, filepath.Join(m.configDir, "data"), acmeEmail, m.frontendDir, sslProvider)
|
||||
if err != nil {
|
||||
return fmt.Errorf("generate config: %w", err)
|
||||
}
|
||||
|
||||
// Validate before applying
|
||||
if err := Validate(config); err != nil {
|
||||
return fmt.Errorf("validation failed: %w", err)
|
||||
}
|
||||
|
||||
// Save snapshot for rollback
|
||||
snapshotPath, err := m.saveSnapshot(config)
|
||||
if err != nil {
|
||||
return fmt.Errorf("save snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Calculate config hash for audit trail
|
||||
configJSON, _ := json.Marshal(config)
|
||||
configHash := fmt.Sprintf("%x", sha256.Sum256(configJSON))
|
||||
|
||||
// Apply to Caddy
|
||||
if err := m.client.Load(ctx, config); err != nil {
|
||||
// Remove the failed snapshot so rollback uses the previous one
|
||||
os.Remove(snapshotPath)
|
||||
|
||||
// Rollback on failure
|
||||
if rollbackErr := m.rollback(ctx); rollbackErr != nil {
|
||||
// If rollback fails, we still want to record the failure
|
||||
m.recordConfigChange(configHash, false, err.Error())
|
||||
return fmt.Errorf("apply failed: %w, rollback also failed: %v", err, rollbackErr)
|
||||
}
|
||||
|
||||
// Record failed attempt
|
||||
m.recordConfigChange(configHash, false, err.Error())
|
||||
return fmt.Errorf("apply failed (rolled back): %w", err)
|
||||
}
|
||||
|
||||
// Record successful application
|
||||
m.recordConfigChange(configHash, true, "")
|
||||
|
||||
// Cleanup old snapshots (keep last 10)
|
||||
if err := m.rotateSnapshots(10); err != nil {
|
||||
// Non-fatal - log but don't fail
|
||||
fmt.Printf("warning: snapshot rotation failed: %v\n", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// saveSnapshot stores the config to disk with timestamp.
|
||||
func (m *Manager) saveSnapshot(config *Config) (string, error) {
|
||||
timestamp := time.Now().Unix()
|
||||
filename := fmt.Sprintf("config-%d.json", timestamp)
|
||||
path := filepath.Join(m.configDir, filename)
|
||||
|
||||
configJSON, err := json.MarshalIndent(config, "", " ")
|
||||
if err != nil {
|
||||
return "", fmt.Errorf("marshal config: %w", err)
|
||||
}
|
||||
|
||||
if err := os.WriteFile(path, configJSON, 0644); err != nil {
|
||||
return "", fmt.Errorf("write snapshot: %w", err)
|
||||
}
|
||||
|
||||
return path, nil
|
||||
}
|
||||
|
||||
// rollback loads the most recent snapshot from disk.
|
||||
func (m *Manager) rollback(ctx context.Context) error {
|
||||
snapshots, err := m.listSnapshots()
|
||||
if err != nil || len(snapshots) == 0 {
|
||||
return fmt.Errorf("no snapshots available for rollback")
|
||||
}
|
||||
|
||||
// Load most recent snapshot
|
||||
latestSnapshot := snapshots[len(snapshots)-1]
|
||||
configJSON, err := os.ReadFile(latestSnapshot)
|
||||
if err != nil {
|
||||
return fmt.Errorf("read snapshot: %w", err)
|
||||
}
|
||||
|
||||
var config Config
|
||||
if err := json.Unmarshal(configJSON, &config); err != nil {
|
||||
return fmt.Errorf("unmarshal snapshot: %w", err)
|
||||
}
|
||||
|
||||
// Apply the snapshot
|
||||
if err := m.client.Load(ctx, &config); err != nil {
|
||||
return fmt.Errorf("load snapshot: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// listSnapshots returns all snapshot file paths sorted by modification time.
|
||||
func (m *Manager) listSnapshots() ([]string, error) {
|
||||
entries, err := os.ReadDir(m.configDir)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("read config dir: %w", err)
|
||||
}
|
||||
|
||||
var snapshots []string
|
||||
for _, entry := range entries {
|
||||
if entry.IsDir() || filepath.Ext(entry.Name()) != ".json" {
|
||||
continue
|
||||
}
|
||||
snapshots = append(snapshots, filepath.Join(m.configDir, entry.Name()))
|
||||
}
|
||||
|
||||
// Sort by modification time
|
||||
sort.Slice(snapshots, func(i, j int) bool {
|
||||
infoI, _ := os.Stat(snapshots[i])
|
||||
infoJ, _ := os.Stat(snapshots[j])
|
||||
return infoI.ModTime().Before(infoJ.ModTime())
|
||||
})
|
||||
|
||||
return snapshots, nil
|
||||
}
|
||||
|
||||
// rotateSnapshots keeps only the N most recent snapshots.
|
||||
func (m *Manager) rotateSnapshots(keep int) error {
|
||||
snapshots, err := m.listSnapshots()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
|
||||
if len(snapshots) <= keep {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete oldest snapshots
|
||||
toDelete := snapshots[:len(snapshots)-keep]
|
||||
for _, path := range toDelete {
|
||||
if err := os.Remove(path); err != nil {
|
||||
return fmt.Errorf("delete snapshot %s: %w", path, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
// recordConfigChange stores an audit record in the database.
|
||||
func (m *Manager) recordConfigChange(configHash string, success bool, errorMsg string) {
|
||||
record := models.CaddyConfig{
|
||||
ConfigHash: configHash,
|
||||
AppliedAt: time.Now(),
|
||||
Success: success,
|
||||
ErrorMsg: errorMsg,
|
||||
}
|
||||
|
||||
// Best effort - don't fail if audit logging fails
|
||||
m.db.Create(&record)
|
||||
}
|
||||
|
||||
// Ping checks if Caddy is reachable.
|
||||
func (m *Manager) Ping(ctx context.Context) error {
|
||||
return m.client.Ping(ctx)
|
||||
}
|
||||
|
||||
// GetCurrentConfig retrieves the running config from Caddy.
|
||||
func (m *Manager) GetCurrentConfig(ctx context.Context) (*Config, error) {
|
||||
return m.client.GetConfig(ctx)
|
||||
}
|
||||
@@ -1,257 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"context"
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
"github.com/stretchr/testify/assert"
|
||||
"github.com/stretchr/testify/require"
|
||||
"gorm.io/driver/sqlite"
|
||||
"gorm.io/gorm"
|
||||
)
|
||||
|
||||
func TestManager_ApplyConfig(t *testing.T) {
|
||||
// Mock Caddy Admin API
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/load" && r.Method == "POST" {
|
||||
// Verify payload
|
||||
var config Config
|
||||
err := json.NewDecoder(r.Body).Decode(&config)
|
||||
if err != nil {
|
||||
w.WriteHeader(http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
// Setup DB
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Setup Manager
|
||||
tmpDir := t.TempDir()
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, tmpDir, "")
|
||||
|
||||
// Create a host
|
||||
host := models.ProxyHost{
|
||||
DomainNames: "example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
}
|
||||
db.Create(&host)
|
||||
|
||||
// Apply Config
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify config was saved to DB
|
||||
var caddyConfig models.CaddyConfig
|
||||
err = db.First(&caddyConfig).Error
|
||||
assert.NoError(t, err)
|
||||
assert.True(t, caddyConfig.Success)
|
||||
}
|
||||
|
||||
func TestManager_ApplyConfig_Failure(t *testing.T) {
|
||||
// Mock Caddy Admin API to fail
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusInternalServerError)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
// Setup DB
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Setup Manager
|
||||
tmpDir := t.TempDir()
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, tmpDir, "")
|
||||
|
||||
// Create a host
|
||||
host := models.ProxyHost{
|
||||
DomainNames: "example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
}
|
||||
require.NoError(t, db.Create(&host).Error)
|
||||
|
||||
// Apply Config - should fail
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "apply failed")
|
||||
|
||||
// Verify failure was recorded
|
||||
var caddyConfig models.CaddyConfig
|
||||
err = db.First(&caddyConfig).Error
|
||||
assert.NoError(t, err)
|
||||
assert.False(t, caddyConfig.Success)
|
||||
assert.NotEmpty(t, caddyConfig.ErrorMsg)
|
||||
}
|
||||
|
||||
func TestManager_Ping(t *testing.T) {
|
||||
// Mock Caddy Admin API
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/config/" && r.Method == "GET" {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, nil, "", "")
|
||||
|
||||
err := manager.Ping(context.Background())
|
||||
assert.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestManager_GetCurrentConfig(t *testing.T) {
|
||||
// Mock Caddy Admin API
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
if r.URL.Path == "/config/" && r.Method == "GET" {
|
||||
w.Header().Set("Content-Type", "application/json")
|
||||
w.Write([]byte(`{"apps": {"http": {}}}`))
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, nil, "", "")
|
||||
|
||||
config, err := manager.GetCurrentConfig(context.Background())
|
||||
assert.NoError(t, err)
|
||||
assert.NotNil(t, config)
|
||||
assert.NotNil(t, config.Apps)
|
||||
assert.NotNil(t, config.Apps.HTTP)
|
||||
}
|
||||
|
||||
func TestManager_RotateSnapshots(t *testing.T) {
|
||||
// Setup Manager
|
||||
tmpDir := t.TempDir()
|
||||
|
||||
// Mock Caddy Admin API (Success)
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
w.WriteHeader(http.StatusOK)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, tmpDir, "")
|
||||
|
||||
// Create 15 dummy config files
|
||||
for i := 0; i < 15; i++ {
|
||||
// Use past timestamps
|
||||
ts := time.Now().Add(-time.Duration(i+1) * time.Minute).Unix()
|
||||
fname := fmt.Sprintf("config-%d.json", ts)
|
||||
f, _ := os.Create(filepath.Join(tmpDir, fname))
|
||||
f.Close()
|
||||
}
|
||||
|
||||
// Call ApplyConfig once
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Check number of files
|
||||
files, _ := os.ReadDir(tmpDir)
|
||||
|
||||
// Count files matching config-*.json
|
||||
count := 0
|
||||
for _, f := range files {
|
||||
if filepath.Ext(f.Name()) == ".json" {
|
||||
count++
|
||||
}
|
||||
}
|
||||
// Should be 10 (kept)
|
||||
assert.Equal(t, 10, count)
|
||||
}
|
||||
|
||||
func TestManager_Rollback_Success(t *testing.T) {
|
||||
// Mock Caddy Admin API
|
||||
// First call succeeds (initial setup), second call fails (bad config), third call succeeds (rollback)
|
||||
callCount := 0
|
||||
caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
|
||||
callCount++
|
||||
if r.URL.Path == "/load" && r.Method == "POST" {
|
||||
if callCount == 2 {
|
||||
w.WriteHeader(http.StatusInternalServerError) // Fail the second apply
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusOK)
|
||||
return
|
||||
}
|
||||
w.WriteHeader(http.StatusNotFound)
|
||||
}))
|
||||
defer caddyServer.Close()
|
||||
|
||||
// Setup DB
|
||||
dsn := fmt.Sprintf("file:%s?mode=memory&cache=shared", t.Name())
|
||||
db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{})
|
||||
require.NoError(t, err)
|
||||
require.NoError(t, db.AutoMigrate(&models.ProxyHost{}, &models.Location{}, &models.Setting{}, &models.CaddyConfig{}, &models.SSLCertificate{}))
|
||||
|
||||
// Setup Manager
|
||||
tmpDir := t.TempDir()
|
||||
client := NewClient(caddyServer.URL)
|
||||
manager := NewManager(client, db, tmpDir, "")
|
||||
|
||||
// 1. Apply valid config (creates snapshot)
|
||||
host1 := models.ProxyHost{
|
||||
UUID: "uuid-1",
|
||||
DomainNames: "example.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8080,
|
||||
}
|
||||
db.Create(&host1)
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.NoError(t, err)
|
||||
|
||||
// Verify snapshot exists
|
||||
snapshots, _ := manager.listSnapshots()
|
||||
assert.Len(t, snapshots, 1)
|
||||
|
||||
// Sleep to ensure different timestamp for next snapshot
|
||||
time.Sleep(1100 * time.Millisecond)
|
||||
|
||||
// 2. Apply another config (will fail at Caddy level)
|
||||
host2 := models.ProxyHost{
|
||||
UUID: "uuid-2",
|
||||
DomainNames: "fail.com",
|
||||
ForwardHost: "127.0.0.1",
|
||||
ForwardPort: 8081,
|
||||
}
|
||||
db.Create(&host2)
|
||||
|
||||
// This should fail, trigger rollback, and succeed in rolling back
|
||||
err = manager.ApplyConfig(context.Background())
|
||||
assert.Error(t, err)
|
||||
assert.Contains(t, err.Error(), "apply failed (rolled back)")
|
||||
|
||||
// Verify we still have 1 snapshot (the failed one was removed)
|
||||
snapshots, _ = manager.listSnapshots()
|
||||
assert.Len(t, snapshots, 1)
|
||||
}
|
||||
@@ -1,187 +0,0 @@
|
||||
package caddy
|
||||
|
||||
// Config represents Caddy's top-level JSON configuration structure.
|
||||
// Reference: https://caddyserver.com/docs/json/
|
||||
type Config struct {
|
||||
Apps Apps `json:"apps"`
|
||||
Logging *LoggingConfig `json:"logging,omitempty"`
|
||||
Storage Storage `json:"storage,omitempty"`
|
||||
}
|
||||
|
||||
// LoggingConfig configures Caddy's logging facility.
|
||||
type LoggingConfig struct {
|
||||
Logs map[string]*LogConfig `json:"logs,omitempty"`
|
||||
Sinks *SinkConfig `json:"sinks,omitempty"`
|
||||
}
|
||||
|
||||
// LogConfig configures a specific logger.
|
||||
type LogConfig struct {
|
||||
Writer *WriterConfig `json:"writer,omitempty"`
|
||||
Encoder *EncoderConfig `json:"encoder,omitempty"`
|
||||
Level string `json:"level,omitempty"`
|
||||
Include []string `json:"include,omitempty"`
|
||||
Exclude []string `json:"exclude,omitempty"`
|
||||
}
|
||||
|
||||
// WriterConfig configures the log writer (output).
|
||||
type WriterConfig struct {
|
||||
Output string `json:"output"`
|
||||
Filename string `json:"filename,omitempty"`
|
||||
Roll bool `json:"roll,omitempty"`
|
||||
RollSize int `json:"roll_size_mb,omitempty"`
|
||||
RollKeep int `json:"roll_keep,omitempty"`
|
||||
RollKeepDays int `json:"roll_keep_days,omitempty"`
|
||||
}
|
||||
|
||||
// EncoderConfig configures the log format.
|
||||
type EncoderConfig struct {
|
||||
Format string `json:"format"` // "json", "console", etc.
|
||||
}
|
||||
|
||||
// SinkConfig configures log sinks (e.g. stderr).
|
||||
type SinkConfig struct {
|
||||
Writer *WriterConfig `json:"writer,omitempty"`
|
||||
}
|
||||
|
||||
// Storage configures the storage module.
|
||||
type Storage struct {
|
||||
System string `json:"module"`
|
||||
Root string `json:"root,omitempty"`
|
||||
}
|
||||
|
||||
// Apps contains all Caddy app modules.
|
||||
type Apps struct {
|
||||
HTTP *HTTPApp `json:"http,omitempty"`
|
||||
TLS *TLSApp `json:"tls,omitempty"`
|
||||
}
|
||||
|
||||
// HTTPApp configures the HTTP app.
|
||||
type HTTPApp struct {
|
||||
Servers map[string]*Server `json:"servers"`
|
||||
}
|
||||
|
||||
// Server represents an HTTP server instance.
|
||||
type Server struct {
|
||||
Listen []string `json:"listen"`
|
||||
Routes []*Route `json:"routes"`
|
||||
AutoHTTPS *AutoHTTPSConfig `json:"automatic_https,omitempty"`
|
||||
Logs *ServerLogs `json:"logs,omitempty"`
|
||||
}
|
||||
|
||||
// AutoHTTPSConfig controls automatic HTTPS behavior.
|
||||
type AutoHTTPSConfig struct {
|
||||
Disable bool `json:"disable,omitempty"`
|
||||
DisableRedir bool `json:"disable_redirects,omitempty"`
|
||||
Skip []string `json:"skip,omitempty"`
|
||||
}
|
||||
|
||||
// ServerLogs configures access logging.
|
||||
type ServerLogs struct {
|
||||
DefaultLoggerName string `json:"default_logger_name,omitempty"`
|
||||
}
|
||||
|
||||
// Route represents an HTTP route (matcher + handlers).
|
||||
type Route struct {
|
||||
Match []Match `json:"match,omitempty"`
|
||||
Handle []Handler `json:"handle"`
|
||||
Terminal bool `json:"terminal,omitempty"`
|
||||
}
|
||||
|
||||
// Match represents a request matcher.
|
||||
type Match struct {
|
||||
Host []string `json:"host,omitempty"`
|
||||
Path []string `json:"path,omitempty"`
|
||||
}
|
||||
|
||||
// Handler is the interface for all handler types.
|
||||
// Actual types will implement handler-specific fields.
|
||||
type Handler map[string]interface{}
|
||||
|
||||
// ReverseProxyHandler creates a reverse_proxy handler.
|
||||
func ReverseProxyHandler(dial string, enableWS bool) Handler {
|
||||
h := Handler{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": []map[string]interface{}{
|
||||
{"dial": dial},
|
||||
},
|
||||
}
|
||||
|
||||
if enableWS {
|
||||
// Enable WebSocket support by preserving upgrade headers
|
||||
h["headers"] = map[string]interface{}{
|
||||
"request": map[string]interface{}{
|
||||
"set": map[string][]string{
|
||||
"Upgrade": {"{http.request.header.Upgrade}"},
|
||||
"Connection": {"{http.request.header.Connection}"},
|
||||
},
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
return h
|
||||
}
|
||||
|
||||
// HeaderHandler creates a handler that sets HTTP response headers.
|
||||
func HeaderHandler(headers map[string][]string) Handler {
|
||||
return Handler{
|
||||
"handler": "headers",
|
||||
"response": map[string]interface{}{
|
||||
"set": headers,
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// BlockExploitsHandler creates a handler that blocks common exploits.
|
||||
// This uses Caddy's request matchers to block malicious patterns.
|
||||
func BlockExploitsHandler() Handler {
|
||||
return Handler{
|
||||
"handler": "vars",
|
||||
// Placeholder for future exploit blocking logic
|
||||
// Can be extended with specific matchers for SQL injection, XSS, etc.
|
||||
}
|
||||
}
|
||||
|
||||
// RewriteHandler creates a rewrite handler.
|
||||
func RewriteHandler(uri string) Handler {
|
||||
return Handler{
|
||||
"handler": "rewrite",
|
||||
"uri": uri,
|
||||
}
|
||||
}
|
||||
|
||||
// FileServerHandler creates a file_server handler.
|
||||
func FileServerHandler(root string) Handler {
|
||||
return Handler{
|
||||
"handler": "file_server",
|
||||
"root": root,
|
||||
}
|
||||
}
|
||||
|
||||
// TLSApp configures the TLS app for certificate management.
|
||||
type TLSApp struct {
|
||||
Automation *AutomationConfig `json:"automation,omitempty"`
|
||||
Certificates *CertificatesConfig `json:"certificates,omitempty"`
|
||||
}
|
||||
|
||||
// CertificatesConfig configures manual certificate loading.
|
||||
type CertificatesConfig struct {
|
||||
LoadPEM []LoadPEMConfig `json:"load_pem,omitempty"`
|
||||
}
|
||||
|
||||
// LoadPEMConfig defines a PEM-loaded certificate.
|
||||
type LoadPEMConfig struct {
|
||||
Certificate string `json:"certificate"`
|
||||
Key string `json:"key"`
|
||||
Tags []string `json:"tags,omitempty"`
|
||||
}
|
||||
|
||||
// AutomationConfig controls certificate automation.
|
||||
type AutomationConfig struct {
|
||||
Policies []*AutomationPolicy `json:"policies,omitempty"`
|
||||
}
|
||||
|
||||
// AutomationPolicy defines certificate management for specific domains.
|
||||
type AutomationPolicy struct {
|
||||
Subjects []string `json:"subjects,omitempty"`
|
||||
IssuersRaw []interface{} `json:"issuers,omitempty"`
|
||||
}
|
||||
@@ -1,31 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/assert"
|
||||
)
|
||||
|
||||
func TestHandlers(t *testing.T) {
|
||||
// Test RewriteHandler
|
||||
h := RewriteHandler("/new-uri")
|
||||
assert.Equal(t, "rewrite", h["handler"])
|
||||
assert.Equal(t, "/new-uri", h["uri"])
|
||||
|
||||
// Test FileServerHandler
|
||||
h = FileServerHandler("/var/www/html")
|
||||
assert.Equal(t, "file_server", h["handler"])
|
||||
assert.Equal(t, "/var/www/html", h["root"])
|
||||
|
||||
// Test ReverseProxyHandler
|
||||
h = ReverseProxyHandler("localhost:8080", true)
|
||||
assert.Equal(t, "reverse_proxy", h["handler"])
|
||||
|
||||
// Test HeaderHandler
|
||||
h = HeaderHandler(map[string][]string{"X-Test": {"Value"}})
|
||||
assert.Equal(t, "headers", h["handler"])
|
||||
|
||||
// Test BlockExploitsHandler
|
||||
h = BlockExploitsHandler()
|
||||
assert.Equal(t, "vars", h["handler"])
|
||||
}
|
||||
@@ -1,146 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"encoding/json"
|
||||
"fmt"
|
||||
"net"
|
||||
"strconv"
|
||||
"strings"
|
||||
)
|
||||
|
||||
// Validate performs pre-flight validation on a Caddy config before applying it.
|
||||
func Validate(cfg *Config) error {
|
||||
if cfg == nil {
|
||||
return fmt.Errorf("config cannot be nil")
|
||||
}
|
||||
|
||||
if cfg.Apps.HTTP == nil {
|
||||
return nil // Empty config is valid
|
||||
}
|
||||
|
||||
// Track seen hosts to detect duplicates
|
||||
seenHosts := make(map[string]bool)
|
||||
|
||||
for serverName, server := range cfg.Apps.HTTP.Servers {
|
||||
if len(server.Listen) == 0 {
|
||||
return fmt.Errorf("server %s has no listen addresses", serverName)
|
||||
}
|
||||
|
||||
// Validate listen addresses
|
||||
for _, addr := range server.Listen {
|
||||
if err := validateListenAddr(addr); err != nil {
|
||||
return fmt.Errorf("invalid listen address %s in server %s: %w", addr, serverName, err)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate routes
|
||||
for i, route := range server.Routes {
|
||||
if err := validateRoute(route, seenHosts); err != nil {
|
||||
return fmt.Errorf("invalid route %d in server %s: %w", i, serverName, err)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Validate JSON marshalling works
|
||||
if _, err := json.Marshal(cfg); err != nil {
|
||||
return fmt.Errorf("config cannot be marshalled to JSON: %w", err)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateListenAddr(addr string) error {
|
||||
// Strip network type prefix if present (tcp/, udp/)
|
||||
if idx := strings.Index(addr, "/"); idx != -1 {
|
||||
addr = addr[idx+1:]
|
||||
}
|
||||
|
||||
// Parse host:port
|
||||
host, portStr, err := net.SplitHostPort(addr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid address format: %w", err)
|
||||
}
|
||||
|
||||
// Validate port
|
||||
port, err := strconv.Atoi(portStr)
|
||||
if err != nil {
|
||||
return fmt.Errorf("invalid port: %w", err)
|
||||
}
|
||||
if port < 1 || port > 65535 {
|
||||
return fmt.Errorf("port %d out of range (1-65535)", port)
|
||||
}
|
||||
|
||||
// Validate host (allow empty for wildcard binding)
|
||||
if host != "" && net.ParseIP(host) == nil {
|
||||
return fmt.Errorf("invalid IP address: %s", host)
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateRoute(route *Route, seenHosts map[string]bool) error {
|
||||
if len(route.Handle) == 0 {
|
||||
return fmt.Errorf("route has no handlers")
|
||||
}
|
||||
|
||||
// Check for duplicate host matchers
|
||||
for _, match := range route.Match {
|
||||
for _, host := range match.Host {
|
||||
if seenHosts[host] {
|
||||
return fmt.Errorf("duplicate host matcher: %s", host)
|
||||
}
|
||||
seenHosts[host] = true
|
||||
}
|
||||
}
|
||||
|
||||
// Validate handlers
|
||||
for i, handler := range route.Handle {
|
||||
if err := validateHandler(handler); err != nil {
|
||||
return fmt.Errorf("invalid handler %d: %w", i, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
|
||||
func validateHandler(handler Handler) error {
|
||||
handlerType, ok := handler["handler"].(string)
|
||||
if !ok {
|
||||
return fmt.Errorf("handler missing 'handler' field")
|
||||
}
|
||||
|
||||
switch handlerType {
|
||||
case "reverse_proxy":
|
||||
return validateReverseProxy(handler)
|
||||
case "file_server", "static_response":
|
||||
return nil // Accept other common handlers
|
||||
default:
|
||||
// Unknown handlers are allowed (Caddy is extensible)
|
||||
return nil
|
||||
}
|
||||
}
|
||||
|
||||
func validateReverseProxy(handler Handler) error {
|
||||
upstreams, ok := handler["upstreams"].([]map[string]interface{})
|
||||
if !ok {
|
||||
return fmt.Errorf("reverse_proxy missing upstreams")
|
||||
}
|
||||
|
||||
if len(upstreams) == 0 {
|
||||
return fmt.Errorf("reverse_proxy has no upstreams")
|
||||
}
|
||||
|
||||
for i, upstream := range upstreams {
|
||||
dial, ok := upstream["dial"].(string)
|
||||
if !ok || dial == "" {
|
||||
return fmt.Errorf("upstream %d missing dial address", i)
|
||||
}
|
||||
|
||||
// Validate dial address format (host:port)
|
||||
if _, _, err := net.SplitHostPort(dial); err != nil {
|
||||
return fmt.Errorf("upstream %d has invalid dial address %s: %w", i, dial, err)
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
}
|
||||
@@ -1,218 +0,0 @@
|
||||
package caddy
|
||||
|
||||
import (
|
||||
"testing"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
|
||||
"github.com/Wikid82/CaddyProxyManagerPlus/backend/internal/models"
|
||||
)
|
||||
|
||||
func TestValidate_EmptyConfig(t *testing.T) {
|
||||
config := &Config{}
|
||||
err := Validate(config)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidate_ValidConfig(t *testing.T) {
|
||||
hosts := []models.ProxyHost{
|
||||
{
|
||||
UUID: "test",
|
||||
DomainNames: "test.example.com",
|
||||
ForwardHost: "10.0.1.100",
|
||||
ForwardPort: 8080,
|
||||
Enabled: true,
|
||||
},
|
||||
}
|
||||
|
||||
config, _ := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "")
|
||||
err := Validate(config)
|
||||
require.NoError(t, err)
|
||||
}
|
||||
|
||||
func TestValidate_DuplicateHosts(t *testing.T) {
|
||||
config := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{
|
||||
"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*Route{
|
||||
{
|
||||
Match: []Match{{Host: []string{"test.com"}}},
|
||||
Handle: []Handler{
|
||||
ReverseProxyHandler("app:8080", false),
|
||||
},
|
||||
},
|
||||
{
|
||||
Match: []Match{{Host: []string{"test.com"}}},
|
||||
Handle: []Handler{
|
||||
ReverseProxyHandler("app2:8080", false),
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := Validate(config)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "duplicate host")
|
||||
}
|
||||
|
||||
func TestValidate_NoListenAddresses(t *testing.T) {
|
||||
config := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{
|
||||
"srv": {
|
||||
Listen: []string{},
|
||||
Routes: []*Route{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := Validate(config)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "no listen addresses")
|
||||
}
|
||||
|
||||
func TestValidate_InvalidPort(t *testing.T) {
|
||||
config := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{
|
||||
"srv": {
|
||||
Listen: []string{":99999"},
|
||||
Routes: []*Route{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := Validate(config)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "out of range")
|
||||
}
|
||||
|
||||
func TestValidate_NoHandlers(t *testing.T) {
|
||||
config := &Config{
|
||||
Apps: Apps{
|
||||
HTTP: &HTTPApp{
|
||||
Servers: map[string]*Server{
|
||||
"srv": {
|
||||
Listen: []string{":80"},
|
||||
Routes: []*Route{
|
||||
{
|
||||
Match: []Match{{Host: []string{"test.com"}}},
|
||||
Handle: []Handler{},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
err := Validate(config)
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "no handlers")
|
||||
}
|
||||
|
||||
func TestValidateListenAddr(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
addr string
|
||||
wantErr bool
|
||||
}{
|
||||
{"Valid", ":80", false},
|
||||
{"ValidIP", "127.0.0.1:80", false},
|
||||
{"ValidTCP", "tcp/127.0.0.1:80", false},
|
||||
{"ValidUDP", "udp/127.0.0.1:80", false},
|
||||
{"InvalidFormat", "invalid", true},
|
||||
{"InvalidPort", ":99999", true},
|
||||
{"InvalidPortNegative", ":-1", true},
|
||||
{"InvalidIP", "999.999.999.999:80", true},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateListenAddr(tt.addr)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestValidateReverseProxy(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
handler Handler
|
||||
wantErr bool
|
||||
}{
|
||||
{
|
||||
name: "Valid",
|
||||
handler: Handler{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": []map[string]interface{}{
|
||||
{"dial": "localhost:8080"},
|
||||
},
|
||||
},
|
||||
wantErr: false,
|
||||
},
|
||||
{
|
||||
name: "MissingUpstreams",
|
||||
handler: Handler{
|
||||
"handler": "reverse_proxy",
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "EmptyUpstreams",
|
||||
handler: Handler{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": []map[string]interface{}{},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "MissingDial",
|
||||
handler: Handler{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": []map[string]interface{}{
|
||||
{"foo": "bar"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
{
|
||||
name: "InvalidDial",
|
||||
handler: Handler{
|
||||
"handler": "reverse_proxy",
|
||||
"upstreams": []map[string]interface{}{
|
||||
{"dial": "invalid"},
|
||||
},
|
||||
},
|
||||
wantErr: true,
|
||||
},
|
||||
}
|
||||
|
||||
for _, tt := range tests {
|
||||
t.Run(tt.name, func(t *testing.T) {
|
||||
err := validateReverseProxy(tt.handler)
|
||||
if tt.wantErr {
|
||||
require.Error(t, err)
|
||||
} else {
|
||||
require.NoError(t, err)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user