Skip to content

Commit ee5350d

Browse files
committed
feat: add keepalive controls to System Settings
- Introduced optional keepalive settings: `keepalive_idle` and `keepalive_count` in the Server struct. - Implemented UI controls for keepalive settings in System Settings, including validation and persistence. - Added localization support for new keepalive fields in multiple languages. - Created a manual test tracking plan for verifying keepalive controls and their behavior. - Updated existing tests to cover new functionality and ensure proper validation of keepalive inputs. - Ensured safe defaults and fallback behavior for missing or invalid keepalive values.
1 parent 9424aca commit ee5350d

19 files changed

Lines changed: 940 additions & 107 deletions

backend/internal/api/handlers/settings_handler.go

Lines changed: 70 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"errors"
66
"fmt"
77
"net/http"
8+
"strconv"
89
"strings"
910
"time"
1011

@@ -37,6 +38,15 @@ type SettingsHandler struct {
3738
DataRoot string
3839
}
3940

41+
const (
42+
settingCaddyKeepaliveIdle = "caddy.keepalive_idle"
43+
settingCaddyKeepaliveCount = "caddy.keepalive_count"
44+
minCaddyKeepaliveIdleDuration = time.Second
45+
maxCaddyKeepaliveIdleDuration = 24 * time.Hour
46+
minCaddyKeepaliveCount = 1
47+
maxCaddyKeepaliveCount = 100
48+
)
49+
4050
func NewSettingsHandler(db *gorm.DB) *SettingsHandler {
4151
return &SettingsHandler{
4252
DB: db,
@@ -109,6 +119,11 @@ func (h *SettingsHandler) UpdateSetting(c *gin.Context) {
109119
}
110120
}
111121

122+
if err := validateOptionalKeepaliveSetting(req.Key, req.Value); err != nil {
123+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
124+
return
125+
}
126+
112127
setting := models.Setting{
113128
Key: req.Key,
114129
Value: req.Value,
@@ -247,6 +262,10 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
247262
}
248263
}
249264

