Skip to content

Commit 8253c29

Browse files
feat: implement app-specific brand isolation for self-enrollment
- Added CreateBrand API method to support one-brand-per-application architecture - Modified self-enrollment to create isolated brands per application domain - Updated Caddy admin client with improved container IP detection and connection pooling - Changed --app flag behavior to create app-specific brands instead of sharing brands - Updated logging messages to reflect new isolated enrollment architecture - Replaced brand-level security
1 parent e07f445 commit 8253c29

5 files changed

Lines changed: 428 additions & 52 deletions

File tree

cmd/update/hecate_enable.go

Lines changed: 5 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -69,7 +69,7 @@ func init() {
6969
updateHecateEnableCmd.Flags().Bool("dry-run", false, "Show what would be changed without applying")
7070

7171
// Add flags for self-enrollment
72-
updateHecateEnableCmd.Flags().String("app", "", "Application name (e.g., bionicgpt) - informational only, enrollment is brand-level")
72+
updateHecateEnableCmd.Flags().String("app", "", "Application name (e.g., bionicgpt) - creates app-specific brand for isolated enrollment")
7373
updateHecateEnableCmd.Flags().Bool("skip-caddyfile", false, "Skip Caddyfile updates (advanced usage)")
7474
updateHecateEnableCmd.Flags().Bool("enable-captcha", false, "Enable captcha stage for bot protection (uses test keys initially)")
7575
updateHecateEnableCmd.Flags().Bool("require-approval", false, "New users inactive until admin approves (default: active immediately)")
@@ -127,11 +127,11 @@ func runEnableSelfEnrollment(rc *eos_io.RuntimeContext, cmd *cobra.Command) erro
127127
zap.Bool("skip_caddyfile", skipCaddyfile),
128128
zap.Bool("require_approval", requireApproval))
129129

130-
// Important note: Forward auth is brand-level, not app-level
130+
// ARCHITECTURE UPDATE (2025-10-31): Self-enrollment is now app-specific via one-brand-per-application
131131
if appName != "hecate" {
132-
logger.Warn("IMPORTANT: Forward auth operates at BRAND level")
133-
logger.Warn("Self-enrollment will affect ALL applications behind Authentik on this brand")
134-
logger.Warn("The --app flag is informational only and does not restrict enrollment to specific apps")
132+
logger.Info("ARCHITECTURE: App-specific brand isolation enabled")
133+
logger.Info("Self-enrollment is isolated to THIS application only")
134+
logger.Info("Each application gets its own Authentik brand for enrollment security")
135135
}
136136

137137
// Build config

pkg/authentik/brand.go

Lines changed: 66 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -96,6 +96,72 @@ func (c *APIClient) GetBrand(ctx context.Context, pk string) (*BrandResponse, er
9696
return &brand, nil
9797
}
9898

