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