265+
if err := validateOptionalKeepaliveSetting(key, value); err != nil {
266+
return err
267+
}
268+
250269
setting := models.Setting{
251270
Key: key,
252271
Value: value,
@@ -284,6 +303,10 @@ func (h *SettingsHandler) PatchConfig(c *gin.Context) {
284303
c.JSON(http.StatusBadRequest, gin.H{"error": "Invalid admin_whitelist"})
285304
return
286305
}
306+
if strings.Contains(err.Error(), "invalid caddy.keepalive_idle") || strings.Contains(err.Error(), "invalid caddy.keepalive_count") {
307+
c.JSON(http.StatusBadRequest, gin.H{"error": err.Error()})
308+
return
309+
}
287310
if respondPermissionError(c, h.SecuritySvc, "settings_save_failed", err, h.DataRoot) {
288311
return
289312
}
@@ -401,6 +424,53 @@ func validateAdminWhitelist(whitelist string) error {
401424
return nil
402425
}
403426

427+
func validateOptionalKeepaliveSetting(key, value string) error {
428+
switch key {
429+
case settingCaddyKeepaliveIdle:
430+
return validateKeepaliveIdleValue(value)
431+
case settingCaddyKeepaliveCount:
432+
return validateKeepaliveCountValue(value)
433+
default:
434+
return nil
435+
}
436+
}
437+
438+
func validateKeepaliveIdleValue(value string) error {
439+
idle := strings.TrimSpace(value)
440+
if idle == "" {
441+
return nil
442+
}
443+
444+
d, err := time.ParseDuration(idle)
445+
if err != nil {
446+
return fmt.Errorf("invalid caddy.keepalive_idle")
447+
}
448+
449+
if d < minCaddyKeepaliveIdleDuration || d > maxCaddyKeepaliveIdleDuration {
450+
return fmt.Errorf("invalid caddy.keepalive_idle")
451+
}
452+
453+
return nil
454+
}
455+
456+
func validateKeepaliveCountValue(value string) error {
457+
raw := strings.TrimSpace(value)
458+
if raw == "" {
459+
return nil
460+
}
461+
462+
count, err := strconv.Atoi(raw)
463+
if err != nil {
464+
return fmt.Errorf("invalid caddy.keepalive_count")
465+
}
466+
467+
if count < minCaddyKeepaliveCount || count > maxCaddyKeepaliveCount {
468+
return fmt.Errorf("invalid caddy.keepalive_count")
469+
}
470+
471+
return nil
472+
}
473+
404474
func (h *SettingsHandler) syncAdminWhitelist(whitelist string) error {
405475
return h.syncAdminWhitelistWithDB(h.DB, whitelist)
406476
}

backend/internal/api/handlers/settings_handler_test.go

Lines changed: 110 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -413,6 +413,58 @@ func TestSettingsHandler_UpdateSetting_InvalidAdminWhitelist(t *testing.T) {
413413
assert.Contains(t, w.Body.String(), "Invalid admin_whitelist")
414414
}
415415

416+
func TestSettingsHandler_UpdateSetting_InvalidKeepaliveIdle(t *testing.T) {
417+
gin.SetMode(gin.TestMode)
418+
db := setupSettingsTestDB(t)
419+
420+
handler := handlers.NewSettingsHandler(db)
421+
router := newAdminRouter()
422+
router.POST("/settings", handler.UpdateSetting)
423+
424+
payload := map[string]string{
425+
"key": "caddy.keepalive_idle",
426+
"value": "bad-duration",
427+
}
428+
body, _ := json.Marshal(payload)
429+
430+
w := httptest.NewRecorder()
431+
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
432+
req.Header.Set("Content-Type", "application/json")
433+
router.ServeHTTP(w, req)
434+
435+
assert.Equal(t, http.StatusBadRequest, w.Code)
436+
assert.Contains(t, w.Body.String(), "invalid caddy.keepalive_idle")
437+
}
438+
439+
func TestSettingsHandler_UpdateSetting_ValidKeepaliveCount(t *testing.T) {
440+
gin.SetMode(gin.TestMode)
441+
db := setupSettingsTestDB(t)
442+
443+
handler := handlers.NewSettingsHandler(db)
444+
router := newAdminRouter()
445+
router.POST("/settings", handler.UpdateSetting)
446+
447+
payload := map[string]string{
448+
"key": "caddy.keepalive_count",
449+
"value": "9",
450+
"category": "caddy",
451+
"type": "number",
452+
}
453+
body, _ := json.Marshal(payload)
454+
455+
w := httptest.NewRecorder()
456+
req, _ := http.NewRequest("POST", "/settings", bytes.NewBuffer(body))
457+
req.Header.Set("Content-Type", "application/json")
458+
router.ServeHTTP(w, req)
459+
460+
assert.Equal(t, http.StatusOK, w.Code)
461+
462+
var setting models.Setting
463+
err := db.Where("key = ?", "caddy.keepalive_count").First(&setting).Error
464+
assert.NoError(t, err)
465+
assert.Equal(t, "9", setting.Value)
466+
}
467+
416468
func TestSettingsHandler_UpdateSetting_SecurityKeyInvalidatesCache(t *testing.T) {
417469
gin.SetMode(gin.TestMode)
418470
db := setupSettingsTestDB(t)
@@ -538,6 +590,64 @@ func TestSettingsHandler_PatchConfig_InvalidAdminWhitelist(t *testing.T) {
538590
assert.Contains(t, w.Body.String(), "Invalid admin_whitelist")
539591
}
540592

593+
func TestSettingsHandler_PatchConfig_InvalidKeepaliveCount(t *testing.T) {
594+
gin.SetMode(gin.TestMode)
595+
db := setupSettingsTestDB(t)
596+
597+
handler := handlers.NewSettingsHandler(db)
598+
router := newAdminRouter()
599+
router.PATCH("/config", handler.PatchConfig)
600+
601+
payload := map[string]any{
602+
"caddy": map[string]any{
603+
"keepalive_count": 0,
604+
},
605+
}
606+
body, _ := json.Marshal(payload)
607+
608+
w := httptest.NewRecorder()
609+
req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body))
610+
req.Header.Set("Content-Type", "application/json")
611+
router.ServeHTTP(w, req)
612+
613+
assert.Equal(t, http.StatusBadRequest, w.Code)
614+
assert.Contains(t, w.Body.String(), "invalid caddy.keepalive_count")
615+
}
616+
617+
func TestSettingsHandler_PatchConfig_ValidKeepaliveSettings(t *testing.T) {
618+
gin.SetMode(gin.TestMode)
619+
db := setupSettingsTestDB(t)
620+
621+
handler := handlers.NewSettingsHandler(db)
622+
router := newAdminRouter()
623+
router.PATCH("/config", handler.PatchConfig)
624+
625+
payload := map[string]any{
626+
"caddy": map[string]any{
627+
"keepalive_idle": "30s",
628+
"keepalive_count": 12,
629+
},
630+
}
631+
body, _ := json.Marshal(payload)
632+
633+
w := httptest.NewRecorder()
634+
req, _ := http.NewRequest(http.MethodPatch, "/config", bytes.NewBuffer(body))
635+
req.Header.Set("Content-Type", "application/json")
636+
router.ServeHTTP(w, req)
637+
638+
assert.Equal(t, http.StatusOK, w.Code)
639+
640+
var idle models.Setting
641+
err := db.Where("key = ?", "caddy.keepalive_idle").First(&idle).Error
642+
assert.NoError(t, err)
643+
assert.Equal(t, "30s", idle.Value)
644+
645+
var count models.Setting
646+
err = db.Where("key = ?", "caddy.keepalive_count").First(&count).Error
647+
assert.NoError(t, err)
648+
assert.Equal(t, "12", count.Value)
649+
}
650+
541651
func TestSettingsHandler_PatchConfig_ReloadFailureReturns500(t *testing.T) {
542652
gin.SetMode(gin.TestMode)
543653
db := setupSettingsTestDB(t)

backend/internal/caddy/config.go

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -857,6 +857,27 @@ func normalizeHeaderOps(headerOps map[string]any) {
857857
}
858858
}
859859