99+
// CreateBrand creates a new brand in Authentik
100+
//
101+
// RATIONALE: App-specific brands enable isolated self-enrollment per application
102+
// ARCHITECTURE: Each application gets its own brand → self-enrollment affects only that app
103+
// SECURITY: Prevents cross-application enrollment (user enrolls for App A, can't access App B)
104+
//
105+
// Required fields:
106+
// - domain: DNS domain for this brand (e.g., "chat.codemonkey.net.au")
107+
// - branding_title: Display name (e.g., "BionicGPT")
108+
//
109+
// Optional fields (will use defaults if not provided):
110+
// - flow_authentication: Authentication flow PK (defaults to Authentik's default)
111+
// - flow_invalidation: Logout flow PK (defaults to Authentik's default)
112+
// - branding_logo: Custom logo URL
113+
// - branding_favicon: Custom favicon URL
114+
func (c *APIClient) CreateBrand(ctx context.Context, domain, title string, optionalFields map[string]interface{}) (*BrandResponse, error) {
115+
// Build request body with required fields
116+
reqBody := map[string]interface{}{
117+
"domain": domain,
118+
"branding_title": title,
119+
}
120+
121+
// Merge optional fields
122+
for key, value := range optionalFields {
123+
reqBody[key] = value
124+
}
125+
126+
jsonBody, err := json.Marshal(reqBody)
127+
if err != nil {
128+
return nil, fmt.Errorf("failed to marshal create request: %w", err)
129+
}
130+
131+
url := fmt.Sprintf("%s/api/v3/core/brands/", c.BaseURL)
132+
133+
req, err := http.NewRequestWithContext(ctx, http.MethodPost, url, bytes.NewReader(jsonBody))
134+
if err != nil {
135+
return nil, fmt.Errorf("failed to create request: %w", err)
136+
}
137+
138+
req.Header.Set("Authorization", "Bearer "+c.Token)
139+
req.Header.Set("Content-Type", "application/json")
140+
req.Header.Set("Accept", "application/json")
141+
142+
resp, err := c.HTTPClient.Do(req)
143+
if err != nil {
144+
return nil, fmt.Errorf("brand create request failed: %w", err)
145+
}
146+
defer func() { _ = resp.Body.Close() }()
147+
148+
body, err := io.ReadAll(io.LimitReader(resp.Body, 8192))
149+
if err != nil {
150+
return nil, fmt.Errorf("failed to read response body: %w", err)
151+
}
152+
153+
if resp.StatusCode != http.StatusCreated && resp.StatusCode != http.StatusOK {
154+
return nil, fmt.Errorf("brand create failed with status %d: %s", resp.StatusCode, string(body))
155+
}
156+
157+
var createdBrand BrandResponse
158+
if err := json.Unmarshal(body, &createdBrand); err != nil {
159+
return nil, fmt.Errorf("failed to parse brand create response: %w\nResponse body: %s", err, string(body))
160+
}
161+
162+
return &createdBrand, nil
163+
}
164+
99165
// UpdateBrand updates a brand's configuration and returns the updated brand
100166
// Only non-empty fields in updates will be modified
101167
// Returns the updated brand object from API response for verification

pkg/hecate/caddy_admin_api.go

Lines changed: 38 additions & 14 deletions
Original file line numberDiff line numberDiff line change
@@ -20,20 +20,27 @@ type CaddyAdminClient struct {
2020
}
2121

