Files
Charon/docs/reports/implementation_notes.md
2026-01-26 19:22:05 +00:00

608 lines
19 KiB
Markdown
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# Proxy TLS & IP Login Recovery — Implementation Notes
The following patches implement the approved plan while respecting the constraint to **not modify source files directly**. Apply them in order. Tests to add are included at the end.
## Backend — Caddy IP-aware TLS/HTTP handling
**Goal:** avoid ACME/AutoHTTPS on IP literals, allow HTTP-only or internal TLS for IP hosts, and add coverage.
Apply this patch to `backend/internal/caddy/config.go`:
```diff
*** Begin Patch
*** Update File: backend/internal/caddy/config.go
@@
-import (
- "encoding/json"
- "fmt"
- "path/filepath"
- "strings"
+import (
+ "encoding/json"
+ "fmt"
+ "net"
+ "path/filepath"
+ "strings"
@@
-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) (*Config, error) {
+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) (*Config, error) {
@@
- // Initialize routes slice
- routes := make([]*Route, 0)
+ // Initialize routes slice
+ routes := make([]*Route, 0)
+ // Track IP-only hostnames to skip AutoHTTPS/ACME
+ ipSubjects := make([]string, 0)
@@
- // Parse comma-separated domains
- rawDomains := strings.Split(host.DomainNames, ",")
- var uniqueDomains []string
+ // Parse comma-separated domains
+ rawDomains := strings.Split(host.DomainNames, ",")
+ var uniqueDomains []string
+ isIPOnly := true
@@
- processedDomains[d] = true
- uniqueDomains = append(uniqueDomains, d)
+ processedDomains[d] = true
+ uniqueDomains = append(uniqueDomains, d)
+ if net.ParseIP(d) == nil {
+ isIPOnly = false
+ }
}
if len(uniqueDomains) == 0 {
continue
}
+
+ if isIPOnly {
+ ipSubjects = append(ipSubjects, uniqueDomains...)
+ }
@@
- route := &Route{
- Match: []Match{
- {Host: uniqueDomains},
- },
- Handle: mainHandlers,
- Terminal: true,
- }
+ route := &Route{
+ Match: []Match{
+ {Host: uniqueDomains},
+ },
+ Handle: mainHandlers,
+ Terminal: true,
+ }
routes = append(routes, route)
}
@@
- config.Apps.HTTP.Servers["charon_server"] = &Server{
- Listen: []string{":80", ":443"},
- Routes: routes,
- AutoHTTPS: &AutoHTTPSConfig{
- Disable: false,
- DisableRedir: false,
- },
- Logs: &ServerLogs{
- DefaultLoggerName: "access_log",
- },
- }
+ autoHTTPS := &AutoHTTPSConfig{Disable: false, DisableRedir: false}
+ if len(ipSubjects) > 0 {
+ // Skip AutoHTTPS/ACME for IP literals to avoid ERR_SSL_PROTOCOL_ERROR
+ autoHTTPS.Skip = append(autoHTTPS.Skip, ipSubjects...)
+ }
+
+ config.Apps.HTTP.Servers["charon_server"] = &Server{
+ Listen: []string{":80", ":443"},
+ Routes: routes,
+ AutoHTTPS: autoHTTPS,
+ Logs: &ServerLogs{
+ DefaultLoggerName: "access_log",
+ },
+ }
+
+ // Provide internal certificates for IP subjects when present so optional TLS can succeed without ACME
+ if len(ipSubjects) > 0 {
+ if config.Apps.TLS == nil {
+ config.Apps.TLS = &TLSApp{}
+ }
+ policy := &AutomationPolicy{
+ Subjects: ipSubjects,
+ IssuersRaw: []interface{}{map[string]interface{}{"module": "internal"}},
+ }
+ if config.Apps.TLS.Automation == nil {
+ config.Apps.TLS.Automation = &AutomationConfig{}
+ }
+ config.Apps.TLS.Automation.Policies = append(config.Apps.TLS.Automation.Policies, policy)
+ }
return config, nil
}
*** End Patch
```
Add a focused test to `backend/internal/caddy/config_test.go` to cover IP hosts:
```diff
*** Begin Patch
*** Update File: backend/internal/caddy/config_test.go
@@
func TestGenerateConfig_Logging(t *testing.T) {
@@
}
+
+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)
+ 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]interface{})
+ require.Equal(t, "internal", issuer["module"])
+ }
+ }
+ require.True(t, foundIPPolicy, "expected internal issuer policy for IP host")
+}
*** End Patch
```
## Backend — Auth cookie scheme-aware flags and header fallback
**Goal:** allow login over IP/HTTP by deriving `Secure` and `SameSite` from the request scheme/X-Forwarded-Proto, and keep Authorization fallback.
Patch `backend/internal/api/handlers/auth_handler.go`:
```diff
*** Begin Patch
*** Update File: backend/internal/api/handlers/auth_handler.go
@@
-import (
- "net/http"
- "os"
- "strconv"
- "strings"
+import (
+ "net/http"
+ "os"
+ "strconv"
+ "strings"
@@
-func isProduction() bool {
- env := os.Getenv("CHARON_ENV")
- return env == "production" || env == "prod"
-}
+func isProduction() bool {
+ env := os.Getenv("CHARON_ENV")
+ return env == "production" || env == "prod"
+}
+
+func requestScheme(c *gin.Context) string {
+ if proto := c.GetHeader("X-Forwarded-Proto"); proto != "" {
+ // Honor first entry in a comma-separated header
+ parts := strings.Split(proto, ",")
+ return strings.ToLower(strings.TrimSpace(parts[0]))
+ }
+ if c.Request != nil && c.Request.TLS != nil {
+ return "https"
+ }
+ if c.Request != nil && c.Request.URL != nil && c.Request.URL.Scheme != "" {
+ return strings.ToLower(c.Request.URL.Scheme)
+ }
+ return "http"
+}
@@
-// setSecureCookie sets an auth cookie with security best practices
-// - HttpOnly: prevents JavaScript access (XSS protection)
-// - Secure: only sent over HTTPS (in production)
-// - SameSite=Strict: prevents CSRF attacks
-func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
- secure := isProduction()
- sameSite := http.SameSiteStrictMode
+// setSecureCookie sets an auth cookie with security best practices
+// - HttpOnly: prevents JavaScript access (XSS protection)
+// - Secure: derived from request scheme to allow HTTP/IP logins when needed
+// - SameSite: Strict for HTTPS, Lax for HTTP/IP to allow forward-auth redirects
+func setSecureCookie(c *gin.Context, name, value string, maxAge int) {
+ scheme := requestScheme(c)
+ secure := isProduction() && scheme == "https"
+ sameSite := http.SameSiteStrictMode
+ if scheme != "https" {
+ sameSite = http.SameSiteLaxMode
+ }
@@
func (h *AuthHandler) Login(c *gin.Context) {
@@
- // Set secure cookie (HttpOnly, Secure in prod, SameSite=Strict)
- setSecureCookie(c, "auth_token", token, 3600*24)
+ // Set secure cookie (scheme-aware) and return token for header fallback
+ setSecureCookie(c, "auth_token", token, 3600*24)
@@
- c.JSON(http.StatusOK, gin.H{"token": token})
+ c.JSON(http.StatusOK, gin.H{"token": token})
}
*** End Patch
```
Add unit tests to `backend/internal/api/handlers/auth_handler_test.go` to cover scheme-aware cookies and header fallback:
```diff
*** Begin Patch
*** Update File: backend/internal/api/handlers/auth_handler_test.go
@@
func TestAuthHandler_Login(t *testing.T) {
@@
}
+
+func TestSetSecureCookie_HTTPS_Strict(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ ctx, w := gin.CreateTestContext(httptest.NewRecorder())
+ req := httptest.NewRequest("POST", "https://example.com/login", http.NoBody)
+ ctx.Request = req
+
+ setSecureCookie(ctx, "auth_token", "abc", 60)
+ cookies := w.Result().Cookies()
+ require.Len(t, cookies, 1)
+ c := cookies[0]
+ assert.True(t, c.Secure)
+ assert.Equal(t, http.SameSiteStrictMode, c.SameSite)
+}
+
+func TestSetSecureCookie_HTTP_Lax(t *testing.T) {
+ gin.SetMode(gin.TestMode)
+ ctx, w := gin.CreateTestContext(httptest.NewRecorder())
+ req := httptest.NewRequest("POST", "http://192.0.2.10/login", http.NoBody)
+ req.Header.Set("X-Forwarded-Proto", "http")
+ ctx.Request = req
+
+ setSecureCookie(ctx, "auth_token", "abc", 60)
+ cookies := w.Result().Cookies()
+ require.Len(t, cookies, 1)
+ c := cookies[0]
+ assert.False(t, c.Secure)
+ assert.Equal(t, http.SameSiteLaxMode, c.SameSite)
+}
*** End Patch
```
Patch `backend/internal/api/middleware/auth.go` to explicitly prefer Authorization header when present and keep cookie/query fallback (behavioral clarity, no functional change):
```diff
*** Begin Patch
*** Update File: backend/internal/api/middleware/auth.go
@@
- authHeader := c.GetHeader("Authorization")
- if authHeader == "" {
- // Try cookie
- cookie, err := c.Cookie("auth_token")
- if err == nil {
- authHeader = "Bearer " + cookie
- }
- }
-
- if authHeader == "" {
- // Try query param
- token := c.Query("token")
- if token != "" {
- authHeader = "Bearer " + token
- }
- }
+ authHeader := c.GetHeader("Authorization")
+
+ if authHeader == "" {
+ // Try cookie first for browser flows
+ if cookie, err := c.Cookie("auth_token"); err == nil && cookie != "" {
+ authHeader = "Bearer " + cookie
+ }
+ }
+
+ if authHeader == "" {
+ // Try query param (token passthrough)
+ if token := c.Query("token"); token != "" {
+ authHeader = "Bearer " + token
+ }
+ }
*** End Patch
```
## Frontend — login header fallback when cookies are blocked
**Goal:** when cookies arent set (IP/HTTP), use the returned token to set the `Authorization` header for subsequent requests.
Patch `frontend/src/api/client.ts` to expose a token setter and persist optional header:
```diff
*** Begin Patch
*** Update File: frontend/src/api/client.ts
@@
-import axios from 'axios';
+import axios from 'axios';
@@
const client = axios.create({
baseURL: '/api/v1',
withCredentials: true, // Required for HttpOnly cookie transmission
timeout: 30000, // 30 second timeout
});
+
+export const setAuthToken = (token: string | null) => {
+ if (token) {
+ client.defaults.headers.common.Authorization = `Bearer ${token}`;
+ } else {
+ delete client.defaults.headers.common.Authorization;
+ }
+};
@@
export default client;
*** End Patch
```
Patch `frontend/src/context/AuthContext.tsx` to reuse stored token when cookies are unavailable:
```diff
*** Begin Patch
*** Update File: frontend/src/context/AuthContext.tsx
@@
-import client from '../api/client';
+import client, { setAuthToken } from '../api/client';
@@
- const checkAuth = async () => {
- try {
- const response = await client.get('/auth/me');
- setUser(response.data);
- } catch {
- setUser(null);
- } finally {
- setIsLoading(false);
- }
- };
+ const checkAuth = async () => {
+ try {
+ const stored = localStorage.getItem('charon_auth_token');
+ if (stored) {
+ setAuthToken(stored);
+ }
+ const response = await client.get('/auth/me');
+ setUser(response.data);
+ } catch {
+ setAuthToken(null);
+ setUser(null);
+ } finally {
+ setIsLoading(false);
+ }
+ };
@@
- const login = async () => {
- // Token is stored in cookie by backend, but we might want to store it in memory or trigger a re-fetch
- // Actually, if backend sets cookie, we just need to fetch /auth/me
- try {
- const response = await client.get<User>('/auth/me');
- setUser(response.data);
- } catch (error) {
- setUser(null);
- throw error;
- }
- };
+ const login = async (token?: string) => {
+ if (token) {
+ localStorage.setItem('charon_auth_token', token);
+ setAuthToken(token);
+ }
+ try {
+ const response = await client.get<User>('/auth/me');
+ setUser(response.data);
+ } catch (error) {
+ setUser(null);
+ setAuthToken(null);
+ localStorage.removeItem('charon_auth_token');
+ throw error;
+ }
+ };
@@
- const logout = async () => {
- try {
- await client.post('/auth/logout');
- } catch (error) {
- console.error("Logout failed", error);
- }
- setUser(null);
- };
+ const logout = async () => {
+ try {
+ await client.post('/auth/logout');
+ } catch (error) {
+ console.error("Logout failed", error);
+ }
+ localStorage.removeItem('charon_auth_token');
+ setAuthToken(null);
+ setUser(null);
+ };
*** End Patch
```
Patch `frontend/src/pages/Login.tsx` to use the returned token when cookies arent set:
```diff
*** Begin Patch
*** Update File: frontend/src/pages/Login.tsx
@@
-import client from '../api/client'
+import client from '../api/client'
@@
- await client.post('/auth/login', { email, password })
- await login()
+ const res = await client.post('/auth/login', { email, password })
+ const token = (res.data as { token?: string }).token
+ await login(token)
@@
- toast.error(error.response?.data?.error || 'Login failed')
+ toast.error(error.response?.data?.error || 'Login failed')
*** End Patch
```
Update types to reflect login signature change in `frontend/src/context/AuthContextValue.ts`:
```diff
*** Begin Patch
*** Update File: frontend/src/context/AuthContextValue.ts
@@
-export interface AuthContextType {
- user: User | null;
- login: () => Promise<void>;
+export interface AuthContextType {
+ user: User | null;
+ login: (token?: string) => Promise<void>;
*** End Patch
```
Patch `frontend/src/hooks/useAuth.ts` to satisfy the updated context type (no behavioral change needed).
## Frontend Tests — cover token fallback
Extend `frontend/src/pages/__tests__/Login.test.tsx` with a new case ensuring the token is passed to `login` when present and that `/auth/me` is retried with the Authorization header (mocked via context):
```diff
*** Begin Patch
*** Update File: frontend/src/pages/__tests__/Login.test.tsx
@@
it('shows error toast when login fails', async () => {
@@
})
+
+ it('uses returned token when cookie is unavailable', async () => {
+ vi.spyOn(setupApi, 'getSetupStatus').mockResolvedValue({ setupRequired: false })
+ const postSpy = vi.spyOn(client, 'post').mockResolvedValueOnce({ data: { token: 'bearer-token' } })
+ const loginFn = vi.fn().mockResolvedValue(undefined)
+ vi.spyOn(authHook, 'useAuth').mockReturnValue({ login: loginFn } as unknown as AuthContextType)
+
+ renderWithProviders(<Login />)
+ const email = screen.getByPlaceholderText(/admin@example.com/i)
+ const pass = screen.getByPlaceholderText(/••••••••/i)
+ fireEvent.change(email, { target: { value: 'a@b.com' } })
+ fireEvent.change(pass, { target: { value: 'pw' } })
+ fireEvent.click(screen.getByRole('button', { name: /Sign In/i }))
+
+ await waitFor(() => expect(postSpy).toHaveBeenCalled())
+ expect(loginFn).toHaveBeenCalledWith('bearer-token')
+ })
*** End Patch
```
## Backend Tests — auth middleware clarity
Add a small assertion to `backend/internal/api/middleware/auth_test.go` to confirm Authorization header is preferred when both cookie and header exist:
```diff
*** Begin Patch
*** Update File: backend/internal/api/middleware/auth_test.go
@@
func TestAuthMiddleware_ValidToken(t *testing.T) {
@@
}
+
+func TestAuthMiddleware_PrefersAuthorizationHeader(t *testing.T) {
+ authService := setupAuthService(t)
+ user, _ := authService.Register("header@example.com", "password", "Header User")
+ token, _ := authService.GenerateToken(user)
+
+ gin.SetMode(gin.TestMode)
+ r := gin.New()
+ r.Use(AuthMiddleware(authService))
+ r.GET("/test", func(c *gin.Context) {
+ userID, _ := c.Get("userID")
+ assert.Equal(t, user.ID, userID)
+ c.Status(http.StatusOK)
+ })
+
+ req, _ := http.NewRequest("GET", "/test", http.NoBody)
+ req.Header.Set("Authorization", "Bearer "+token)
+ req.AddCookie(&http.Cookie{Name: "auth_token", Value: "stale"})
+ w := httptest.NewRecorder()
+ r.ServeHTTP(w, req)
+
+ assert.Equal(t, http.StatusOK, w.Code)
+}
*** End Patch
```
## Hygiene — ignores and coverage
Update `.gitignore` to include transient caches and geoip data (mirrors plan):
```diff
*** Begin Patch
*** Update File: .gitignore
@@
frontend/.vite/
frontend/*.tsbuildinfo
+/frontend/.cache/
+/frontend/.eslintcache
+/backend/.vscode/
+/data/geoip/
*** End Patch
```
Update `.dockerignore` with the same cache directories (add if present):
```diff
*** Begin Patch
*** Update File: .dockerignore
@@
node_modules
frontend/node_modules
backend/node_modules
frontend/dist
+frontend/.cache
+frontend/.eslintcache
+data/geoip
*** End Patch
```
Adjust `.codecov.yml` to **include** backend startup logic in coverage (remove the `backend/cmd/api/**` ignore) and leave other ignores untouched:
```diff
*** Begin Patch
*** Update File: .codecov.yml
@@
- - "backend/cmd/api/**"
*** End Patch
```
## Tests to run after applying patches
1. Backend unit tests: `go test ./backend/internal/caddy ./backend/internal/api/...`
2. Frontend unit tests: `cd frontend && npm test -- Login.test.tsx`
3. Backend build: run workspace task **Go: Build Backend**.
4. Frontend type-check/build: `cd frontend && npm run type-check` then `npm run build`.
## Notes
- The IP-aware TLS policy uses Caddys `internal` issuer for IP literals while skipping AutoHTTPS to prevent ACME on IPs.
- Cookie flags now respect the inbound scheme (or `X-Forwarded-Proto`), enabling HTTP/IP logins without disabling secure defaults on HTTPS.
- Frontend stores the login token only when provided; it clears the header and storage on logout or auth failure.
- Remove the `.codecov.yml` ignore entry only if new code paths should count toward coverage; otherwise keep existing thresholds.