860+
func applyOptionalServerKeepalive(conf *Config, keepaliveIdle string, keepaliveCount int) {
861+
if conf == nil || conf.Apps.HTTP == nil || conf.Apps.HTTP.Servers == nil {
862+
return
863+
}
864+
865+
server, ok := conf.Apps.HTTP.Servers["charon_server"]
866+
if !ok || server == nil {
867+
return
868+
}
869+
870+
idle := strings.TrimSpace(keepaliveIdle)
871+
if idle != "" {
872+
server.KeepaliveIdle = &idle
873+
}
874+
875+
if keepaliveCount > 0 {
876+
count := keepaliveCount
877+
server.KeepaliveCount = &count
878+
}
879+
}
880+
860881
// NormalizeAdvancedConfig traverses a parsed JSON advanced config (map or array)
861882
// and normalizes any headers blocks so that header values are arrays of strings.
862883
// It returns the modified config object which can be JSON marshaled again.

backend/internal/caddy/config_generate_test.go

Lines changed: 40 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -103,3 +103,43 @@ func TestGenerateConfig_EmergencyRoutesBypassSecurity(t *testing.T) {
103103
require.NotEqual(t, "crowdsec", name)
104104
}
105105
}
106+
107+
func TestApplyOptionalServerKeepalive_OmitsWhenUnset(t *testing.T) {
108+
cfg := &Config{
109+
Apps: Apps{
110+
HTTP: &HTTPApp{Servers: map[string]*Server{
111+
"charon_server": {
112+
Listen: []string{":80", ":443"},
113+
Routes: []*Route{},
114+
},
115+
}},
116+
},
117+
}
118+
119+
applyOptionalServerKeepalive(cfg, "", 0)
120+
121+
server := cfg.Apps.HTTP.Servers["charon_server"]
122+
require.Nil(t, server.KeepaliveIdle)
123+
require.Nil(t, server.KeepaliveCount)
124+
}
125+
126+
func TestApplyOptionalServerKeepalive_AppliesValidValues(t *testing.T) {
127+
cfg := &Config{
128+
Apps: Apps{
129+
HTTP: &HTTPApp{Servers: map[string]*Server{
130+
"charon_server": {
131+
Listen: []string{":80", ":443"},
132+
Routes: []*Route{},
133+
},
134+
}},
135+
},
136+
}
137+
138+
applyOptionalServerKeepalive(cfg, "45s", 7)
139+
140+
server := cfg.Apps.HTTP.Servers["charon_server"]
141+
require.NotNil(t, server.KeepaliveIdle)
142+
require.Equal(t, "45s", *server.KeepaliveIdle)
143+
require.NotNil(t, server.KeepaliveCount)
144+
require.Equal(t, 7, *server.KeepaliveCount)
145+
}

0 commit comments

Comments
 (0)