2222
// NewCaddyAdminClient creates a new Caddy Admin API client
23-
// Connects to Caddy Admin API via HTTP on localhost:2019
24-
// RATIONALE: Unix sockets don't work for host-to-container communication with Docker
25-
// SECURITY: Port only exposed on localhost (127.0.0.1:2019), not accessible from network
26-
// ARCHITECTURE: Eos runs on host, Caddy runs in container - requires TCP, not Unix socket
23+
// AUTO-DETECTS container IP via Docker SDK to bypass localhost IPv4/IPv6 resolution issues
24+
//
25+
// ARCHITECTURE: Three-tier fallback strategy
26+
// 1. Use provided host if explicitly given (e.g., "192.168.1.100")
27+
// 2. Auto-detect Caddy container IP via Docker SDK (bypasses localhost issues)
28+
// 3. Fall back to localhost:2019 (legacy behavior, may fail with IPv6)
29+
//
30+
// ROOT CAUSE FIXED: Caddy binds to 127.0.0.1 (IPv4) inside container
31+
// Host's `localhost` resolves to ::1 (IPv6) first → connection refused
32+
// Docker SDK provides container's bridge IP (172.x.x.x) → direct connection
33+
//
34+
// SECURITY: Docker SDK requires socket access (same as `docker ps`)
35+
// Connection pooling prevents resource exhaustion
2736
func NewCaddyAdminClient(host string) *CaddyAdminClient {
28-
// P0 FIX (2025-10-31): Add connection pooling to prevent "connection reset by peer" errors
29-
// RATIONALE: Docker networking can reset idle connections in default Go HTTP transport
30-
// EVIDENCE: https://stackoverflow.com/questions/37774624 (56 upvotes, accepted answer)
31-
// VENDOR BEST PRACTICE: Caddy documentation recommends MaxIdleConnsPerHost=10 for high-traffic
32-
// LOCALHOST OPTIMIZATION: Using lower limits (2) since this is single-host localhost API
33-
// SECURITY: Connection limits prevent resource exhaustion on Caddy Admin API
37+
// Connection pooling configuration
38+
// RATIONALE: Explicit transport for HTTP/1.1 connection reuse
39+
// NOTE: MaxIdleConnsPerHost=2 matches Go default (sufficient for single-container API)
40+
// FUTURE: Increase to 10-20 if Admin API performance becomes bottleneck
3441
transport := &http.Transport{
3542
MaxIdleConns: 10, // Total idle connections across all hosts
36-
MaxIdleConnsPerHost: 2, // Low for single-host localhost API (not multi-host proxy)
43+
MaxIdleConnsPerHost: 2, // Sufficient for localhost/container-IP API
3744
IdleConnTimeout: 30 * time.Second, // Match Caddy's default keep-alive
3845
}
3946

@@ -42,9 +49,26 @@ func NewCaddyAdminClient(host string) *CaddyAdminClient {
4249
Transport: transport,
4350
}
4451

45-
// Connect to Admin API on localhost:2019
46-
// SECURITY: Port only exposed as 127.0.0.1:2019 in docker-compose (not 0.0.0.0)
47-
baseURL := fmt.Sprintf("http://%s:%d", host, CaddyAdminAPIPort)
52+
// Determine which host to use (three-tier fallback)
53+
var targetHost string
54+
if host != "" && host != "localhost" {
55+
// Tier 1: Explicit host provided (e.g., from env var CADDY_ADMIN_HOST)
56+
targetHost = host
57+
} else {
58+
// Tier 2: Auto-detect container IP via Docker SDK (best approach)
59+
// RATIONALE: Bypasses localhost IPv4/IPv6 resolution issues
60+
// NON-FATAL: If Docker SDK fails, fall back to localhost
61+
ctx := context.Background()
62+
if containerIP, err := GetCaddyContainerIP(ctx); err == nil {
63+
targetHost = containerIP
64+
} else {
65+
// Tier 3: Fall back to localhost (legacy behavior)
66+
// WARNING: May fail with IPv6 resolution issues
67+
targetHost = "localhost"
68+
}
69+
}
70+
71+
baseURL := fmt.Sprintf("http://%s:%d", targetHost, CaddyAdminAPIPort)
4872

4973
return &CaddyAdminClient{
5074
BaseURL: baseURL,

pkg/hecate/caddy_docker.go

Lines changed: 211 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,211 @@
1+
// pkg/hecate/caddy_docker.go - Docker SDK integration for Caddy Admin API
2+
//
3+
// ARCHITECTURE: Solves "connection reset" issue by using Docker SDK
4+
// ROOT CAUSE: Caddy binds Admin API to 127.0.0.1 inside container (IPv4 only)
5+
// Host's `localhost` resolves to ::1 (IPv6) first → connection refused
6+
// SOLUTION: Use Docker SDK to get container's internal IP address on bridge network
7+
// Then connect directly to container IP, bypassing localhost resolution
8+
//
9+
// VENDOR EVIDENCE:
10+
// - Caddy Community: "Connection reset to Docker container usually means wrong bind address"
11+
// - Docker Docs: "Containers have their own network namespace, use bridge IP for host access"
12+
// - Go net: "localhost can resolve to IPv6 ::1 or IPv4 127.0.0.1 depending on OS"
13+
//
14+
// SECURITY: Docker SDK respects same security model as docker CLI
15+
// Only works if user has docker socket access (/var/run/docker.sock)
16+
17+
package hecate
18+
19+
import (
20+
"context"
21+
"fmt"
22+
23+
"github.com/CodeMonkeyCybersecurity/eos/pkg/eos_io"
24+
"github.com/docker/docker/api/types/container"
25+
"github.com/docker/docker/client"
26+
"github.com/uptrace/opentelemetry-go-extra/otelzap"
27+
"go.uber.org/zap"
28+
)
29+
30+
// GetCaddyContainerIP retrieves the IP address of the Caddy container on the hecate-net bridge network
31+
//
32+
// RATIONALE: Caddy Admin API binds to 127.0.0.1 inside container, but that's not accessible from host
33+
// Docker SDK provides container's bridge network IP, which IS accessible from host
34+
//
35+
// ARCHITECTURE:
36+
// Host (Eos) → Docker Bridge (172.x.x.x) → Container (hecate-caddy)
37+
// Container has BOTH:
38+
// - Internal localhost (127.0.0.1) - only accessible inside container
39+
// - Bridge IP (172.x.x.x) - accessible from host and other containers
40+
//
41+
// SECURITY: Docker socket access required (/var/run/docker.sock)
42+
// Same permissions as `docker inspect hecate-caddy`
43+
// Safe: Read-only operation, no container modification
44+
//
45+
// RETURNS:
46+
// - Container's IP on hecate-net network (e.g., "172.21.0.5")
47+
// - Error if container not found, not running, or not on hecate-net
48+
func GetCaddyContainerIP(ctx context.Context) (string, error) {
49+
// Create Docker client from environment (respects DOCKER_HOST, DOCKER_CERT_PATH, etc.)
50+
// SECURITY: Uses same credentials as docker CLI
51+
// RATIONALE: Supports both local socket and remote Docker daemons
52+
dockerClient, err := client.NewClientWithOpts(
53+
client.FromEnv,
54+
client.WithAPIVersionNegotiation(), // Auto-negotiate API version (best practice)
55+
)
56+
if err != nil {
57+
return "", fmt.Errorf("failed to create Docker client: %w\n\n"+
58+
"Troubleshooting:\n"+
59+
" 1. Docker installed? Run: docker --version\n"+
60+
" 2. Docker running? Run: docker ps\n"+
61+
" 3. Socket accessible? Run: ls -l /var/run/docker.sock\n"+
62+
" 4. User in docker group? Run: groups | grep docker", err)
63+
}
64+
defer dockerClient.Close()
65+
66+
// Inspect Caddy container to get network settings
67+
// ARCHITECTURE: ContainerInspect returns full container metadata including all networks
68+
containerInfo, err := dockerClient.ContainerInspect(ctx, CaddyContainerName)
69+
if err != nil {
70+
return "", fmt.Errorf("failed to inspect Caddy container '%s': %w\n\n"+
71+
"Troubleshooting:\n"+
72+
" 1. Container running? Run: docker ps -a | grep %s\n"+
73+
" 2. Container name correct? Expected: %s\n"+
74+
" 3. Start container: docker compose -f /opt/hecate/docker-compose.yml up -d caddy",
75+
CaddyContainerName, err, CaddyContainerName, CaddyContainerName)
76+
}
77+
78+
// Verify container is actually running (not stopped/paused/restarting)
79+
// RATIONALE: Container could exist but not be running → IP would be invalid
80+
if !containerInfo.State.Running {
81+
return "", fmt.Errorf("Caddy container '%s' is not running (state: %s)\n\n"+
82+
"Start the container:\n"+
83+
" docker compose -f /opt/hecate/docker-compose.yml up -d caddy\n\n"+
84+
"Check logs for errors:\n"+
85+
" docker logs %s --tail 50",
86+
CaddyContainerName, containerInfo.State.Status, CaddyContainerName)
87+
}
88+
89+
// Get IP from hecate-net bridge network
90+
// ARCHITECTURE: Docker Compose creates a custom bridge network named "hecate-net"
91+
// RATIONALE: Custom networks provide DNS, isolation, and predictable IPs
92+
// FALLBACK: If hecate-net doesn't exist, try "bridge" (default Docker network)
93+
networkName := "hecate-net"
94+
if network, ok := containerInfo.NetworkSettings.Networks[networkName]; ok {
95+
if network.IPAddress == "" {
96+
return "", fmt.Errorf("Caddy container on network '%s' but has no IP address\n\n"+
97+
"This usually means the network is starting up.\n"+
98+
"Wait 5 seconds and retry, or restart container:\n"+
99+
" docker compose -f /opt/hecate/docker-compose.yml restart caddy",
100+
networkName)
101+
}
102+
return network.IPAddress, nil
103+
}
104+
105+
// Fallback: Try default bridge network
106+
// RATIONALE: User might have modified docker-compose.yml to use default bridge
107+
if network, ok := containerInfo.NetworkSettings.Networks["bridge"]; ok {
108+
if network.IPAddress != "" {
109+
return network.IPAddress, nil
110+
}
111+
}
112+
113+
// No suitable network found
114+
availableNetworks := make([]string, 0, len(containerInfo.NetworkSettings.Networks))
115+
for name := range containerInfo.NetworkSettings.Networks {
116+
availableNetworks = append(availableNetworks, name)
117+
}
118+
119+
return "", fmt.Errorf("Caddy container not connected to '%s' network\n\n"+
120+
"Available networks: %v\n\n"+
121+
"Fix docker-compose.yml:\n"+
122+
" caddy:\n"+
123+
" networks:\n"+
124+
" - hecate-net\n\n"+
125+
"Then recreate container:\n"+
126+
" docker compose -f /opt/hecate/docker-compose.yml up -d --force-recreate caddy",
127+
networkName, availableNetworks)
128+
}
129+
130+
// GetCaddyContainerIPWithLogging is a wrapper around GetCaddyContainerIP that adds structured logging
131+
//
132+
// RATIONALE: Observability - log Docker SDK operations for debugging
133+
// USAGE: Use this in production code, use GetCaddyContainerIP in tests
134+
func GetCaddyContainerIPWithLogging(rc *eos_io.RuntimeContext) (string, error) {
135+
logger := otelzap.Ctx(rc.Ctx)
136+
137+
logger.Debug("Detecting Caddy container IP via Docker SDK",
138+
zap.String("container_name", CaddyContainerName),
139+
zap.String("expected_network", "hecate-net"))
140+
141+
ip, err := GetCaddyContainerIP(rc.Ctx)
142+
if err != nil {
143+
logger.Error("Failed to detect Caddy container IP",
144+
zap.String("container_name", CaddyContainerName),
145+
zap.Error(err))
146+
return "", err
147+
}
148+
149+
logger.Info("✓ Caddy container IP detected via Docker SDK",
150+
zap.String("container_name", CaddyContainerName),
151+
zap.String("bridge_ip", ip),
152+
zap.String("admin_api_url", fmt.Sprintf("http://%s:%d", ip, CaddyAdminAPIPort)))
153+
154+
return ip, nil
155+
}
156+
157+
// IsCaddyContainerRunning checks if the Caddy container is running
158+
//
159+
// RATIONALE: Pre-flight check before attempting Admin API operations
160+
// RETURNS: true if container exists and is running, false otherwise
161+
// ERROR: Only returns error if Docker SDK fails, NOT if container is stopped
162+
func IsCaddyContainerRunning(ctx context.Context) (bool, error) {
163+
dockerClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
164+
if err != nil {
165+
return false, fmt.Errorf("failed to create Docker client: %w", err)
166+
}
167+
defer dockerClient.Close()
168+
169+
containerInfo, err := dockerClient.ContainerInspect(ctx, CaddyContainerName)
170+
if err != nil {
171+
// Container not found is not an error - just return false
172+
if client.IsErrNotFound(err) {
173+
return false, nil
174+
}
175+
return false, fmt.Errorf("failed to inspect container: %w", err)
176+
}
177+
178+
return containerInfo.State.Running, nil
179+
}
180+
181+
// GetCaddyContainerLogs retrieves recent logs from Caddy container for debugging
182+
//
183+
// RATIONALE: When Admin API fails, logs often contain the root cause
184+
// RETURNS: Last N lines of logs as string
185+
// USAGE: Call this when Admin API operations fail to provide user with debugging context
186+
func GetCaddyContainerLogs(ctx context.Context, tailLines int) (string, error) {
187+
dockerClient, err := client.NewClientWithOpts(client.FromEnv, client.WithAPIVersionNegotiation())
188+
if err != nil {
189+
return "", fmt.Errorf("failed to create Docker client: %w", err)
190+
}
191+
defer dockerClient.Close()
192+
193+
// ContainerLogs options
194+
opts := container.LogsOptions{
195+
ShowStdout: true,
196+
ShowStderr: true,
197+
Tail: fmt.Sprintf("%d", tailLines),
198+
Timestamps: true,
199+
}
200+
201+
logs, err := dockerClient.ContainerLogs(ctx, CaddyContainerName, opts)
202+
if err != nil {
203+
return "", fmt.Errorf("failed to get container logs: %w", err)
204+
}
205+
defer logs.Close()
206+
207+
// Read logs (Docker returns multiplexed stream, but for simple text we can read directly)
208+
buf := make([]byte, 4096)
209+
n, _ := logs.Read(buf)
210+
return string(buf[:n]), nil
211+
}

0 commit comments

Comments
 (0)