- Implemented `useManualChallenge`, `useChallengePoll`, and `useManualChallengeMutations` hooks for managing manual DNS challenges. - Created tests for the `useManualChallenge` hooks to ensure correct fetching and mutation behavior. - Added `ManualDNSChallenge` component for displaying challenge details and actions. - Developed end-to-end tests for the Manual DNS Provider feature, covering provider selection, challenge UI, and accessibility compliance. - Included error handling tests for verification failures and network errors.
1821 lines
56 KiB
Go
1821 lines
56 KiB
Go
package caddy
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"strconv"
|
|
"testing"
|
|
|
|
"github.com/stretchr/testify/require"
|
|
|
|
"github.com/Wikid82/charon/backend/internal/models"
|
|
)
|
|
|
|
func TestGenerateConfig_Empty(t *testing.T) {
|
|
config, err := GenerateConfig([]models.ProxyHost{}, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config.Apps.HTTP)
|
|
require.Empty(t, config.Apps.HTTP.Servers)
|
|
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", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config.Apps.HTTP)
|
|
require.Len(t, config.Apps.HTTP.Servers, 1)
|
|
require.NotNil(t, config)
|
|
require.NotNil(t, config.Apps.HTTP)
|
|
require.Len(t, config.Apps.HTTP.Servers, 1)
|
|
|
|
server := config.Apps.HTTP.Servers["charon_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", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
require.Len(t, config.Apps.HTTP.Servers["charon_server"].Routes, 2)
|
|
require.Len(t, config.Apps.HTTP.Servers["charon_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", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
route := config.Apps.HTTP.Servers["charon_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", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
require.Empty(t, config.Apps.HTTP.Servers["charon_server"].Routes)
|
|
// Should produce empty routes (or just catch-all if frontendDir was set, but it's empty here)
|
|
require.Empty(t, config.Apps.HTTP.Servers["charon_server"].Routes)
|
|
}
|
|
|
|
func TestGenerateConfig_Logging(t *testing.T) {
|
|
hosts := []models.ProxyHost{}
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config.Logging)
|
|
|
|
// Verify logging configuration
|
|
require.NotNil(t, config.Logging)
|
|
require.NotNil(t, config.Logging.Logs)
|
|
require.NotNil(t, config.Logging.Logs["access"])
|
|
require.Equal(t, "INFO", 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_IPHostsSkipAutoHTTPS(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "uuid-ip",
|
|
DomainNames: "192.0.2.10",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
server := config.Apps.HTTP.Servers["charon_server"]
|
|
require.NotNil(t, server)
|
|
require.Contains(t, server.AutoHTTPS.Skip, "192.0.2.10")
|
|
|
|
// Ensure TLS automation adds internal issuer for IP literals
|
|
require.NotNil(t, config.Apps.TLS)
|
|
require.NotNil(t, config.Apps.TLS.Automation)
|
|
require.GreaterOrEqual(t, len(config.Apps.TLS.Automation.Policies), 1)
|
|
foundIPPolicy := false
|
|
for _, p := range config.Apps.TLS.Automation.Policies {
|
|
if len(p.Subjects) == 0 {
|
|
continue
|
|
}
|
|
if p.Subjects[0] == "192.0.2.10" {
|
|
foundIPPolicy = true
|
|
require.Len(t, p.IssuersRaw, 1)
|
|
issuer := p.IssuersRaw[0].(map[string]any)
|
|
require.Equal(t, "internal", issuer["module"])
|
|
}
|
|
}
|
|
require.True(t, foundIPPolicy, "expected internal issuer policy for IP host")
|
|
}
|
|
|
|
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", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config)
|
|
require.NotNil(t, config)
|
|
|
|
server := config.Apps.HTTP.Servers["charon_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"])
|
|
}
|
|
|
|
func TestGenerateConfig_ACMEStaging(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "test-uuid",
|
|
DomainNames: "test.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
// Test with staging enabled
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", true, false, false, false, true, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config.Apps.TLS)
|
|
require.NotNil(t, config.Apps.TLS)
|
|
require.NotNil(t, config.Apps.TLS.Automation)
|
|
require.Len(t, config.Apps.TLS.Automation.Policies, 1)
|
|
|
|
issuers := config.Apps.TLS.Automation.Policies[0].IssuersRaw
|
|
require.Len(t, issuers, 1)
|
|
|
|
acmeIssuer := issuers[0].(map[string]any)
|
|
require.Equal(t, "acme", acmeIssuer["module"])
|
|
require.Equal(t, "admin@example.com", acmeIssuer["email"])
|
|
require.Equal(t, "https://acme-staging-v02.api.letsencrypt.org/directory", acmeIssuer["ca"])
|
|
|
|
// Test with staging disabled (production)
|
|
config, err = GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "letsencrypt", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config.Apps.TLS)
|
|
require.NotNil(t, config.Apps.TLS.Automation)
|
|
require.Len(t, config.Apps.TLS.Automation.Policies, 1)
|
|
|
|
issuers = config.Apps.TLS.Automation.Policies[0].IssuersRaw
|
|
require.Len(t, issuers, 1)
|
|
|
|
acmeIssuer = issuers[0].(map[string]any)
|
|
require.Equal(t, "acme", acmeIssuer["module"])
|
|
require.Equal(t, "admin@example.com", acmeIssuer["email"])
|
|
_, hasCA := acmeIssuer["ca"]
|
|
require.False(t, hasCA, "Production mode should not set ca field (uses default)")
|
|
// We can't easily check the map content without casting, but we know it's there.
|
|
}
|
|
|
|
func TestBuildACLHandler_WhitelistAndBlacklistAdminMerge(t *testing.T) {
|
|
// Whitelist case: ensure adminWhitelist gets merged into allowed ranges
|
|
acl := &models.AccessList{Type: "whitelist", IPRules: `[{"cidr":"127.0.0.1/32"}]`}
|
|
handler, err := buildACLHandler(acl, "10.0.0.1/32")
|
|
require.NoError(t, err)
|
|
// handler should include both ranges in the remote_ip ranges
|
|
b, _ := json.Marshal(handler)
|
|
s := string(b)
|
|
require.Contains(t, s, "127.0.0.1/32")
|
|
require.Contains(t, s, "10.0.0.1/32")
|
|
|
|
// Blacklist case: ensure adminWhitelist excluded from match
|
|
acl2 := &models.AccessList{Type: "blacklist", IPRules: `[{"cidr":"1.2.3.0/24"}]`}
|
|
handler2, err := buildACLHandler(acl2, "192.168.0.1/32")
|
|
require.NoError(t, err)
|
|
b2, _ := json.Marshal(handler2)
|
|
s2 := string(b2)
|
|
require.Contains(t, s2, "1.2.3.0/24")
|
|
require.Contains(t, s2, "192.168.0.1/32")
|
|
}
|
|
|
|
func TestBuildACLHandler_GeoAndLocalNetwork(t *testing.T) {
|
|
// Geo whitelist
|
|
acl := &models.AccessList{Type: "geo_whitelist", CountryCodes: "US,CA"}
|
|
h, err := buildACLHandler(acl, "")
|
|
require.NoError(t, err)
|
|
b, _ := json.Marshal(h)
|
|
s := string(b)
|
|
require.Contains(t, s, "geoip2.country_code")
|
|
|
|
// Geo blacklist
|
|
acl2 := &models.AccessList{Type: "geo_blacklist", CountryCodes: "RU"}
|
|
h2, err := buildACLHandler(acl2, "")
|
|
require.NoError(t, err)
|
|
b2, _ := json.Marshal(h2)
|
|
s2 := string(b2)
|
|
require.Contains(t, s2, "geoip2.country_code")
|
|
|
|
// Local network only
|
|
acl3 := &models.AccessList{Type: "whitelist", LocalNetworkOnly: true}
|
|
h3, err := buildACLHandler(acl3, "")
|
|
require.NoError(t, err)
|
|
b3, _ := json.Marshal(h3)
|
|
s3 := string(b3)
|
|
require.Contains(t, s3, "10.0.0.0/8")
|
|
}
|
|
|
|
func TestBuildACLHandler_AdminWhitelistParsing(t *testing.T) {
|
|
// Whitelist should trim and include multiple values, skip empties
|
|
acl := &models.AccessList{Type: "whitelist", IPRules: `[{"cidr":"127.0.0.1/32"}]`}
|
|
handler, err := buildACLHandler(acl, " , 10.0.0.1/32, , 192.168.1.5/32 ")
|
|
require.NoError(t, err)
|
|
b, _ := json.Marshal(handler)
|
|
s := string(b)
|
|
require.Contains(t, s, "127.0.0.1/32")
|
|
require.Contains(t, s, "10.0.0.1/32")
|
|
require.Contains(t, s, "192.168.1.5/32")
|
|
|
|
// Blacklist parsing too
|
|
acl2 := &models.AccessList{Type: "blacklist", IPRules: `[{"cidr":"1.2.3.0/24"}]`}
|
|
handler2, err := buildACLHandler(acl2, " , 192.168.0.1/32, ")
|
|
require.NoError(t, err)
|
|
b2, _ := json.Marshal(handler2)
|
|
s2 := string(b2)
|
|
require.Contains(t, s2, "1.2.3.0/24")
|
|
require.Contains(t, s2, "192.168.0.1/32")
|
|
}
|
|
|
|
func TestBuildRateLimitHandler_Disabled(t *testing.T) {
|
|
// Test nil secCfg returns nil handler
|
|
h, err := buildRateLimitHandler(nil, nil)
|
|
require.NoError(t, err)
|
|
require.Nil(t, h)
|
|
}
|
|
|
|
func TestBuildRateLimitHandler_InvalidValues(t *testing.T) {
|
|
// Test zero requests returns nil handler
|
|
secCfg := &models.SecurityConfig{
|
|
RateLimitRequests: 0,
|
|
RateLimitWindowSec: 60,
|
|
}
|
|
h, err := buildRateLimitHandler(nil, secCfg)
|
|
require.NoError(t, err)
|
|
require.Nil(t, h)
|
|
|
|
// Test zero window returns nil handler
|
|
secCfg2 := &models.SecurityConfig{
|
|
RateLimitRequests: 100,
|
|
RateLimitWindowSec: 0,
|
|
}
|
|
h, err = buildRateLimitHandler(nil, secCfg2)
|
|
require.NoError(t, err)
|
|
require.Nil(t, h)
|
|
|
|
// Test negative values returns nil handler
|
|
secCfg3 := &models.SecurityConfig{
|
|
RateLimitRequests: -1,
|
|
RateLimitWindowSec: 60,
|
|
}
|
|
h, err = buildRateLimitHandler(nil, secCfg3)
|
|
require.NoError(t, err)
|
|
require.Nil(t, h)
|
|
}
|
|
|
|
func TestBuildRateLimitHandler_ValidConfig(t *testing.T) {
|
|
// Test valid configuration produces correct caddy-ratelimit format
|
|
secCfg := &models.SecurityConfig{
|
|
RateLimitRequests: 100,
|
|
RateLimitWindowSec: 60,
|
|
RateLimitBurst: 25,
|
|
}
|
|
h, err := buildRateLimitHandler(nil, secCfg)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
// Verify handler type
|
|
require.Equal(t, "rate_limit", h["handler"])
|
|
|
|
// Verify rate_limits structure
|
|
rateLimits, ok := h["rate_limits"].(map[string]any)
|
|
require.True(t, ok, "rate_limits should be a map")
|
|
|
|
staticZone, ok := rateLimits["static"].(map[string]any)
|
|
require.True(t, ok, "static zone should be a map")
|
|
|
|
// Verify caddy-ratelimit specific fields
|
|
require.Equal(t, "{http.request.remote.host}", staticZone["key"])
|
|
require.Equal(t, "60s", staticZone["window"])
|
|
require.Equal(t, 100, staticZone["max_events"])
|
|
// Note: caddy-ratelimit doesn't support burst parameter (uses sliding window)
|
|
}
|
|
|
|
func TestBuildRateLimitHandler_JSONFormat(t *testing.T) {
|
|
// Test that the handler produces valid JSON matching caddy-ratelimit schema
|
|
secCfg := &models.SecurityConfig{
|
|
RateLimitRequests: 30,
|
|
RateLimitWindowSec: 10,
|
|
RateLimitBurst: 5,
|
|
}
|
|
h, err := buildRateLimitHandler(nil, secCfg)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
// Marshal to JSON and verify structure
|
|
b, err := json.Marshal(h)
|
|
require.NoError(t, err)
|
|
s := string(b)
|
|
|
|
// Verify expected JSON content
|
|
require.Contains(t, s, `"handler":"rate_limit"`)
|
|
require.Contains(t, s, `"rate_limits"`)
|
|
require.Contains(t, s, `"static"`)
|
|
require.Contains(t, s, `"key":"{http.request.remote.host}"`)
|
|
require.Contains(t, s, `"window":"10s"`)
|
|
require.Contains(t, s, `"max_events":30`)
|
|
// Note: burst field not included (not supported by caddy-ratelimit)
|
|
}
|
|
|
|
func TestGenerateConfig_WithRateLimiting(t *testing.T) {
|
|
// Test that rate limiting is included in generated config when enabled
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "test-uuid",
|
|
DomainNames: "example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
secCfg := &models.SecurityConfig{
|
|
RateLimitEnable: true,
|
|
RateLimitRequests: 60,
|
|
RateLimitWindowSec: 60,
|
|
}
|
|
|
|
// rateLimitEnabled=true should include the handler
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, true, false, "", nil, nil, nil, secCfg, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config.Apps.HTTP)
|
|
|
|
server := config.Apps.HTTP.Servers["charon_server"]
|
|
require.NotNil(t, server)
|
|
require.Len(t, server.Routes, 1)
|
|
|
|
route := server.Routes[0]
|
|
// Handlers should include rate_limit + reverse_proxy
|
|
require.GreaterOrEqual(t, len(route.Handle), 2)
|
|
|
|
// Find the rate_limit handler
|
|
var foundRateLimit bool
|
|
for _, h := range route.Handle {
|
|
if h["handler"] == "rate_limit" {
|
|
foundRateLimit = true
|
|
// Verify it has the correct structure
|
|
require.NotNil(t, h["rate_limits"])
|
|
break
|
|
}
|
|
}
|
|
require.True(t, foundRateLimit, "rate_limit handler should be present")
|
|
}
|
|
|
|
func TestBuildRateLimitHandler_UsesBurst(t *testing.T) {
|
|
// Verify that burst config value is ignored (caddy-ratelimit doesn't support it)
|
|
secCfg := &models.SecurityConfig{
|
|
RateLimitRequests: 100,
|
|
RateLimitWindowSec: 60,
|
|
RateLimitBurst: 50,
|
|
}
|
|
h, err := buildRateLimitHandler(nil, secCfg)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
// Handler should be a plain rate_limit (no bypass list)
|
|
require.Equal(t, "rate_limit", h["handler"])
|
|
|
|
rateLimits, ok := h["rate_limits"].(map[string]any)
|
|
require.True(t, ok)
|
|
staticZone, ok := rateLimits["static"].(map[string]any)
|
|
require.True(t, ok)
|
|
|
|
// Verify burst field is NOT present (not supported by caddy-ratelimit)
|
|
_, hasBurst := staticZone["burst"]
|
|
require.False(t, hasBurst, "burst field should not be included")
|
|
}
|
|
|
|
func TestBuildRateLimitHandler_DefaultBurst(t *testing.T) {
|
|
// Verify that burst field is not included (caddy-ratelimit uses sliding window, no burst)
|
|
secCfg := &models.SecurityConfig{
|
|
RateLimitRequests: 100,
|
|
RateLimitWindowSec: 60,
|
|
RateLimitBurst: 0, // Not set
|
|
}
|
|
h, err := buildRateLimitHandler(nil, secCfg)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
rateLimits, ok := h["rate_limits"].(map[string]any)
|
|
require.True(t, ok)
|
|
staticZone, ok := rateLimits["static"].(map[string]any)
|
|
require.True(t, ok)
|
|
|
|
// Verify burst field is NOT present
|
|
_, hasBurst := staticZone["burst"]
|
|
require.False(t, hasBurst, "burst field should not be included")
|
|
|
|
// Test with small requests value - should also not have burst
|
|
secCfg2 := &models.SecurityConfig{
|
|
RateLimitRequests: 3,
|
|
RateLimitWindowSec: 60,
|
|
RateLimitBurst: 0,
|
|
}
|
|
h2, err := buildRateLimitHandler(nil, secCfg2)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h2)
|
|
|
|
rateLimits2, ok := h2["rate_limits"].(map[string]any)
|
|
require.True(t, ok)
|
|
staticZone2, ok := rateLimits2["static"].(map[string]any)
|
|
require.True(t, ok)
|
|
|
|
// Verify no burst field here either
|
|
_, hasBurst2 := staticZone2["burst"]
|
|
require.False(t, hasBurst2, "burst field should not be included")
|
|
}
|
|
|
|
// TestGetAccessLogPath_CrowdSecEnabled verifies log path when CrowdSec is explicitly enabled
|
|
func TestGetAccessLogPath_CrowdSecEnabled(t *testing.T) {
|
|
// When CrowdSec is enabled, always use standard path
|
|
path := getAccessLogPath("/tmp/caddy-data", true)
|
|
require.Equal(t, "/var/log/caddy/access.log", path)
|
|
}
|
|
|
|
// TestGetAccessLogPath_DockerEnv verifies log path detection via /.dockerenv
|
|
func TestGetAccessLogPath_DockerEnv(t *testing.T) {
|
|
// This test can't reliably test /.dockerenv detection without mocking os.Stat
|
|
// But we can test the CHARON_ENV fallback
|
|
|
|
// Save original env
|
|
originalEnv := os.Getenv("CHARON_ENV")
|
|
defer func() { _ = os.Setenv("CHARON_ENV", originalEnv) }()
|
|
|
|
// Set CHARON_ENV=production
|
|
_ = os.Setenv("CHARON_ENV", "production")
|
|
path := getAccessLogPath("/tmp/caddy-data", false)
|
|
require.Equal(t, "/var/log/caddy/access.log", path)
|
|
|
|
// Unset CHARON_ENV - should use development path
|
|
_ = os.Unsetenv("CHARON_ENV")
|
|
path = getAccessLogPath("/tmp/storage/caddy/data", false)
|
|
require.Contains(t, path, "logs/access.log")
|
|
require.Contains(t, path, "/tmp/storage/logs/access.log")
|
|
}
|
|
|
|
// TestGetAccessLogPath_Development verifies development fallback path
|
|
func TestGetAccessLogPath_Development(t *testing.T) {
|
|
// Save original env
|
|
originalEnv := os.Getenv("CHARON_ENV")
|
|
defer func() {
|
|
if originalEnv != "" {
|
|
_ = os.Setenv("CHARON_ENV", originalEnv)
|
|
} else {
|
|
_ = os.Unsetenv("CHARON_ENV")
|
|
}
|
|
}()
|
|
|
|
// Clear CHARON_ENV to simulate dev environment
|
|
_ = os.Unsetenv("CHARON_ENV")
|
|
|
|
// Test with typical dev path
|
|
storageDir := "/home/user/charon/data/caddy/data"
|
|
path := getAccessLogPath(storageDir, false)
|
|
|
|
// Should construct path: /home/user/charon/data/logs/access.log
|
|
expectedPath := filepath.Join("/home/user/charon/data/logs", "access.log")
|
|
require.Equal(t, expectedPath, path)
|
|
}
|
|
|
|
// TestBuildPermissionsPolicyString_EmptyAllowlist verifies empty allowlist creates "()"
|
|
func TestBuildPermissionsPolicyString_EmptyAllowlist(t *testing.T) {
|
|
permissionsJSON := `[{"feature":"geolocation","allowlist":[]}]`
|
|
result, err := buildPermissionsPolicyString(permissionsJSON)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "geolocation=()", result)
|
|
}
|
|
|
|
// TestBuildPermissionsPolicyString_SelfAndStar verifies self and * handling
|
|
func TestBuildPermissionsPolicyString_SelfAndStar(t *testing.T) {
|
|
permissionsJSON := `[{"feature":"camera","allowlist":["self"]},{"feature":"microphone","allowlist":["*"]}]`
|
|
result, err := buildPermissionsPolicyString(permissionsJSON)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "camera=(self), microphone=(*)", result)
|
|
}
|
|
|
|
// TestBuildPermissionsPolicyString_DomainValues verifies domain values are quoted
|
|
func TestBuildPermissionsPolicyString_DomainValues(t *testing.T) {
|
|
permissionsJSON := `[{"feature":"payment","allowlist":["https://example.com","https://payment.example.com"]}]`
|
|
result, err := buildPermissionsPolicyString(permissionsJSON)
|
|
require.NoError(t, err)
|
|
require.Equal(t, `payment=("https://example.com" "https://payment.example.com")`, result)
|
|
}
|
|
|
|
// TestBuildPermissionsPolicyString_Mixed verifies mixed allowlist (self + domains)
|
|
func TestBuildPermissionsPolicyString_Mixed(t *testing.T) {
|
|
permissionsJSON := `[{"feature":"fullscreen","allowlist":["self","https://cdn.example.com"]}]`
|
|
result, err := buildPermissionsPolicyString(permissionsJSON)
|
|
require.NoError(t, err)
|
|
require.Equal(t, `fullscreen=(self "https://cdn.example.com")`, result)
|
|
}
|
|
|
|
// TestBuildPermissionsPolicyString_InvalidJSON verifies error handling
|
|
func TestBuildPermissionsPolicyString_InvalidJSON(t *testing.T) {
|
|
permissionsJSON := `invalid json`
|
|
result, err := buildPermissionsPolicyString(permissionsJSON)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid permissions JSON")
|
|
require.Equal(t, "", result)
|
|
}
|
|
|
|
// TestBuildCSPString_EmptyDirective verifies empty directives return empty string
|
|
func TestBuildCSPString_EmptyDirective(t *testing.T) {
|
|
directivesJSON := ``
|
|
result, err := buildCSPString(directivesJSON)
|
|
require.NoError(t, err)
|
|
require.Equal(t, "", result)
|
|
}
|
|
|
|
// TestBuildCSPString_InvalidJSON verifies error handling
|
|
func TestBuildCSPString_InvalidJSON(t *testing.T) {
|
|
directivesJSON := `not valid json`
|
|
result, err := buildCSPString(directivesJSON)
|
|
require.Error(t, err)
|
|
require.Contains(t, err.Error(), "invalid CSP JSON")
|
|
require.Equal(t, "", result)
|
|
}
|
|
|
|
// TestBuildSecurityHeadersHandler_CompleteProfile verifies all headers are set
|
|
func TestBuildSecurityHeadersHandler_CompleteProfile(t *testing.T) {
|
|
profile := &models.SecurityHeaderProfile{
|
|
HSTSEnabled: true,
|
|
HSTSMaxAge: 63072000,
|
|
HSTSIncludeSubdomains: true,
|
|
HSTSPreload: true,
|
|
CSPEnabled: true,
|
|
CSPDirectives: `{"default-src":["'self'"],"script-src":["'self'","'unsafe-inline'"]}`,
|
|
CSPReportOnly: false,
|
|
XFrameOptions: "DENY",
|
|
XContentTypeOptions: true,
|
|
ReferrerPolicy: "no-referrer",
|
|
PermissionsPolicy: `[{"feature":"geolocation","allowlist":[]},{"feature":"camera","allowlist":["self"]}]`,
|
|
CrossOriginOpenerPolicy: "same-origin-allow-popups",
|
|
CrossOriginResourcePolicy: "cross-origin",
|
|
CrossOriginEmbedderPolicy: "require-corp",
|
|
XSSProtection: true,
|
|
CacheControlNoStore: true,
|
|
}
|
|
|
|
host := &models.ProxyHost{
|
|
SecurityHeaderProfile: profile,
|
|
}
|
|
|
|
h, err := buildSecurityHeadersHandler(host)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
require.Equal(t, "headers", h["handler"])
|
|
|
|
// Check response headers
|
|
response := h["response"].(map[string]any)
|
|
headers := response["set"].(map[string][]string)
|
|
|
|
// Verify HSTS
|
|
require.Equal(t, []string{"max-age=63072000; includeSubDomains; preload"}, headers["Strict-Transport-Security"])
|
|
|
|
// Verify CSP
|
|
require.Contains(t, headers, "Content-Security-Policy")
|
|
require.Contains(t, headers["Content-Security-Policy"][0], "default-src 'self'")
|
|
require.Contains(t, headers["Content-Security-Policy"][0], "script-src 'self' 'unsafe-inline'")
|
|
|
|
// Verify all security headers
|
|
require.Equal(t, []string{"DENY"}, headers["X-Frame-Options"])
|
|
require.Equal(t, []string{"nosniff"}, headers["X-Content-Type-Options"])
|
|
require.Equal(t, []string{"no-referrer"}, headers["Referrer-Policy"])
|
|
require.Equal(t, []string{"same-origin-allow-popups"}, headers["Cross-Origin-Opener-Policy"])
|
|
require.Equal(t, []string{"cross-origin"}, headers["Cross-Origin-Resource-Policy"])
|
|
require.Equal(t, []string{"require-corp"}, headers["Cross-Origin-Embedder-Policy"])
|
|
require.Equal(t, []string{"1; mode=block"}, headers["X-XSS-Protection"])
|
|
require.Equal(t, []string{"no-store"}, headers["Cache-Control"])
|
|
|
|
// Verify Permissions-Policy
|
|
require.Contains(t, headers, "Permissions-Policy")
|
|
require.Contains(t, headers["Permissions-Policy"][0], "geolocation=()")
|
|
require.Contains(t, headers["Permissions-Policy"][0], "camera=(self)")
|
|
}
|
|
|
|
// TestGenerateConfig_SSLProviderZeroSSL verifies ZeroSSL issuer configuration
|
|
func TestGenerateConfig_SSLProviderZeroSSL(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "test-uuid",
|
|
DomainNames: "test.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "zerossl", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config.Apps.TLS)
|
|
require.NotNil(t, config.Apps.TLS.Automation)
|
|
require.Len(t, config.Apps.TLS.Automation.Policies, 1)
|
|
|
|
issuers := config.Apps.TLS.Automation.Policies[0].IssuersRaw
|
|
require.Len(t, issuers, 1)
|
|
|
|
issuer := issuers[0].(map[string]any)
|
|
require.Equal(t, "zerossl", issuer["module"])
|
|
}
|
|
|
|
// TestGenerateConfig_SSLProviderBoth verifies both Let's Encrypt and ZeroSSL
|
|
func TestGenerateConfig_SSLProviderBoth(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "test-uuid",
|
|
DomainNames: "test.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
// Test with "both" provider
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "both", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config.Apps.TLS)
|
|
require.NotNil(t, config.Apps.TLS.Automation)
|
|
require.Len(t, config.Apps.TLS.Automation.Policies, 1)
|
|
|
|
issuers := config.Apps.TLS.Automation.Policies[0].IssuersRaw
|
|
require.Len(t, issuers, 2)
|
|
|
|
// First should be ACME (Let's Encrypt)
|
|
issuer1 := issuers[0].(map[string]any)
|
|
require.Equal(t, "acme", issuer1["module"])
|
|
|
|
// Second should be ZeroSSL
|
|
issuer2 := issuers[1].(map[string]any)
|
|
require.Equal(t, "zerossl", issuer2["module"])
|
|
}
|
|
|
|
// TestGenerateConfig_DuplicateDomains verifies Ghost Host duplicate detection
|
|
func TestGenerateConfig_DuplicateDomains(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "uuid-1",
|
|
DomainNames: "duplicate.example.com",
|
|
ForwardHost: "app1",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
{
|
|
UUID: "uuid-2",
|
|
DomainNames: "duplicate.example.com", // Same domain
|
|
ForwardHost: "app2",
|
|
ForwardPort: 8081,
|
|
Enabled: true,
|
|
},
|
|
{
|
|
UUID: "uuid-3",
|
|
DomainNames: "unique.example.com",
|
|
ForwardHost: "app3",
|
|
ForwardPort: 8082,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
server := config.Apps.HTTP.Servers["charon_server"]
|
|
require.NotNil(t, server)
|
|
|
|
// Should only have 2 routes (one duplicate filtered out)
|
|
require.Len(t, server.Routes, 2)
|
|
|
|
// Verify unique.example.com is present
|
|
var foundUnique bool
|
|
for _, route := range server.Routes {
|
|
if len(route.Match) > 0 && len(route.Match[0].Host) > 0 {
|
|
if route.Match[0].Host[0] == "unique.example.com" {
|
|
foundUnique = true
|
|
}
|
|
}
|
|
}
|
|
require.True(t, foundUnique, "unique.example.com should be present")
|
|
}
|
|
|
|
// TestGenerateConfig_WithCrowdSecApp verifies CrowdSec app configuration
|
|
func TestGenerateConfig_WithCrowdSecApp(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "test-uuid",
|
|
DomainNames: "test.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
secCfg := &models.SecurityConfig{
|
|
CrowdSecAPIURL: "http://crowdsec:8080",
|
|
}
|
|
|
|
// Save original env
|
|
originalAPIKey := os.Getenv("CROWDSEC_API_KEY")
|
|
defer func() {
|
|
if originalAPIKey != "" {
|
|
_ = os.Setenv("CROWDSEC_API_KEY", originalAPIKey)
|
|
} else {
|
|
_ = os.Unsetenv("CROWDSEC_API_KEY")
|
|
}
|
|
}()
|
|
|
|
// Set test API key
|
|
_ = os.Setenv("CROWDSEC_API_KEY", "test-api-key-12345")
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, true, false, false, false, "", nil, nil, nil, secCfg, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Verify CrowdSec app is configured
|
|
require.NotNil(t, config.Apps.CrowdSec)
|
|
require.Equal(t, "http://crowdsec:8080", config.Apps.CrowdSec.APIUrl)
|
|
require.Equal(t, "test-api-key-12345", config.Apps.CrowdSec.APIKey)
|
|
require.Equal(t, "60s", config.Apps.CrowdSec.TickerInterval)
|
|
require.NotNil(t, config.Apps.CrowdSec.EnableStreaming)
|
|
require.True(t, *config.Apps.CrowdSec.EnableStreaming)
|
|
}
|
|
|
|
// TestGenerateConfig_CrowdSecHandlerAdded verifies CrowdSec handler is added to routes
|
|
func TestGenerateConfig_CrowdSecHandlerAdded(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "test-uuid",
|
|
DomainNames: "test.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, true, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
server := config.Apps.HTTP.Servers["charon_server"]
|
|
require.NotNil(t, server)
|
|
require.Len(t, server.Routes, 1)
|
|
|
|
route := server.Routes[0]
|
|
// Should have CrowdSec handler + reverse_proxy handler
|
|
require.GreaterOrEqual(t, len(route.Handle), 2)
|
|
|
|
// Find CrowdSec handler
|
|
var foundCrowdSec bool
|
|
for _, h := range route.Handle {
|
|
if h["handler"] == "crowdsec" {
|
|
foundCrowdSec = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, foundCrowdSec, "CrowdSec handler should be present")
|
|
}
|
|
|
|
// TestGenerateConfig_WithSecurityDecisions verifies manual IP blocks
|
|
func TestGenerateConfig_WithSecurityDecisions(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "test-uuid",
|
|
DomainNames: "test.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
decisions := []models.SecurityDecision{
|
|
{IP: "1.2.3.4", Action: "block"},
|
|
{IP: "5.6.7.0/24", Action: "block"},
|
|
{IP: "10.0.0.1", Action: "allow"}, // Should be ignored (not block action)
|
|
}
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, decisions, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
server := config.Apps.HTTP.Servers["charon_server"]
|
|
require.NotNil(t, server)
|
|
require.Len(t, server.Routes, 1)
|
|
|
|
route := server.Routes[0]
|
|
|
|
// Marshal to JSON for inspection
|
|
b, err := json.Marshal(route.Handle)
|
|
require.NoError(t, err)
|
|
s := string(b)
|
|
|
|
// Should contain blocked IPs
|
|
require.Contains(t, s, "1.2.3.4")
|
|
require.Contains(t, s, "5.6.7.0/24")
|
|
|
|
// Should NOT contain allowed IP (not a block action)
|
|
require.NotContains(t, s, "10.0.0.1")
|
|
}
|
|
|
|
func TestBuildRateLimitHandler_BypassList(t *testing.T) {
|
|
// Verify bypass list creates subroute structure
|
|
secCfg := &models.SecurityConfig{
|
|
RateLimitRequests: 100,
|
|
RateLimitWindowSec: 60,
|
|
RateLimitBurst: 20,
|
|
RateLimitBypassList: "10.0.0.0/8, 192.168.1.0/24",
|
|
}
|
|
h, err := buildRateLimitHandler(nil, secCfg)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
// Handler should be a subroute when bypass list is configured
|
|
require.Equal(t, "subroute", h["handler"])
|
|
|
|
// Marshal to JSON for easy inspection
|
|
b, err := json.Marshal(h)
|
|
require.NoError(t, err)
|
|
s := string(b)
|
|
|
|
// Verify subroute contains bypass IPs
|
|
require.Contains(t, s, "10.0.0.0/8")
|
|
require.Contains(t, s, "192.168.1.0/24")
|
|
require.Contains(t, s, "remote_ip")
|
|
require.Contains(t, s, "rate_limit")
|
|
}
|
|
|
|
func TestBuildRateLimitHandler_BypassList_PlainIPs(t *testing.T) {
|
|
// Verify plain IPs are converted to CIDRs
|
|
secCfg := &models.SecurityConfig{
|
|
RateLimitRequests: 100,
|
|
RateLimitWindowSec: 60,
|
|
RateLimitBurst: 20,
|
|
RateLimitBypassList: "10.0.0.1, 192.168.1.1",
|
|
}
|
|
h, err := buildRateLimitHandler(nil, secCfg)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
require.Equal(t, "subroute", h["handler"])
|
|
|
|
b, err := json.Marshal(h)
|
|
require.NoError(t, err)
|
|
s := string(b)
|
|
|
|
// Plain IPs should be converted to /32 CIDRs
|
|
require.Contains(t, s, "10.0.0.1/32")
|
|
require.Contains(t, s, "192.168.1.1/32")
|
|
}
|
|
|
|
func TestBuildRateLimitHandler_BypassList_InvalidEntries(t *testing.T) {
|
|
// Verify invalid entries are ignored
|
|
secCfg := &models.SecurityConfig{
|
|
RateLimitRequests: 100,
|
|
RateLimitWindowSec: 60,
|
|
RateLimitBurst: 20,
|
|
RateLimitBypassList: "invalid, 10.0.0.0/8, also-invalid",
|
|
}
|
|
h, err := buildRateLimitHandler(nil, secCfg)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
require.Equal(t, "subroute", h["handler"])
|
|
|
|
b, err := json.Marshal(h)
|
|
require.NoError(t, err)
|
|
s := string(b)
|
|
|
|
// Only valid CIDR should be present
|
|
require.Contains(t, s, "10.0.0.0/8")
|
|
require.NotContains(t, s, "invalid")
|
|
require.NotContains(t, s, "also-invalid")
|
|
}
|
|
|
|
func TestBuildRateLimitHandler_BypassList_Empty(t *testing.T) {
|
|
// Verify empty bypass list returns plain rate_limit handler
|
|
secCfg := &models.SecurityConfig{
|
|
RateLimitRequests: 100,
|
|
RateLimitWindowSec: 60,
|
|
RateLimitBurst: 20,
|
|
RateLimitBypassList: "",
|
|
}
|
|
h, err := buildRateLimitHandler(nil, secCfg)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
// Should be plain rate_limit, not subroute
|
|
require.Equal(t, "rate_limit", h["handler"])
|
|
}
|
|
|
|
func TestBuildRateLimitHandler_BypassList_AllInvalid(t *testing.T) {
|
|
// Verify all-invalid bypass list returns plain rate_limit handler
|
|
secCfg := &models.SecurityConfig{
|
|
RateLimitRequests: 100,
|
|
RateLimitWindowSec: 60,
|
|
RateLimitBurst: 20,
|
|
RateLimitBypassList: "invalid, also-invalid, not-an-ip",
|
|
}
|
|
h, err := buildRateLimitHandler(nil, secCfg)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
// Should be plain rate_limit since no valid CIDRs
|
|
require.Equal(t, "rate_limit", h["handler"])
|
|
}
|
|
|
|
func TestParseBypassCIDRs(t *testing.T) {
|
|
// Test various inputs
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected []string
|
|
}{
|
|
{"empty", "", nil},
|
|
{"single_cidr", "10.0.0.0/8", []string{"10.0.0.0/8"}},
|
|
{"multiple_cidrs", "10.0.0.0/8, 192.168.0.0/16", []string{"10.0.0.0/8", "192.168.0.0/16"}},
|
|
{"plain_ipv4", "10.0.0.1", []string{"10.0.0.1/32"}},
|
|
{"plain_ipv6", "::1", []string{"::1/128"}},
|
|
{"mixed", "10.0.0.0/8, 192.168.1.1, invalid", []string{"10.0.0.0/8", "192.168.1.1/32"}},
|
|
{"with_spaces", " 10.0.0.0/8 , , 192.168.0.0/16 ", []string{"10.0.0.0/8", "192.168.0.0/16"}},
|
|
{"all_invalid", "invalid, bad-ip", nil},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := parseBypassCIDRs(tt.input)
|
|
if tt.expected == nil {
|
|
require.Nil(t, result)
|
|
} else {
|
|
require.Equal(t, tt.expected, result)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildWAFHandler_ParanoiaLevel verifies paranoia level is correctly set in directives
|
|
func TestBuildWAFHandler_ParanoiaLevel(t *testing.T) {
|
|
rulesetPaths := map[string]string{
|
|
"owasp-crs": "/etc/caddy/rules/owasp-crs.conf",
|
|
}
|
|
rulesets := []models.SecurityRuleSet{
|
|
{Name: "owasp-crs"},
|
|
}
|
|
|
|
tests := []struct {
|
|
name string
|
|
paranoiaLevel int
|
|
expectedLevel int
|
|
expectedEngine string
|
|
}{
|
|
{"level_1_default", 0, 1, "On"},
|
|
{"level_1_explicit", 1, 1, "On"},
|
|
{"level_2", 2, 2, "On"},
|
|
{"level_3", 3, 3, "On"},
|
|
{"level_4_max", 4, 4, "On"},
|
|
{"level_invalid_high", 5, 1, "On"}, // Invalid falls back to 1
|
|
{"level_invalid_neg", -1, 1, "On"}, // Invalid falls back to 1
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
secCfg := &models.SecurityConfig{
|
|
WAFMode: "block",
|
|
WAFParanoiaLevel: tt.paranoiaLevel,
|
|
WAFRulesSource: "owasp-crs",
|
|
}
|
|
|
|
h, err := buildWAFHandler(nil, rulesets, rulesetPaths, secCfg, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
require.Equal(t, "waf", h["handler"])
|
|
|
|
directives := h["directives"].(string)
|
|
require.Contains(t, directives, "SecRuleEngine On")
|
|
require.Contains(t, directives, "SecRequestBodyAccess On")
|
|
require.Contains(t, directives, "tx.paranoia_level="+strconv.Itoa(tt.expectedLevel))
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestBuildWAFHandler_Exclusions verifies SecRuleRemoveById directives are generated
|
|
func TestBuildWAFHandler_Exclusions(t *testing.T) {
|
|
rulesetPaths := map[string]string{
|
|
"owasp-crs": "/etc/caddy/rules/owasp-crs.conf",
|
|
}
|
|
rulesets := []models.SecurityRuleSet{
|
|
{Name: "owasp-crs"},
|
|
}
|
|
|
|
// Test exclusions without targets (full rule removal)
|
|
exclusionsJSON := `[{"rule_id":942100,"description":"SQL Injection rule"},{"rule_id":941100}]`
|
|
secCfg := &models.SecurityConfig{
|
|
WAFMode: "block",
|
|
WAFRulesSource: "owasp-crs",
|
|
WAFExclusions: exclusionsJSON,
|
|
}
|
|
|
|
h, err := buildWAFHandler(nil, rulesets, rulesetPaths, secCfg, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
directives := h["directives"].(string)
|
|
require.Contains(t, directives, "SecRuleRemoveById 942100")
|
|
require.Contains(t, directives, "SecRuleRemoveById 941100")
|
|
}
|
|
|
|
// TestBuildWAFHandler_ExclusionsWithTarget verifies SecRuleUpdateTargetById directives
|
|
func TestBuildWAFHandler_ExclusionsWithTarget(t *testing.T) {
|
|
rulesetPaths := map[string]string{
|
|
"owasp-crs": "/etc/caddy/rules/owasp-crs.conf",
|
|
}
|
|
rulesets := []models.SecurityRuleSet{
|
|
{Name: "owasp-crs"},
|
|
}
|
|
|
|
// Test exclusions with targets (partial rule exclusion)
|
|
exclusionsJSON := `[{"rule_id":942100,"target":"ARGS:password"},{"rule_id":941100,"target":"ARGS:content"}]`
|
|
secCfg := &models.SecurityConfig{
|
|
WAFMode: "block",
|
|
WAFRulesSource: "owasp-crs",
|
|
WAFExclusions: exclusionsJSON,
|
|
}
|
|
|
|
h, err := buildWAFHandler(nil, rulesets, rulesetPaths, secCfg, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
directives := h["directives"].(string)
|
|
require.Contains(t, directives, `SecRuleUpdateTargetById 942100 "!ARGS:password"`)
|
|
require.Contains(t, directives, `SecRuleUpdateTargetById 941100 "!ARGS:content"`)
|
|
}
|
|
|
|
// TestBuildWAFHandler_PerHostDisabled verifies returns nil when host.WAFDisabled is true
|
|
func TestBuildWAFHandler_PerHostDisabled(t *testing.T) {
|
|
rulesetPaths := map[string]string{
|
|
"owasp-crs": "/etc/caddy/rules/owasp-crs.conf",
|
|
}
|
|
rulesets := []models.SecurityRuleSet{
|
|
{Name: "owasp-crs"},
|
|
}
|
|
secCfg := &models.SecurityConfig{
|
|
WAFMode: "block",
|
|
WAFRulesSource: "owasp-crs",
|
|
}
|
|
|
|
// Host with WAF disabled
|
|
host := &models.ProxyHost{
|
|
UUID: "test-uuid",
|
|
WAFDisabled: true,
|
|
}
|
|
|
|
h, err := buildWAFHandler(host, rulesets, rulesetPaths, secCfg, true)
|
|
require.NoError(t, err)
|
|
require.Nil(t, h, "WAF handler should be nil when host.WAFDisabled is true")
|
|
|
|
// Host with WAF enabled (default)
|
|
host2 := &models.ProxyHost{
|
|
UUID: "test-uuid-2",
|
|
WAFDisabled: false,
|
|
}
|
|
|
|
h2, err := buildWAFHandler(host2, rulesets, rulesetPaths, secCfg, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h2, "WAF handler should not be nil when host.WAFDisabled is false")
|
|
}
|
|
|
|
// TestBuildWAFHandler_MonitorMode verifies DetectionOnly when mode is "monitor"
|
|
func TestBuildWAFHandler_MonitorMode(t *testing.T) {
|
|
rulesetPaths := map[string]string{
|
|
"owasp-crs": "/etc/caddy/rules/owasp-crs.conf",
|
|
}
|
|
rulesets := []models.SecurityRuleSet{
|
|
{Name: "owasp-crs"},
|
|
}
|
|
|
|
// Monitor mode
|
|
secCfg := &models.SecurityConfig{
|
|
WAFMode: "monitor",
|
|
WAFRulesSource: "owasp-crs",
|
|
}
|
|
|
|
h, err := buildWAFHandler(nil, rulesets, rulesetPaths, secCfg, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
directives := h["directives"].(string)
|
|
require.Contains(t, directives, "SecRuleEngine DetectionOnly")
|
|
require.NotContains(t, directives, "SecRuleEngine On")
|
|
|
|
// Block mode
|
|
secCfg2 := &models.SecurityConfig{
|
|
WAFMode: "block",
|
|
WAFRulesSource: "owasp-crs",
|
|
}
|
|
|
|
h2, err := buildWAFHandler(nil, rulesets, rulesetPaths, secCfg2, true)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h2)
|
|
|
|
directives2 := h2["directives"].(string)
|
|
require.Contains(t, directives2, "SecRuleEngine On")
|
|
require.NotContains(t, directives2, "SecRuleEngine DetectionOnly")
|
|
}
|
|
|
|
// TestBuildWAFHandler_GlobalDisabled verifies handler returns nil when globally disabled
|
|
func TestBuildWAFHandler_GlobalDisabled(t *testing.T) {
|
|
rulesetPaths := map[string]string{
|
|
"owasp-crs": "/etc/caddy/rules/owasp-crs.conf",
|
|
}
|
|
rulesets := []models.SecurityRuleSet{
|
|
{Name: "owasp-crs"},
|
|
}
|
|
|
|
// WAF disabled via wafEnabled flag
|
|
secCfg := &models.SecurityConfig{
|
|
WAFMode: "block",
|
|
WAFRulesSource: "owasp-crs",
|
|
}
|
|
|
|
h, err := buildWAFHandler(nil, rulesets, rulesetPaths, secCfg, false)
|
|
require.NoError(t, err)
|
|
require.Nil(t, h)
|
|
|
|
// WAF disabled via SecurityConfig.WAFMode
|
|
secCfg2 := &models.SecurityConfig{
|
|
WAFMode: "disabled",
|
|
WAFRulesSource: "owasp-crs",
|
|
}
|
|
|
|
h2, err := buildWAFHandler(nil, rulesets, rulesetPaths, secCfg2, true)
|
|
require.NoError(t, err)
|
|
require.Nil(t, h2)
|
|
}
|
|
|
|
// TestBuildWAFHandler_NoRuleset verifies handler returns nil when no ruleset available
|
|
// WAF without rules is essentially a no-op, so we return nil.
|
|
func TestBuildWAFHandler_NoRuleset(t *testing.T) {
|
|
// Empty rulesets and ruleset paths
|
|
secCfg := &models.SecurityConfig{
|
|
WAFMode: "block",
|
|
}
|
|
|
|
h, err := buildWAFHandler(nil, nil, nil, secCfg, true)
|
|
require.NoError(t, err)
|
|
require.Nil(t, h, "WAF handler should be nil when no ruleset is available")
|
|
}
|
|
|
|
// TestParseWAFExclusions verifies exclusion parsing from JSON
|
|
func TestParseWAFExclusions(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input string
|
|
expected []WAFExclusion
|
|
}{
|
|
{
|
|
name: "empty",
|
|
input: "",
|
|
expected: nil,
|
|
},
|
|
{
|
|
name: "single_exclusion",
|
|
input: `[{"rule_id":942100}]`,
|
|
expected: []WAFExclusion{
|
|
{RuleID: 942100},
|
|
},
|
|
},
|
|
{
|
|
name: "multiple_exclusions",
|
|
input: `[{"rule_id":942100,"description":"SQL Injection"},{"rule_id":941100,"target":"ARGS:password"}]`,
|
|
expected: []WAFExclusion{
|
|
{RuleID: 942100, Description: "SQL Injection"},
|
|
{RuleID: 941100, Target: "ARGS:password"},
|
|
},
|
|
},
|
|
{
|
|
name: "invalid_json",
|
|
input: `invalid json`,
|
|
expected: nil,
|
|
},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := parseWAFExclusions(tt.input)
|
|
if tt.expected == nil {
|
|
require.Nil(t, result)
|
|
} else {
|
|
require.Equal(t, len(tt.expected), len(result))
|
|
for i, e := range tt.expected {
|
|
require.Equal(t, e.RuleID, result[i].RuleID)
|
|
require.Equal(t, e.Target, result[i].Target)
|
|
require.Equal(t, e.Description, result[i].Description)
|
|
}
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestGenerateConfig_WithWAFPerHostDisabled verifies per-host WAF toggle in full config generation
|
|
func TestGenerateConfig_WithWAFPerHostDisabled(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "uuid-waf-enabled",
|
|
DomainNames: "waf-enabled.example.com",
|
|
ForwardHost: "app1",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
WAFDisabled: false,
|
|
},
|
|
{
|
|
UUID: "uuid-waf-disabled",
|
|
DomainNames: "waf-disabled.example.com",
|
|
ForwardHost: "app2",
|
|
ForwardPort: 8081,
|
|
Enabled: true,
|
|
WAFDisabled: true,
|
|
},
|
|
}
|
|
|
|
rulesetPaths := map[string]string{
|
|
"owasp-crs": "/etc/caddy/rules/owasp-crs.conf",
|
|
}
|
|
rulesets := []models.SecurityRuleSet{
|
|
{Name: "owasp-crs"},
|
|
}
|
|
secCfg := &models.SecurityConfig{
|
|
WAFMode: "block",
|
|
WAFRulesSource: "owasp-crs",
|
|
}
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, true, false, false, "", rulesets, rulesetPaths, nil, secCfg, nil)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, config.Apps.HTTP)
|
|
|
|
server := config.Apps.HTTP.Servers["charon_server"]
|
|
require.NotNil(t, server)
|
|
require.Len(t, server.Routes, 2)
|
|
|
|
// Check waf-enabled host has WAF handler
|
|
var wafEnabledRoute, wafDisabledRoute *Route
|
|
for _, route := range server.Routes {
|
|
if len(route.Match) > 0 && len(route.Match[0].Host) > 0 {
|
|
switch route.Match[0].Host[0] {
|
|
case "waf-enabled.example.com":
|
|
wafEnabledRoute = route
|
|
case "waf-disabled.example.com":
|
|
wafDisabledRoute = route
|
|
}
|
|
}
|
|
}
|
|
|
|
// WAF-enabled route should have WAF handler
|
|
require.NotNil(t, wafEnabledRoute)
|
|
foundWAF := false
|
|
for _, h := range wafEnabledRoute.Handle {
|
|
if h["handler"] == "waf" {
|
|
foundWAF = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, foundWAF, "WAF handler should be present for waf-enabled host")
|
|
|
|
// WAF-disabled route should NOT have WAF handler
|
|
require.NotNil(t, wafDisabledRoute)
|
|
for _, h := range wafDisabledRoute.Handle {
|
|
require.NotEqual(t, "waf", h["handler"], "WAF handler should NOT be present for waf-disabled host")
|
|
}
|
|
}
|
|
|
|
// TestGenerateConfig_WithDisabledHost verifies disabled hosts are skipped
|
|
func TestGenerateConfig_WithDisabledHost(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "uuid-enabled",
|
|
DomainNames: "enabled.example.com",
|
|
ForwardHost: "app1",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
{
|
|
UUID: "uuid-disabled",
|
|
DomainNames: "disabled.example.com",
|
|
ForwardHost: "app2",
|
|
ForwardPort: 8081,
|
|
Enabled: false, // Disabled
|
|
},
|
|
}
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
server := config.Apps.HTTP.Servers["charon_server"]
|
|
require.NotNil(t, server)
|
|
// Only 1 route for the enabled host
|
|
require.Len(t, server.Routes, 1)
|
|
require.Equal(t, []string{"enabled.example.com"}, server.Routes[0].Match[0].Host)
|
|
}
|
|
|
|
// TestGenerateConfig_WithFrontendDir verifies catch-all route with frontend
|
|
func TestGenerateConfig_WithFrontendDir(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "uuid-1",
|
|
DomainNames: "app.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "/var/www/html", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
server := config.Apps.HTTP.Servers["charon_server"]
|
|
require.NotNil(t, server)
|
|
// Should have 2 routes: 1 for the host + 1 catch-all for frontend
|
|
require.Len(t, server.Routes, 2)
|
|
|
|
// Last route should be catch-all with file_server
|
|
catchAll := server.Routes[1]
|
|
require.Nil(t, catchAll.Match)
|
|
require.True(t, catchAll.Terminal)
|
|
|
|
// Check handlers include rewrite and file_server
|
|
var foundRewrite, foundFileServer bool
|
|
for _, h := range catchAll.Handle {
|
|
if h["handler"] == "rewrite" {
|
|
foundRewrite = true
|
|
}
|
|
if h["handler"] == "file_server" {
|
|
foundFileServer = true
|
|
}
|
|
}
|
|
require.True(t, foundRewrite, "catch-all should have rewrite handler")
|
|
require.True(t, foundFileServer, "catch-all should have file_server handler")
|
|
}
|
|
|
|
// TestGenerateConfig_CustomCertificate verifies custom certificates are loaded
|
|
func TestGenerateConfig_CustomCertificate(t *testing.T) {
|
|
certUUID := "cert-uuid-123"
|
|
cert := models.SSLCertificate{
|
|
UUID: certUUID,
|
|
Name: "Custom Cert",
|
|
Provider: "custom",
|
|
Certificate: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
|
PrivateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
|
|
}
|
|
certID := uint(1)
|
|
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "uuid-1",
|
|
DomainNames: "secure.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
CertificateID: &certID,
|
|
Certificate: &cert,
|
|
},
|
|
}
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Check TLS certificates are loaded
|
|
require.NotNil(t, config.Apps.TLS)
|
|
require.NotNil(t, config.Apps.TLS.Certificates)
|
|
require.NotNil(t, config.Apps.TLS.Certificates.LoadPEM)
|
|
require.Len(t, config.Apps.TLS.Certificates.LoadPEM, 1)
|
|
|
|
loadPEM := config.Apps.TLS.Certificates.LoadPEM[0]
|
|
require.Equal(t, cert.Certificate, loadPEM.Certificate)
|
|
require.Equal(t, cert.PrivateKey, loadPEM.Key)
|
|
require.Contains(t, loadPEM.Tags, certUUID)
|
|
}
|
|
|
|
// TestGenerateConfig_CustomCertificateMissingData verifies invalid custom certs are skipped
|
|
func TestGenerateConfig_CustomCertificateMissingData(t *testing.T) {
|
|
// Certificate missing private key
|
|
cert := models.SSLCertificate{
|
|
UUID: "cert-uuid-123",
|
|
Name: "Bad Cert",
|
|
Provider: "custom",
|
|
Certificate: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
|
PrivateKey: "", // Missing
|
|
}
|
|
certID := uint(1)
|
|
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "uuid-1",
|
|
DomainNames: "secure.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
CertificateID: &certID,
|
|
Certificate: &cert,
|
|
},
|
|
}
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
// TLS should be configured but without the invalid custom cert
|
|
if config.Apps.TLS != nil && config.Apps.TLS.Certificates != nil {
|
|
require.Empty(t, config.Apps.TLS.Certificates.LoadPEM)
|
|
}
|
|
}
|
|
|
|
// TestGenerateConfig_LetsEncryptCertificateNotLoaded verifies ACME certs aren't loaded via LoadPEM
|
|
func TestGenerateConfig_LetsEncryptCertificateNotLoaded(t *testing.T) {
|
|
cert := models.SSLCertificate{
|
|
UUID: "cert-uuid-123",
|
|
Name: "Let's Encrypt Cert",
|
|
Provider: "letsencrypt", // Not custom
|
|
Certificate: "-----BEGIN CERTIFICATE-----\ntest\n-----END CERTIFICATE-----",
|
|
PrivateKey: "-----BEGIN PRIVATE KEY-----\ntest\n-----END PRIVATE KEY-----",
|
|
}
|
|
certID := uint(1)
|
|
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "uuid-1",
|
|
DomainNames: "secure.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
CertificateID: &certID,
|
|
Certificate: &cert,
|
|
},
|
|
}
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
// Let's Encrypt certs should NOT be loaded via LoadPEM (ACME handles them)
|
|
if config.Apps.TLS != nil && config.Apps.TLS.Certificates != nil {
|
|
require.Empty(t, config.Apps.TLS.Certificates.LoadPEM)
|
|
}
|
|
}
|
|
|
|
// TestGenerateConfig_NormalizeAdvancedConfig verifies advanced config normalization
|
|
func TestGenerateConfig_NormalizeAdvancedConfig(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "uuid-advanced",
|
|
DomainNames: "advanced.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
AdvancedConfig: `{"handler": "headers", "response": {"set": {"X-Custom": "value"}}}`,
|
|
},
|
|
}
|
|
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
server := config.Apps.HTTP.Servers["charon_server"]
|
|
require.NotNil(t, server)
|
|
require.Len(t, server.Routes, 1)
|
|
|
|
route := server.Routes[0]
|
|
// Should have headers handler + reverse_proxy
|
|
require.GreaterOrEqual(t, len(route.Handle), 2)
|
|
|
|
var foundHeaders bool
|
|
for _, h := range route.Handle {
|
|
if h["handler"] == "headers" {
|
|
foundHeaders = true
|
|
break
|
|
}
|
|
}
|
|
require.True(t, foundHeaders, "advanced config handler should be present")
|
|
}
|
|
|
|
// TestGenerateConfig_NoACMEEmailNoTLS verifies no TLS config when no ACME email
|
|
func TestGenerateConfig_NoACMEEmailNoTLS(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "uuid-1",
|
|
DomainNames: "app.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
// No ACME email
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
// TLS automation policies should not be set
|
|
require.Nil(t, config.Apps.TLS)
|
|
}
|
|
|
|
// TestGenerateConfig_SecurityDecisionsWithAdminWhitelist verifies admin bypass for blocks
|
|
func TestGenerateConfig_SecurityDecisionsWithAdminWhitelist(t *testing.T) {
|
|
hosts := []models.ProxyHost{
|
|
{
|
|
UUID: "test-uuid",
|
|
DomainNames: "test.example.com",
|
|
ForwardHost: "app",
|
|
ForwardPort: 8080,
|
|
Enabled: true,
|
|
},
|
|
}
|
|
|
|
decisions := []models.SecurityDecision{
|
|
{IP: "1.2.3.4", Action: "block"},
|
|
}
|
|
|
|
// With admin whitelist
|
|
config, err := GenerateConfig(hosts, "/tmp/caddy-data", "admin@example.com", "", "", false, false, false, false, false, "10.0.0.1/32", nil, nil, decisions, nil, nil)
|
|
require.NoError(t, err)
|
|
|
|
server := config.Apps.HTTP.Servers["charon_server"]
|
|
require.NotNil(t, server)
|
|
|
|
route := server.Routes[0]
|
|
b, _ := json.Marshal(route.Handle)
|
|
s := string(b)
|
|
|
|
// Should contain blocked IP and admin whitelist exclusion
|
|
require.Contains(t, s, "1.2.3.4")
|
|
require.Contains(t, s, "10.0.0.1/32")
|
|
}
|
|
|
|
// TestBuildSecurityHeadersHandler_DefaultProfile verifies default profile when enabled
|
|
func TestBuildSecurityHeadersHandler_DefaultProfile(t *testing.T) {
|
|
host := &models.ProxyHost{
|
|
SecurityHeadersEnabled: true,
|
|
SecurityHeaderProfile: nil, // Use default
|
|
}
|
|
|
|
h, err := buildSecurityHeadersHandler(host)
|
|
require.NoError(t, err)
|
|
require.NotNil(t, h)
|
|
|
|
response := h["response"].(map[string]any)
|
|
headers := response["set"].(map[string][]string)
|
|
|
|
// Should have default HSTS
|
|
require.Contains(t, headers, "Strict-Transport-Security")
|
|
// Should have X-Frame-Options
|
|
require.Contains(t, headers, "X-Frame-Options")
|
|
// Should have X-Content-Type-Options
|
|
require.Contains(t, headers, "X-Content-Type-Options")
|
|
}
|
|
|
|
// TestHasWildcard verifies wildcard detection
|
|
func TestHasWildcard(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
domains []string
|
|
expected bool
|
|
}{
|
|
{"no_wildcard", []string{"example.com", "test.com"}, false},
|
|
{"with_wildcard", []string{"example.com", "*.test.com"}, true},
|
|
{"only_wildcard", []string{"*.example.com"}, true},
|
|
{"empty", []string{}, false},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := hasWildcard(tt.domains)
|
|
require.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestDedupeDomains verifies domain deduplication
|
|
func TestDedupeDomains(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
input []string
|
|
expected []string
|
|
}{
|
|
{"no_dupes", []string{"a.com", "b.com"}, []string{"a.com", "b.com"}},
|
|
{"with_dupes", []string{"a.com", "b.com", "a.com"}, []string{"a.com", "b.com"}},
|
|
{"all_dupes", []string{"a.com", "a.com", "a.com"}, []string{"a.com"}},
|
|
{"empty", []string{}, []string{}},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
result := dedupeDomains(tt.input)
|
|
require.Equal(t, tt.expected, result)
|
|
})
|
|
}
|
|
}
|
|
|
|
// TestNormalizeAdvancedConfig_NestedRoutes verifies nested route normalization
|
|
func TestNormalizeAdvancedConfig_NestedRoutes(t *testing.T) {
|
|
// Test with nested routes structure
|
|
input := map[string]any{
|
|
"handler": "subroute",
|
|
"routes": []any{
|
|
map[string]any{
|
|
"handle": []any{
|
|
map[string]any{
|
|
"handler": "headers",
|
|
"response": map[string]any{
|
|
"set": map[string]any{
|
|
"X-Test": "value", // String should become []string
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := NormalizeAdvancedConfig(input)
|
|
require.NotNil(t, result)
|
|
|
|
// The nested headers should be normalized
|
|
m := result.(map[string]any)
|
|
routes := m["routes"].([]any)
|
|
routeMap := routes[0].(map[string]any)
|
|
handles := routeMap["handle"].([]any)
|
|
handlerMap := handles[0].(map[string]any)
|
|
response := handlerMap["response"].(map[string]any)
|
|
setHeaders := response["set"].(map[string]any)
|
|
|
|
// String should be converted to []string
|
|
xTest := setHeaders["X-Test"]
|
|
require.IsType(t, []string{}, xTest)
|
|
require.Equal(t, []string{"value"}, xTest)
|
|
}
|
|
|
|
// TestNormalizeAdvancedConfig_ArrayInput verifies array normalization
|
|
func TestNormalizeAdvancedConfig_ArrayInput(t *testing.T) {
|
|
input := []any{
|
|
map[string]any{
|
|
"handler": "headers",
|
|
"response": map[string]any{
|
|
"set": map[string]any{
|
|
"X-Test": "value",
|
|
},
|
|
},
|
|
},
|
|
}
|
|
|
|
result := NormalizeAdvancedConfig(input)
|
|
require.NotNil(t, result)
|
|
|
|
arr := result.([]any)
|
|
require.Len(t, arr, 1)
|
|
}
|
|
|
|
// TestGetCrowdSecAPIKey verifies API key retrieval from environment
|
|
func TestGetCrowdSecAPIKey(t *testing.T) {
|
|
// Save original values
|
|
origVars := map[string]string{}
|
|
envVars := []string{"CROWDSEC_API_KEY", "CROWDSEC_BOUNCER_API_KEY", "CERBERUS_SECURITY_CROWDSEC_API_KEY", "CHARON_SECURITY_CROWDSEC_API_KEY", "CPM_SECURITY_CROWDSEC_API_KEY"}
|
|
for _, v := range envVars {
|
|
origVars[v] = os.Getenv(v)
|
|
_ = os.Unsetenv(v)
|
|
}
|
|
defer func() {
|
|
for k, v := range origVars {
|
|
if v != "" {
|
|
os.Setenv(k, v)
|
|
} else {
|
|
_ = os.Unsetenv(k)
|
|
}
|
|
}
|
|
}()
|
|
|
|
// No keys set - should return empty
|
|
result := getCrowdSecAPIKey()
|
|
require.Equal(t, "", result)
|
|
|
|
// Set primary key
|
|
os.Setenv("CROWDSEC_API_KEY", "primary-key")
|
|
result = getCrowdSecAPIKey()
|
|
require.Equal(t, "primary-key", result)
|
|
|
|
// Test fallback priority
|
|
_ = os.Unsetenv("CROWDSEC_API_KEY")
|
|
os.Setenv("CROWDSEC_BOUNCER_API_KEY", "bouncer-key")
|
|
result = getCrowdSecAPIKey()
|
|
require.Equal(t, "bouncer-key", result)
|
|
}
|