Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
35 changes: 15 additions & 20 deletions ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -723,36 +723,31 @@ export const useProxyHosts = () => {
5. Caddy validates and applies new configuration
6. Traffic flows through new proxy route

**Route Pattern: Emergency + Main**
**Route Pattern: Main Route (plus a recovery route for Charon's own host)**

For each proxy host, Charon generates **two routes** with the same domain:
For each proxy host, Charon generates a **main route** with the domain and no path matchers. Its handlers run the full Cerberus security pipeline before the reverse proxy. Locations are generated as separate path-matched routes that run the same pipeline.

1. **Emergency Route** (with path matchers):
- Matches: `/api/v1/emergency/*` paths
- Purpose: Bypass security features for administrative access
- Priority: Evaluated first (more specific match)
- Handlers: No WAF, ACL, or Rate Limiting

2. **Main Route** (without path matchers):
- Matches: All other paths for the domain
1. **Main Route** (without path matchers):
- Matches: All paths for the domain
- Purpose: Normal application traffic with full security
- Priority: Evaluated second (catch-all)
- Handlers: Full Cerberus security suite
- Handlers: Full Cerberus security suite, then the reverse proxy

2. **Recovery Route** (exact path matchers, only for a host that proxies Charon itself):
- Matches: exactly `/api/v1/emergency/security-reset` and `/emergency/security-reset`, with no wildcards
- Purpose: Recovery route for the reset endpoint. The endpoint performs its own token and management-network checks.
- Handlers: Skips the security pre-handlers; no other path under `/emergency/` takes this route, token endpoints included

This pattern is **intentional and valid**:
A host that proxies Charon gets a recovery route for the reset endpoint. Every other host gets only its main route and its locations.

- Emergency route provides break-glass access to security controls
- Main route protects application with enterprise security features
- Caddy processes routes in order (emergency matches first)
- Validator allows duplicate hosts when one has paths and one doesn't
Caddy processes routes in order, so the recovery route (when present) is evaluated before the main route. Validator allows duplicate hosts when one has paths and one doesn't.

**Example:**

```json
// Emergency Route (evaluated first)
// Recovery Route (only for a host whose upstream is Charon; evaluated first)
{
"match": [{"host": ["app.example.com"], "path": ["/api/v1/emergency/*"]}],
"handle": [/* Emergency handlers - no security */],
"match": [{"host": ["charon.example.com"], "path": ["/api/v1/emergency/security-reset", "/emergency/security-reset"]}],
"handle": [/* reverse proxy only */],
"terminal": true
}

Expand Down
55 changes: 32 additions & 23 deletions backend/internal/caddy/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"net"
"os"
"path/filepath"
"slices"
"strconv"
"strings"

Expand All @@ -32,6 +33,14 @@ const serverShutdownGracePeriod = "5s"
// but no bouncer key exists yet, so CrowdSec is left out of the proxy config.
const crowdSecNoKeyWarning = "CrowdSec is on but no bouncer key exists yet; CrowdSec is left out of the proxy configuration until a key is saved"

// resetRecoveryPaths are the only paths the recovery route for the reset endpoint
// matches. They are exact paths with no wildcard, so nothing under the prefix
// (token endpoints included) takes the route.
var resetRecoveryPaths = []string{
"/api/v1/emergency/security-reset",
"/emergency/security-reset",
}

// 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, acmeEmail, frontendDir, sslProvider string, acmeStaging, crowdsecEnabled, wafEnabled, rateLimitEnabled, aclEnabled bool, adminWhitelist string, rulesets []models.SecurityRuleSet, rulesetPaths map[string]string, decisions []models.SecurityDecision, secCfg *models.SecurityConfig, dnsProviderConfigs []DNSProviderConfig, opts ...GenerateConfigOption) (*Config, error) {
Expand Down Expand Up @@ -662,31 +671,31 @@ func GenerateConfig(hosts []models.ProxyHost, storageDir, acmeEmail, frontendDir
// Build main handlers: security pre-handlers, other host-level handlers, then reverse proxy
// Determine if standard headers should be enabled (default true if nil)
enableStdHeaders := host.EnableStandardHeaders == nil || *host.EnableStandardHeaders
emergencyPaths := []string{
"/api/v1/emergency/security-reset",
"/api/v1/emergency/*",
"/emergency/security-reset",
"/emergency/*",
}
emergencyHandlers := append(append([]Handler{}, handlers...), resolvedOpts.proxyHandler(dial, resolvedOpts.isRemoteHost(host.UUID), host.Application, enableStdHeaders))
emergencyRoute := &Route{
Match: []Match{
{
Host: uniqueDomains,
Path: emergencyPaths,
// Recovery route for the reset endpoint. It skips the security pre-handlers,
// so it is emitted only for a host whose upstream is Charon itself, and it
// matches the exact reset paths only. The reset endpoint performs its own
// token and management-network checks.
if resolvedOpts.selfHop.isSelfUpstream(dial, resolvedOpts.isRemoteHost(host.UUID)) {
recoveryHandlers := append(append([]Handler{}, handlers...), resolvedOpts.proxyHandler(dial, resolvedOpts.isRemoteHost(host.UUID), host.Application, enableStdHeaders))
recoveryRoute := &Route{
Match: []Match{
{
Host: uniqueDomains,
Path: slices.Clone(resetRecoveryPaths),
},
},
},
Handle: emergencyHandlers,
Terminal: true,
Handle: recoveryHandlers,
Terminal: true,
}
logger.Log().WithFields(map[string]any{
"host_id": host.ID,
"host_uuid": host.UUID,
"unique_domains": uniqueDomains,
"has_paths": true,
"path_count": len(resetRecoveryPaths),
}).Debug("[CONFIG DEBUG] Creating recovery route for the reset endpoint")
routes = append(routes, recoveryRoute)
}
logger.Log().WithFields(map[string]any{
"host_id": host.ID,
"host_uuid": host.UUID,
"unique_domains": uniqueDomains,
"has_paths": true,
"path_count": len(emergencyPaths),
}).Debug("[CONFIG DEBUG] Creating EMERGENCY route")
routes = append(routes, emergencyRoute)

mainHandlers := append(append([]Handler{}, securityHandlers...), handlers...)
mainHandlers = append(mainHandlers, resolvedOpts.proxyHandler(dial, resolvedOpts.isRemoteHost(host.UUID), host.Application, enableStdHeaders))
Expand Down
10 changes: 5 additions & 5 deletions backend/internal/caddy/config_crowdsec_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -134,10 +134,10 @@ func TestGenerateConfig_WithCrowdSec(t *testing.T) {
assert.Contains(t, server.TrustedProxies.Ranges, "10.0.0.0/8", "Should trust private networks")
assert.Contains(t, server.TrustedProxies.Ranges, "192.168.0.0/16", "Should trust private networks")

// Check handler is minimal (2 routes: emergency + main)
require.Len(t, server.Routes, 2)
// Check handler is minimal (1 route: main)
require.Len(t, server.Routes, 1)

route := server.Routes[1] // Main route is at index 1
route := server.Routes[0] // Main route is at index 0
// Handlers should include crowdsec + reverse_proxy
require.GreaterOrEqual(t, len(route.Handle), 2)

Expand Down Expand Up @@ -177,9 +177,9 @@ func TestGenerateConfig_CrowdSecDisabled(t *testing.T) {

server := config.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
require.Len(t, server.Routes, 2) // 2 routes: emergency + main
require.Len(t, server.Routes, 1) // 1 route: main

route := server.Routes[1] // Main route is at index 1
route := server.Routes[0] // Main route is at index 0

// Verify no crowdsec handler
for _, h := range route.Handle {
Expand Down
26 changes: 13 additions & 13 deletions backend/internal/caddy/config_extra_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -37,9 +37,9 @@ func TestGenerateConfig_AdvancedInvalidJSON(t *testing.T) {
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
// Main route should still have ReverseProxy as last handler (2 routes: emergency + main)
require.Len(t, server.Routes, 2)
route := server.Routes[1] // Main route is at index 1
// Main route should still have ReverseProxy as last handler (1 route: main)
require.Len(t, server.Routes, 1)
route := server.Routes[0] // Main route is at index 0
last := route.Handle[len(route.Handle)-1]
require.Equal(t, "reverse_proxy", last["handler"])
}
Expand Down Expand Up @@ -68,7 +68,7 @@ func TestGenerateConfig_AdvancedArrayHandler(t *testing.T) {
require.NoError(t, err)
server := cfg.Apps.HTTP.Servers["charon_server"]
require.NotNil(t, server)
route := server.Routes[1] // Main route is at index 1 (after emergency route)
route := server.Routes[0] // Main route is at index 0 (no emergency route for a non-Charon upstream)
// First handler should be our headers handler
first := route.Handle[0]
require.Equal(t, "headers", first["handler"])
Expand All @@ -80,7 +80,7 @@ func TestGenerateConfig_LowercaseDomains(t *testing.T) {
}
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[1]
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Debug prints removed
require.Equal(t, []string{"upper.example.com"}, route.Match[0].Host)
}
Expand All @@ -96,7 +96,7 @@ func TestGenerateConfig_AdvancedObjectHandler(t *testing.T) {
}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[1]
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// First handler should be headers
first := route.Handle[0]
require.Equal(t, "headers", first["handler"])
Expand All @@ -113,7 +113,7 @@ func TestGenerateConfig_AdvancedHeadersStringToArray(t *testing.T) {
}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, true, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[1]
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Debug prints removed
first := route.Handle[0]
require.Equal(t, "headers", first["handler"])
Expand Down Expand Up @@ -174,7 +174,7 @@ func TestGenerateConfig_ACLWhitelistIncluded(t *testing.T) {
require.NotNil(t, aclH)
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[1]
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Accept either a subroute (ACL) or reverse_proxy as first handler
first := route.Handle[0]
if first["handler"] != "subroute" {
Expand All @@ -186,15 +186,15 @@ func TestGenerateConfig_SkipsEmptyDomainEntries(t *testing.T) {
hosts := []models.ProxyHost{{UUID: "u1", DomainNames: ", test.example.com", ForwardHost: "a", ForwardPort: 80, Enabled: true}}
cfg, err := GenerateConfig(hosts, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[1]
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
require.Equal(t, []string{"test.example.com"}, route.Match[0].Host)
}

func TestGenerateConfig_AdvancedNoHandlerKey(t *testing.T) {
host := models.ProxyHost{UUID: "adv3", DomainNames: "nohandler.example.com", ForwardHost: "app", ForwardPort: 8080, Enabled: true, AdvancedConfig: `{"foo":"bar"}`}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[1]
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// No headers handler appended; last handler is reverse_proxy
last := route.Handle[len(route.Handle)-1]
require.Equal(t, "reverse_proxy", last["handler"])
Expand All @@ -204,7 +204,7 @@ func TestGenerateConfig_AdvancedUnexpectedJSONStructure(t *testing.T) {
host := models.ProxyHost{UUID: "adv4", DomainNames: "struct.example.com", ForwardHost: "app", ForwardPort: 8080, Enabled: true, AdvancedConfig: `42`}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[1]
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]
// Expect main reverse proxy handler exists but no appended advanced handler
last := route.Handle[len(route.Handle)-1]
require.Equal(t, "reverse_proxy", last["handler"])
Expand Down Expand Up @@ -232,7 +232,7 @@ func TestGenerateConfig_SecurityPipeline_Order(t *testing.T) {
secCfg := &models.SecurityConfig{CrowdSecMode: "local", RateLimitRequests: 100, RateLimitWindowSec: 60}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, true, true, true, true, "", rulesets, rulesetPaths, nil, secCfg, nil, WithBouncerKeyPath(keyPath))
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[1]
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]

// Extract handler names
names := []string{}
Expand All @@ -255,7 +255,7 @@ func TestGenerateConfig_SecurityPipeline_OmitWhenDisabled(t *testing.T) {
host := models.ProxyHost{UUID: "pipe2", DomainNames: "pipe2.example.com", Enabled: true, ForwardHost: "app", ForwardPort: 8080}
cfg, err := GenerateConfig([]models.ProxyHost{host}, "/tmp/caddy-data", "", "", "", false, false, false, false, false, "", nil, nil, nil, nil, nil)
require.NoError(t, err)
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[1]
route := cfg.Apps.HTTP.Servers["charon_server"].Routes[0]

// Extract handler names
names := []string{}
Expand Down
Loading
Loading