diff --git a/backend/internal/api/handlers/settings_handler.go b/backend/internal/api/handlers/settings_handler.go index 7d6603fd..d2eca5a6 100644 --- a/backend/internal/api/handlers/settings_handler.go +++ b/backend/internal/api/handlers/settings_handler.go @@ -5,6 +5,7 @@ import ( "errors" "fmt" "net/http" + "strconv" "strings" "time" @@ -37,6 +38,15 @@ type SettingsHandler struct { DataRoot string } +const ( + settingCaddyKeepaliveIdle = "caddy.keepalive_idle" + settingCaddyKeepaliveCount = "caddy.keepalive_count" + minCaddyKeepaliveIdleDuration = time.Second + maxCaddyKeepaliveIdleDuration = 24 * time.Hour + minCaddyKeepaliveCount = 1 + maxCaddyKeepaliveCount = 100 +) + func NewSettingsHandler(db *gorm.DB) *SettingsHandler { return &SettingsHandler{ DB: db, @@ -109,6 +119,11 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) { } } + if err := validateOptionalKeepaliveSetting(req.Key, req.Value); err != nil { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } + setting := models.Setting{ Key: req.Key, Value: req.Value, @@ -247,6 +262,10 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) { } } + if err := validateOptionalKeepaliveSetting(key, value); err != nil { + return err + } + setting := models.Setting{ Key: key, Value: value, @@ -284,6 +303,10 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) { c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"}) return } + if strings.Contains(err.Error(), "invalid caddy.keepalive_idle") || strings.Contains(err.Error(), "invalid caddy.keepalive_count") { + c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()}) + return + } if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) { return } @@ -401,6 +424,53 @@ func validateAdminWhitelist(whitelist string) error { return nil } +func validateOptionalKeepaliveSetting(key, value string) error { + switch key { + case settingCaddyKeepaliveIdle: + return validateKeepaliveIdleValue(value) + case settingCaddyKeepaliveCount: + return validateKeepaliveCountValue(value) + default: + return nil + } +} + +func validateKeepaliveIdleValue(value string) error { + idle := strings.TrimSpace(value) + if idle == "" { + return nil + } + + d, err := time.ParseDuration(idle) + if err != nil { + return fmt.Errorf("invalid caddy.keepalive_idle") + } + + if d < minCaddyKeepaliveIdleDuration || d > maxCaddyKeepaliveIdleDuration { + return fmt.Errorf("invalid caddy.keepalive_idle") + } + + return nil +} + +func validateKeepaliveCountValue(value string) error { + raw := strings.TrimSpace(value) + if raw == "" { + return nil + } + + count, err := strconv.Atoi(raw) + if err != nil { + return fmt.Errorf("invalid caddy.keepalive_count") + } + + if count < minCaddyKeepaliveCount || count > maxCaddyKeepaliveCount { + return fmt.Errorf("invalid caddy.keepalive_count") + } + + return nil +} + func (h *SettingsHandler) syncAdminWhitelist(whitelist string) error { return h.syncAdminWhitelistWithDB(h.DB, whitelist) } diff --git a/backend/internal/api/handlers/settings_handler_test.go b/backend/internal/api/handlers/settings_handler_test.go index fdc1097d..f64f4340 100644 --- a/backend/internal/api/handlers/settings_handler_test.go +++ b/backend/internal/api/handlers/settings_handler_test.go @@ -413,6 +413,58 @@ func TestSettingsHandler_UpdateSetting_InvalidAdminWhitelist(t *testing.T) { assert.Contains(t, w.Body.String(), "Invalid admin_whitelist") } +func TestSettingsHandler_UpdateSetting_InvalidKeepaliveIdle(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + handler := handlers.NewSettingsHandler(db) + router := newAdminRouter() + router.POST("/settings", handler.UpdateSetting) + + payload := map[string]string{ + "key": "caddy.keepalive_idle", + "value": "bad-duration", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid caddy.keepalive_idle") +} + +func TestSettingsHandler_UpdateSetting_ValidKeepaliveCount(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + handler := handlers.NewSettingsHandler(db) + router := newAdminRouter() + router.POST("/settings", handler.UpdateSetting) + + payload := map[string]string{ + "key": "caddy.keepalive_count", + "value": "9", + "category": "caddy", + "type": "number", + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var setting models.Setting + err := db.Where("key = ?", "caddy.keepalive_count").First(&setting).Error + assert.NoError(t, err) + assert.Equal(t, "9", setting.Value) +} + func TestSettingsHandler_UpdateSetting_SecurityKeyInvalidatesCache(t *testing.T) { gin.SetMode(gin.TestMode) db := setupSettingsTestDB(t) @@ -538,6 +590,64 @@ func TestSettingsHandler_PatchConfig_InvalidAdminWhitelist(t *testing.T) { assert.Contains(t, w.Body.String(), "Invalid admin_whitelist") } +func TestSettingsHandler_PatchConfig_InvalidKeepaliveCount(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + handler := handlers.NewSettingsHandler(db) + router := newAdminRouter() + router.PATCH("/config", handler.PatchConfig) + + payload := map[string]any{ + "caddy": map[string]any{ + "keepalive_count": 0, + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusBadRequest, w.Code) + assert.Contains(t, w.Body.String(), "invalid caddy.keepalive_count") +} + +func TestSettingsHandler_PatchConfig_ValidKeepaliveSettings(t *testing.T) { + gin.SetMode(gin.TestMode) + db := setupSettingsTestDB(t) + + handler := handlers.NewSettingsHandler(db) + router := newAdminRouter() + router.PATCH("/config", handler.PatchConfig) + + payload := map[string]any{ + "caddy": map[string]any{ + "keepalive_idle": "30s", + "keepalive_count": 12, + }, + } + body, _ := json.Marshal(payload) + + w := httptest.NewRecorder() + req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body)) + req.Header.Set("Content-Type", "application/json") + router.ServeHTTP(w, req) + + assert.Equal(t, http.StatusOK, w.Code) + + var idle models.Setting + err := db.Where("key = ?", "caddy.keepalive_idle").First(&idle).Error + assert.NoError(t, err) + assert.Equal(t, "30s", idle.Value) + + var count models.Setting + err = db.Where("key = ?", "caddy.keepalive_count").First(&count).Error + assert.NoError(t, err) + assert.Equal(t, "12", count.Value) +} + func TestSettingsHandler_PatchConfig_ReloadFailureReturns500(t *testing.T) { gin.SetMode(gin.TestMode) db := setupSettingsTestDB(t) diff --git a/backend/internal/caddy/config.go b/backend/internal/caddy/config.go index 60008607..63a8b893 100644 --- a/backend/internal/caddy/config.go +++ b/backend/internal/caddy/config.go @@ -857,6 +857,27 @@ func normalizeHeaderOps(headerOps map[string]any) { } } +func applyOptionalServerKeepalive(conf *Config, keepaliveIdle string, keepaliveCount int) { + if conf == nil || conf.Apps.HTTP == nil || conf.Apps.HTTP.Servers == nil { + return + } + + server, ok := conf.Apps.HTTP.Servers["charon_server"] + if !ok || server == nil { + return + } + + idle := strings.TrimSpace(keepaliveIdle) + if idle != "" { + server.KeepaliveIdle = &idle + } + + if keepaliveCount > 0 { + count := keepaliveCount + server.KeepaliveCount = &count + } +} + // NormalizeAdvancedConfig traverses a parsed JSON advanced config (map or array) // and normalizes any headers blocks so that header values are arrays of strings. // It returns the modified config object which can be JSON marshaled again. diff --git a/backend/internal/caddy/config_generate_test.go b/backend/internal/caddy/config_generate_test.go index d913f669..c3242f65 100644 --- a/backend/internal/caddy/config_generate_test.go +++ b/backend/internal/caddy/config_generate_test.go @@ -103,3 +103,43 @@ func TestGenerateConfig_EmergencyRoutesBypassSecurity(t *testing.T) { require.NotEqual(t, "crowdsec", name) } } + +func TestApplyOptionalServerKeepalive_OmitsWhenUnset(t *testing.T) { + cfg := &Config{ + Apps: Apps{ + HTTP: &HTTPApp{Servers: map[string]*Server{ + "charon_server": { + Listen: []string{":80", ":443"}, + Routes: []*Route{}, + }, + }}, + }, + } + + applyOptionalServerKeepalive(cfg, "", 0) + + server := cfg.Apps.HTTP.Servers["charon_server"] + require.Nil(t, server.KeepaliveIdle) + require.Nil(t, server.KeepaliveCount) +} + +func TestApplyOptionalServerKeepalive_AppliesValidValues(t *testing.T) { + cfg := &Config{ + Apps: Apps{ + HTTP: &HTTPApp{Servers: map[string]*Server{ + "charon_server": { + Listen: []string{":80", ":443"}, + Routes: []*Route{}, + }, + }}, + }, + } + + applyOptionalServerKeepalive(cfg, "45s", 7) + + server := cfg.Apps.HTTP.Servers["charon_server"] + require.NotNil(t, server.KeepaliveIdle) + require.Equal(t, "45s", *server.KeepaliveIdle) + require.NotNil(t, server.KeepaliveCount) + require.Equal(t, 7, *server.KeepaliveCount) +} diff --git a/backend/internal/caddy/manager.go b/backend/internal/caddy/manager.go index 01cf5447..c2cfab9d 100644 --- a/backend/internal/caddy/manager.go +++ b/backend/internal/caddy/manager.go @@ -8,6 +8,7 @@ import ( "os" "path/filepath" "sort" + "strconv" "strings" "time" @@ -33,6 +34,15 @@ var ( validateConfigFunc = Validate ) +const ( + minKeepaliveIdleDuration = time.Second + maxKeepaliveIdleDuration = 24 * time.Hour + minKeepaliveCount = 1 + maxKeepaliveCount = 100 + settingCaddyKeepaliveIdle = "caddy.keepalive_idle" + settingCaddyKeepaliveCnt = "caddy.keepalive_count" +) + // DNSProviderConfig contains a DNS provider with its decrypted credentials // for use in Caddy DNS challenge configuration generation type DNSProviderConfig struct { @@ -277,6 +287,18 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { // Compute effective security flags (re-read runtime overrides) _, aclEnabled, wafEnabled, rateLimitEnabled, crowdsecEnabled := m.computeEffectiveFlags(ctx) + keepaliveIdle := "" + var keepaliveIdleSetting models.Setting + if err := m.db.Where("key = ?", settingCaddyKeepaliveIdle).First(&keepaliveIdleSetting).Error; err == nil { + keepaliveIdle = sanitizeKeepaliveIdle(keepaliveIdleSetting.Value) + } + + keepaliveCount := 0 + var keepaliveCountSetting models.Setting + if err := m.db.Where("key = ?", settingCaddyKeepaliveCnt).First(&keepaliveCountSetting).Error; err == nil { + keepaliveCount = sanitizeKeepaliveCount(keepaliveCountSetting.Value) + } + // Safety check: if Cerberus is enabled in DB and no admin whitelist configured, // warn but allow initial startup to proceed. This prevents total lockout when // the user has enabled Cerberus but hasn't configured admin_whitelist yet. @@ -401,6 +423,8 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { return fmt.Errorf("generate config: %w", err) } + applyOptionalServerKeepalive(generatedConfig, keepaliveIdle, keepaliveCount) + // Debug logging: WAF configuration state for troubleshooting integration issues logger.Log().WithFields(map[string]any{ "waf_enabled": wafEnabled, @@ -467,6 +491,42 @@ func (m *Manager) ApplyConfig(ctx context.Context) error { return nil } +func sanitizeKeepaliveIdle(value string) string { + idle := strings.TrimSpace(value) + if idle == "" { + return "" + } + + d, err := time.ParseDuration(idle) + if err != nil { + return "" + } + + if d < minKeepaliveIdleDuration || d > maxKeepaliveIdleDuration { + return "" + } + + return idle +} + +func sanitizeKeepaliveCount(value string) int { + raw := strings.TrimSpace(value) + if raw == "" { + return 0 + } + + count, err := strconv.Atoi(raw) + if err != nil { + return 0 + } + + if count < minKeepaliveCount || count > maxKeepaliveCount { + return 0 + } + + return count +} + // saveSnapshot stores the config to disk with timestamp. func (m *Manager) saveSnapshot(conf *Config) (string, error) { timestamp := time.Now().Unix() diff --git a/backend/internal/caddy/manager_patch_coverage_test.go b/backend/internal/caddy/manager_patch_coverage_test.go index d9fab970..5939b322 100644 --- a/backend/internal/caddy/manager_patch_coverage_test.go +++ b/backend/internal/caddy/manager_patch_coverage_test.go @@ -1,8 +1,10 @@ package caddy import ( + "bytes" "context" "encoding/base64" + "io" "net/http" "net/http/httptest" "os" @@ -185,3 +187,93 @@ func TestManagerApplyConfig_DNSProviders_SkipsDecryptOrJSONFailures(t *testing.T require.Len(t, captured, 1) require.Equal(t, uint(24), captured[0].ID) } + +func TestManagerApplyConfig_MapsKeepaliveSettingsToGeneratedServer(t *testing.T) { + var loadBody []byte + caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/load" && r.Method == http.MethodPost { + payload, _ := io.ReadAll(r.Body) + loadBody = append([]byte(nil), payload...) + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer caddyServer.Close() + + dsn := "file:" + t.Name() + "?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.ProxyHost{}, + &models.Location{}, + &models.Setting{}, + &models.CaddyConfig{}, + &models.SSLCertificate{}, + &models.SecurityConfig{}, + &models.SecurityRuleSet{}, + &models.SecurityDecision{}, + &models.DNSProvider{}, + )) + + db.Create(&models.ProxyHost{DomainNames: "keepalive.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}) + db.Create(&models.SecurityConfig{Name: "default", Enabled: true}) + db.Create(&models.Setting{Key: settingCaddyKeepaliveIdle, Value: "45s"}) + db.Create(&models.Setting{Key: settingCaddyKeepaliveCnt, Value: "8"}) + + origVal := validateConfigFunc + defer func() { validateConfigFunc = origVal }() + validateConfigFunc = func(_ *Config) error { return nil } + + manager := NewManager(newTestClient(t, caddyServer.URL), db, t.TempDir(), "", false, config.SecurityConfig{CerberusEnabled: true}) + require.NoError(t, manager.ApplyConfig(context.Background())) + require.NotEmpty(t, loadBody) + + require.True(t, bytes.Contains(loadBody, []byte(`"keepalive_idle":"45s"`))) + require.True(t, bytes.Contains(loadBody, []byte(`"keepalive_count":8`))) +} + +func TestManagerApplyConfig_InvalidKeepaliveSettingsFallbackToDefaults(t *testing.T) { + var loadBody []byte + caddyServer := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/load" && r.Method == http.MethodPost { + payload, _ := io.ReadAll(r.Body) + loadBody = append([]byte(nil), payload...) + w.WriteHeader(http.StatusOK) + return + } + w.WriteHeader(http.StatusNotFound) + })) + defer caddyServer.Close() + + dsn := "file:" + t.Name() + "_invalid?mode=memory&cache=shared" + db, err := gorm.Open(sqlite.Open(dsn), &gorm.Config{}) + require.NoError(t, err) + require.NoError(t, db.AutoMigrate( + &models.ProxyHost{}, + &models.Location{}, + &models.Setting{}, + &models.CaddyConfig{}, + &models.SSLCertificate{}, + &models.SecurityConfig{}, + &models.SecurityRuleSet{}, + &models.SecurityDecision{}, + &models.DNSProvider{}, + )) + + db.Create(&models.ProxyHost{DomainNames: "invalid-keepalive.example.com", ForwardHost: "127.0.0.1", ForwardPort: 8080, Enabled: true}) + db.Create(&models.SecurityConfig{Name: "default", Enabled: true}) + db.Create(&models.Setting{Key: settingCaddyKeepaliveIdle, Value: "bad"}) + db.Create(&models.Setting{Key: settingCaddyKeepaliveCnt, Value: "-1"}) + + origVal := validateConfigFunc + defer func() { validateConfigFunc = origVal }() + validateConfigFunc = func(_ *Config) error { return nil } + + manager := NewManager(newTestClient(t, caddyServer.URL), db, t.TempDir(), "", false, config.SecurityConfig{CerberusEnabled: true}) + require.NoError(t, manager.ApplyConfig(context.Background())) + require.NotEmpty(t, loadBody) + + require.False(t, bytes.Contains(loadBody, []byte(`"keepalive_idle"`))) + require.False(t, bytes.Contains(loadBody, []byte(`"keepalive_count"`))) +} diff --git a/backend/internal/caddy/types.go b/backend/internal/caddy/types.go index 5fce7ba8..474964b1 100644 --- a/backend/internal/caddy/types.go +++ b/backend/internal/caddy/types.go @@ -83,6 +83,8 @@ type Server struct { AutoHTTPS *AutoHTTPSConfig `json:"automatic_https,omitempty"` Logs *ServerLogs `json:"logs,omitempty"` TrustedProxies *TrustedProxies `json:"trusted_proxies,omitempty"` + KeepaliveIdle *string `json:"keepalive_idle,omitempty"` + KeepaliveCount *int `json:"keepalive_count,omitempty"` } // TrustedProxies defines the module for configuring trusted proxy IP ranges. diff --git a/docs/issues/manual_test_pr3_keepalive_controls_closure.md b/docs/issues/manual_test_pr3_keepalive_controls_closure.md new file mode 100644 index 00000000..af3ff00a --- /dev/null +++ b/docs/issues/manual_test_pr3_keepalive_controls_closure.md @@ -0,0 +1,102 @@ +--- +title: "Manual Test Tracking Plan - PR-3 Keepalive Controls Closure" +labels: + - testing + - frontend + - backend + - security +priority: high +--- + +# Manual Test Tracking Plan - PR-3 Keepalive Controls Closure + +## Scope +PR-3 only. + +This plan tracks manual verification for: +- Keepalive control behavior in System Settings +- Safe default/fallback behavior for missing or invalid keepalive values +- Non-exposure constraints for deferred advanced settings + +Out of scope: +- PR-1 compatibility closure tasks +- PR-2 security posture closure tasks +- Any new page, route, or feature expansion beyond approved PR-3 controls + +## Preconditions +- [ ] Branch includes PR-3 closure changes only. +- [ ] Environment starts cleanly. +- [ ] Tester can access System Settings and save settings. +- [ ] Tester can restart and re-open the app to verify persisted behavior. + +## Track A - Keepalive Controls + +### TC-PR3-001 Keepalive controls are present and editable +- [ ] Open System Settings. +- [ ] Verify keepalive idle and keepalive count controls are visible. +- [ ] Enter valid values and save. +- Expected result: values save successfully and are shown after refresh. +- Status: [ ] Not run [ ] Pass [ ] Fail +- Notes: + +### TC-PR3-002 Keepalive values persist across reload +- [ ] Save valid keepalive idle and count values. +- [ ] Refresh the page. +- [ ] Re-open System Settings. +- Expected result: saved values are preserved. +- Status: [ ] Not run [ ] Pass [ ] Fail +- Notes: + +## Track B - Safe Defaults and Fallback + +### TC-PR3-003 Missing keepalive input keeps safe defaults +- [ ] Clear optional keepalive inputs (leave unset/empty where allowed). +- [ ] Save and reload settings. +- Expected result: app remains stable and uses safe default behavior. +- Status: [ ] Not run [ ] Pass [ ] Fail +- Notes: + +### TC-PR3-004 Invalid keepalive input is handled safely +- [ ] Enter invalid keepalive values (out-of-range or malformed). +- [ ] Attempt to save. +- [ ] Correct the values and save again. +- Expected result: invalid values are rejected safely; system remains stable; valid correction saves. +- Status: [ ] Not run [ ] Pass [ ] Fail +- Notes: + +### TC-PR3-005 Regression check after fallback path +- [ ] Trigger one invalid save attempt. +- [ ] Save valid values immediately after. +- [ ] Refresh and verify current values. +- Expected result: no stuck state; final valid values are preserved. +- Status: [ ] Not run [ ] Pass [ ] Fail +- Notes: + +## Track C - Non-Exposure Constraints + +### TC-PR3-006 Deferred advanced settings remain non-exposed +- [ ] Review System Settings controls. +- [ ] Confirm `trusted_proxies_unix` is not exposed. +- [ ] Confirm certificate lifecycle internals are not exposed. +- Expected result: only approved PR-3 keepalive controls are user-visible. +- Status: [ ] Not run [ ] Pass [ ] Fail +- Notes: + +### TC-PR3-007 Scope containment remains intact +- [ ] Verify no new page/tab/modal was introduced for PR-3 controls. +- [ ] Verify settings flow still uses existing System Settings experience. +- Expected result: PR-3 remains contained to approved existing surface. +- Status: [ ] Not run [ ] Pass [ ] Fail +- Notes: + +## Defect Log + +| ID | Test Case | Severity | Summary | Reproducible | Status | +| --- | --- | --- | --- | --- | --- | +| | | | | | | + +## Exit Criteria +- [ ] All PR-3 test cases executed. +- [ ] No unresolved critical defects. +- [ ] Keepalive controls, safe fallback/default behavior, and non-exposure constraints are verified. +- [ ] No PR-1 or PR-2 closure tasks introduced in this PR-3 plan. diff --git a/docs/plans/current_spec.md b/docs/plans/current_spec.md index 06fae334..a7527a07 100644 --- a/docs/plans/current_spec.md +++ b/docs/plans/current_spec.md @@ -649,27 +649,118 @@ Rollback notes: - Revert patch retirement lines and keep previous pinned patch model. -### PR-3: Optional UX/API exposure and cleanup +### PR-3: Optional UX/API exposure and cleanup (Focused Execution Update) -Scope: +Decision summary: -- only approved high-value settings exposed in existing settings surface -- backend mapping and frontend wiring using existing settings flows -- docs and translations updates if UI text changes +- PR-3 remains optional and value-gated. +- Expose only controls with clear operator value on existing `SystemSettings`. +- Keep low-value/high-risk knobs backend-default and non-exposed. + +Operator-value exposure decision: + +| Candidate | Operator value | Decision in PR-3 | +| --- | --- | --- | +| `keepalive_idle`, `keepalive_count` | Helps operators tune long-lived upstream behavior (streaming, websocket-heavy, high-connection churn) without editing config by hand. | **Expose minimally** (only if PR-2 confirms stable runtime behavior). | +| `trusted_proxies_unix` | Niche socket-chain use case, easy to misconfigure, low value for default Charon operators. | **Do not expose**; backend-default only. | +| `renewal_window_ratio` / cert maintenance internals | Advanced certificate lifecycle tuning with low day-to-day value and higher support burden. | **Do not expose**; backend-default only. | + +Strict scope constraints: + +- No new routes, pages, tabs, or modals. +- UI changes limited to existing `frontend/src/pages/SystemSettings.tsx` general/system section. +- API surface remains existing settings endpoints only (`POST /settings`, `PATCH /config`). +- Preserve backend defaults when setting is absent, empty, or invalid. + +Minimum viable controls (if PR-3 is activated): + +1. `caddy.keepalive_idle` (optional) + - Surface: `SystemSettings` under existing Caddy/system controls. + - UX: bounded select/input for duration-like value (validated server-side). + - Persistence: existing `updateSetting()` flow. +2. `caddy.keepalive_count` (optional) + - Surface: `SystemSettings` adjacent to keepalive idle. + - UX: bounded numeric control (validated server-side). + - Persistence: existing `updateSetting()` flow. + +Exact files/functions/components to change: + +Backend (no new endpoints): + +1. `backend/internal/caddy/manager.go` + - Function: `ApplyConfig(ctx context.Context) error` + - Change: read optional settings keys (`caddy.keepalive_idle`, `caddy.keepalive_count`), normalize/validate parsed values, pass sanitized values into config generation. + - Default rule: on missing/invalid values, pass empty/zero equivalents so generated config keeps current backend-default behavior. +2. `backend/internal/caddy/config.go` + - Function: `GenerateConfig(...)` + - Change: extend function parameters with optional keepalive values and apply them only when non-default/valid. + - Change location: HTTP server construction block where server-level settings (including trusted proxies) are assembled. +3. `backend/internal/caddy/types.go` + - Type: `Server` + - Change: add optional fields required to emit keepalive keys in Caddy JSON only when provided. +4. `backend/internal/api/handlers/settings_handler.go` + - Functions: `UpdateSetting(...)`, `PatchConfig(...)` + - Change: add narrow validation for `caddy.keepalive_idle` and `caddy.keepalive_count` to reject malformed/out-of-range values while preserving existing generic settings behavior for unrelated keys. + +Frontend (existing surface only): + +1. `frontend/src/pages/SystemSettings.tsx` + - Component: `SystemSettings` + - Change: add local state load/save wiring for optional keepalive controls using existing settings query/mutation flow. + - Change: render controls in existing General/System card only. +2. `frontend/src/api/settings.ts` + - No contract expansion required; reuse `updateSetting(key, value, category, type)`. +3. Localization files (labels/help text only, if controls are exposed): + - `frontend/src/locales/en/translation.json` + - `frontend/src/locales/de/translation.json` + - `frontend/src/locales/es/translation.json` + - `frontend/src/locales/fr/translation.json` + - `frontend/src/locales/zh/translation.json` + +Tests to update/add (targeted): + +1. `frontend/src/pages/__tests__/SystemSettings.test.tsx` + - Verify control rendering, default-state behavior, and save calls for optional keepalive keys. +2. `backend/internal/caddy/config_generate_test.go` + - Verify keepalive keys are omitted when unset/invalid and emitted when valid. +3. `backend/internal/api/handlers/settings_handler_test.go` + - Verify validation pass/fail for keepalive keys via both `UpdateSetting` and `PatchConfig` paths. +4. Existing E2E settings coverage (no new suite) + - Extend existing settings-related specs only if UI controls are activated in PR-3. Dependencies: -- PR-2 must establish stable runtime baseline first +- PR-2 must establish stable runtime/security baseline first. +- PR-3 activation requires explicit operator-value confirmation from PR-2 evidence. -Acceptance criteria: +Acceptance criteria (PR-3 complete): -1. No net-new page; updates land in existing `SystemSettings` domain. -2. E2E and unit tests cover newly exposed controls and defaults. -3. Deferred features explicitly documented with rationale. +1. No net-new page; all UI changes are within `SystemSettings` only. +2. No new backend routes/endpoints; existing settings APIs are reused. +3. Only approved controls (`caddy.keepalive_idle`, `caddy.keepalive_count`) are exposed, and exposure is allowed only if the PR-3 Value Gate checklist is fully satisfied. +4. `trusted_proxies_unix`, `renewal_window_ratio`, and certificate-maintenance internals remain backend-default and non-exposed. +5. Backend preserves current behavior when optional keepalive settings are absent or invalid (no generated-config drift). +6. Unit tests pass for settings validation + config generation default/override behavior. +7. Settings UI tests pass for load/save/default behavior on exposed controls. +8. Deferred/non-exposed features are explicitly documented in PR notes as intentional non-goals. + +#### PR-3 Value Gate (required evidence and approval) + +Required evidence checklist (all items required): + +- [ ] PR-2 evidence bundle contains an explicit operator-value decision record for PR-3 controls, naming `caddy.keepalive_idle` and `caddy.keepalive_count` individually. +- [ ] Decision record includes objective evidence for each exposed control from at least one concrete source: test/baseline artifact, compatibility/security report, or documented operator requirement. +- [ ] PR includes before/after evidence proving scope containment: no new page, no new route, and no additional exposed Caddy keys beyond the two approved controls. +- [ ] Validation artifacts for PR-3 are attached: backend unit tests, frontend settings tests, and generated-config assertions for default/override behavior. + +Approval condition (pass/fail): + +- **Pass**: all checklist items are complete and a maintainer approval explicitly states "PR-3 Value Gate approved". +- **Fail**: any checklist item is missing or approval text is absent; PR-3 control exposure is blocked and controls remain backend-default/non-exposed. Rollback notes: -- Revert UI/API additions while retaining already landed security/runtime upgrades. +- Revert only PR-3 UI/settings mapping changes while retaining PR-1/PR-2 runtime and security upgrades. ## Config File Review and Proposed Updates @@ -735,3 +826,32 @@ After approval of this plan: (especially patch removals). 3. Treat PR-3 as optional and value-driven, not mandatory for the security update itself. + +## PR-3 QA Closure Addendum (2026-02-23) + +### Scope + +PR-3 closure only: + +1. Keepalive controls (`caddy.keepalive_idle`, `caddy.keepalive_count`) +2. Safe defaults/fallback behavior when keepalive values are missing or invalid +3. Non-exposure constraints for deferred settings + +### Final QA Outcome + +- Verdict: **READY (PASS)** +- Targeted PR-3 E2E rerun: **30 passed, 0 failed** +- Local patch preflight: **PASS** with required LCOV artifact present +- Coverage/type-check/security gates: **PASS** + +### Scope Guardrails Confirmed + +- UI scope remains constrained to existing System Settings surface. +- No PR-3 expansion beyond approved keepalive controls. +- Non-exposed settings remain non-exposed (`trusted_proxies_unix` and certificate lifecycle internals). +- Safe fallback/default behavior remains intact for invalid or absent keepalive input. + +### Reviewer References + +- QA closure report: `docs/reports/qa_report.md` +- Manual verification plan: `docs/issues/manual_test_pr3_keepalive_controls_closure.md` diff --git a/docs/reports/qa_report.md b/docs/reports/qa_report.md index 799791c4..6b0e0eba 100644 --- a/docs/reports/qa_report.md +++ b/docs/reports/qa_report.md @@ -23,3 +23,35 @@ ## PR-2 Closure Statement All PR-2 QA/security gates required for merge are passing. No PR-3 scope is included in this report. + +--- + +## QA Report — PR-3 Keepalive Controls Closure + +- Date: 2026-02-23 +- Scope: PR-3 only (keepalive controls, safe fallback/default behavior, non-exposure constraints) +- Verdict: **READY (PASS)** + +## Reviewer Gate Summary (PR-3) + +| Gate | Status | Reviewer evidence | +| --- | --- | --- | +| Targeted E2E rerun | PASS | Security settings targeted rerun completed: **30 passed, 0 failed**. | +| Local patch preflight | PASS | `frontend/coverage/lcov.info` present; `scripts/local-patch-report.sh` artifacts regenerated with `pass` status. | +| Coverage + type-check | PASS | Frontend coverage gate passed (89% lines vs 85% minimum); type-check passed. | +| Pre-commit + security scans | PASS | `pre-commit --all-files`, CodeQL Go/JS CI-aligned scans, findings gate, and Trivy checks passed (no HIGH/CRITICAL blockers). | +| Final readiness | PASS | All PR-3 closure gates are green. | + +## Scope Guardrails Verified (PR-3) + +- Keepalive controls are limited to approved PR-3 scope. +- Safe fallback behavior remains intact when keepalive values are missing or invalid. +- Non-exposure constraints remain intact (`trusted_proxies_unix` and certificate lifecycle internals are not exposed). + +## Manual Verification Reference + +- PR-3 manual test tracking plan: `docs/issues/manual_test_pr3_keepalive_controls_closure.md` + +## PR-3 Closure Statement + +PR-3 is **ready to merge** with no open QA blockers. diff --git a/frontend/src/locales/de/translation.json b/frontend/src/locales/de/translation.json index 33af5ccb..e8610749 100644 --- a/frontend/src/locales/de/translation.json +++ b/frontend/src/locales/de/translation.json @@ -768,6 +768,13 @@ "newTab": "Neuer Tab (Standard)", "newWindow": "Neues Fenster", "domainLinkBehaviorHelper": "Steuern Sie, wie Domain-Links in der Proxy-Hosts-Liste geöffnet werden.", + "keepaliveIdle": "Keepalive Idle (Optional)", + "keepaliveIdleHelper": "Optionale Caddy-Dauer (z. B. 2m, 30s). Leer lassen, um Backend-Standardwerte zu verwenden.", + "keepaliveIdleError": "Geben Sie eine gültige Dauer ein (z. B. 30s, 2m, 1h).", + "keepaliveCount": "Keepalive Count (Optional)", + "keepaliveCountHelper": "Optionale maximale Keepalive-Tests (1-1000). Leer lassen, um Backend-Standardwerte zu verwenden.", + "keepaliveCountError": "Geben Sie eine ganze Zahl zwischen 1 und 1000 ein.", + "keepaliveValidationFailed": "Keepalive-Einstellungen enthalten ungültige Werte.", "languageHelper": "Wählen Sie Ihre bevorzugte Sprache. Änderungen werden sofort wirksam." }, "applicationUrl": { diff --git a/frontend/src/locales/en/translation.json b/frontend/src/locales/en/translation.json index fb769b1d..e89e2d99 100644 --- a/frontend/src/locales/en/translation.json +++ b/frontend/src/locales/en/translation.json @@ -876,6 +876,13 @@ "newTab": "New Tab (Default)", "newWindow": "New Window", "domainLinkBehaviorHelper": "Control how domain links open in the Proxy Hosts list.", + "keepaliveIdle": "Keepalive Idle (Optional)", + "keepaliveIdleHelper": "Optional Caddy duration (e.g., 2m, 30s). Leave blank to keep backend defaults.", + "keepaliveIdleError": "Enter a valid duration (for example: 30s, 2m, 1h).", + "keepaliveCount": "Keepalive Count (Optional)", + "keepaliveCountHelper": "Optional max keepalive probes (1-1000). Leave blank to keep backend defaults.", + "keepaliveCountError": "Enter a whole number between 1 and 1000.", + "keepaliveValidationFailed": "Keepalive settings contain invalid values.", "languageHelper": "Select your preferred language. Changes take effect immediately." }, "applicationUrl": { diff --git a/frontend/src/locales/es/translation.json b/frontend/src/locales/es/translation.json index d30ca0f2..07593570 100644 --- a/frontend/src/locales/es/translation.json +++ b/frontend/src/locales/es/translation.json @@ -768,6 +768,13 @@ "newTab": "Nueva Pestaña (Por defecto)", "newWindow": "Nueva Ventana", "domainLinkBehaviorHelper": "Controla cómo se abren los enlaces de dominio en la lista de Hosts Proxy.", + "keepaliveIdle": "Keepalive Idle (Opcional)", + "keepaliveIdleHelper": "Duración opcional de Caddy (por ejemplo, 2m, 30s). Déjelo vacío para mantener los valores predeterminados del backend.", + "keepaliveIdleError": "Ingrese una duración válida (por ejemplo: 30s, 2m, 1h).", + "keepaliveCount": "Keepalive Count (Opcional)", + "keepaliveCountHelper": "Número máximo opcional de sondeos keepalive (1-1000). Déjelo vacío para mantener los valores predeterminados del backend.", + "keepaliveCountError": "Ingrese un número entero entre 1 y 1000.", + "keepaliveValidationFailed": "La configuración de keepalive contiene valores no válidos.", "languageHelper": "Selecciona tu idioma preferido. Los cambios surten efecto inmediatamente." }, "applicationUrl": { "title": "URL de aplicación", diff --git a/frontend/src/locales/fr/translation.json b/frontend/src/locales/fr/translation.json index ab379313..9853dffc 100644 --- a/frontend/src/locales/fr/translation.json +++ b/frontend/src/locales/fr/translation.json @@ -768,6 +768,13 @@ "newTab": "Nouvel Onglet (Par défaut)", "newWindow": "Nouvelle Fenêtre", "domainLinkBehaviorHelper": "Contrôle comment les liens de domaine s'ouvrent dans la liste des Hôtes Proxy.", + "keepaliveIdle": "Keepalive Idle (Optionnel)", + "keepaliveIdleHelper": "Durée Caddy optionnelle (ex. 2m, 30s). Laissez vide pour conserver les valeurs par défaut du backend.", + "keepaliveIdleError": "Entrez une durée valide (par exemple : 30s, 2m, 1h).", + "keepaliveCount": "Keepalive Count (Optionnel)", + "keepaliveCountHelper": "Nombre maximal optionnel de sondes keepalive (1-1000). Laissez vide pour conserver les valeurs par défaut du backend.", + "keepaliveCountError": "Entrez un nombre entier entre 1 et 1000.", + "keepaliveValidationFailed": "Les paramètres keepalive contiennent des valeurs invalides.", "languageHelper": "Sélectionnez votre langue préférée. Les modifications prennent effet immédiatement." }, "applicationUrl": { "title": "URL de l'application", diff --git a/frontend/src/locales/zh/translation.json b/frontend/src/locales/zh/translation.json index b74471c4..09e96cdd 100644 --- a/frontend/src/locales/zh/translation.json +++ b/frontend/src/locales/zh/translation.json @@ -768,6 +768,13 @@ "newTab": "新标签页(默认)", "newWindow": "新窗口", "domainLinkBehaviorHelper": "控制代理主机列表中的域名链接如何打开。", + "keepaliveIdle": "Keepalive Idle(可选)", + "keepaliveIdleHelper": "可选的 Caddy 时长(例如 2m、30s)。留空可使用后端默认值。", + "keepaliveIdleError": "请输入有效时长(例如:30s、2m、1h)。", + "keepaliveCount": "Keepalive Count(可选)", + "keepaliveCountHelper": "可选的 keepalive 最大探测次数(1-1000)。留空可使用后端默认值。", + "keepaliveCountError": "请输入 1 到 1000 之间的整数。", + "keepaliveValidationFailed": "keepalive 设置包含无效值。", "languageHelper": "选择您的首选语言。更改立即生效。" }, "applicationUrl": { diff --git a/frontend/src/pages/SystemSettings.tsx b/frontend/src/pages/SystemSettings.tsx index 4cd9f1a8..3ef8a24e 100644 --- a/frontend/src/pages/SystemSettings.tsx +++ b/frontend/src/pages/SystemSettings.tsx @@ -41,11 +41,32 @@ export default function SystemSettings() { const queryClient = useQueryClient() const [caddyAdminAPI, setCaddyAdminAPI] = useState('http://localhost:2019') const [sslProvider, setSslProvider] = useState('auto') + const [keepaliveIdle, setKeepaliveIdle] = useState('') + const [keepaliveCount, setKeepaliveCount] = useState('') const [domainLinkBehavior, setDomainLinkBehavior] = useState('new_tab') const [publicURL, setPublicURL] = useState('') const [publicURLValid, setPublicURLValid] = useState(null) const [publicURLSaving, setPublicURLSaving] = useState(false) + const keepaliveIdlePattern = /^(?:\d+)(?:ns|us|µs|ms|s|m|h)$/ + const keepaliveIdleTrimmed = keepaliveIdle.trim() + const keepaliveCountTrimmed = keepaliveCount.trim() + const keepaliveIdleError = + keepaliveIdleTrimmed.length > 0 && !keepaliveIdlePattern.test(keepaliveIdleTrimmed) + ? t('systemSettings.general.keepaliveIdleError') + : undefined + const keepaliveCountError = (() => { + if (!keepaliveCountTrimmed) { + return undefined + } + const parsed = Number.parseInt(keepaliveCountTrimmed, 10) + if (!Number.isInteger(parsed) || parsed < 1 || parsed > 1000) { + return t('systemSettings.general.keepaliveCountError') + } + return undefined + })() + const hasKeepaliveValidationError = Boolean(keepaliveIdleError || keepaliveCountError) + // Fetch Settings const { data: settings } = useQuery({ queryKey: ['settings'], @@ -62,6 +83,8 @@ export default function SystemSettings() { const provider = settings['caddy.ssl_provider'] setSslProvider(validProviders.includes(provider) ? provider : 'auto') } + setKeepaliveIdle(settings['caddy.keepalive_idle'] ?? '') + setKeepaliveCount(settings['caddy.keepalive_count'] ?? '') if (settings['ui.domain_link_behavior']) setDomainLinkBehavior(settings['ui.domain_link_behavior']) if (settings['app.public_url']) setPublicURL(settings['app.public_url']) } @@ -139,8 +162,14 @@ export default function SystemSettings() { const saveSettingsMutation = useMutation({ mutationFn: async () => { + if (hasKeepaliveValidationError) { + throw new Error(t('systemSettings.general.keepaliveValidationFailed')) + } + await updateSetting('caddy.admin_api', caddyAdminAPI, 'caddy', 'string') await updateSetting('caddy.ssl_provider', sslProvider, 'caddy', 'string') + await updateSetting('caddy.keepalive_idle', keepaliveIdleTrimmed, 'caddy', 'string') + await updateSetting('caddy.keepalive_count', keepaliveCountTrimmed, 'caddy', 'string') await updateSetting('ui.domain_link_behavior', domainLinkBehavior, 'ui', 'string') await updateSetting('app.public_url', publicURL, 'general', 'string') }, @@ -341,6 +370,36 @@ export default function SystemSettings() {

+
+ + setKeepaliveIdle(e.target.value)} + placeholder="2m" + error={keepaliveIdleError} + helperText={t('systemSettings.general.keepaliveIdleHelper')} + aria-invalid={keepaliveIdleError ? 'true' : 'false'} + /> +
+ +
+ + setKeepaliveCount(e.target.value)} + placeholder="3" + error={keepaliveCountError} + helperText={t('systemSettings.general.keepaliveCountHelper')} + aria-invalid={keepaliveCountError ? 'true' : 'false'} + /> +
+
@@ -353,6 +412,7 @@ export default function SystemSettings() {