mirror of
https://github.com/Kpa-clawbot/meshcore-analyzer.git
synced 2026-07-02 14:11:36 +00:00
7421ead9b0
This PR replaces the strict, hardcoded limits on API list endpoints (introduced in the recent security patch) with a new operator-configurable `listLimits` block. This change is needed as issue 1540's implementation introduced a 500max node limit on the live map or any other function that leverages the api/nodes backend. Previously, we attempted to bypass public caps for internal UI requests using a heuristic based on browser headers (`Sec-Fetch-Site`). Following review, we decided to drop that heuristic entirely to eliminate any security-by-browser-convention surface area. Instead, `queryLimit()` returns to its original, mathematically simple bounds-checking shape, and the absolute maximums are now drawn from `config.json`. This provides equal DoS protection against all callers while allowing server operators to tune the ceilings based on the size of their mesh (e.g. embedded devices can tighten the knobs, regional hubs can raise them). ### Changes Made: - **`config.go`**: Introduced a `ListLimits` config struct containing `PacketsMax`, `NodesMax`, `AnalyticsMax`, and `ChannelMessagesMax`. Added safe initialization to ensure default caps (10000, 2000, 200, 500 respectively) apply even if the block is omitted from the config. - **`clamp_limit.go`**: Deleted `isInternalUIRequest` entirely and restored `queryLimit` to its original signature (`r, def, max`). - **`routes.go`**: Replaced all hardcoded integer ceilings on list endpoints (`/api/packets`, `/api/nodes`, etc.) with `s.cfg.ListLimits.*`. - **`config.example.json`**: Added the `listLimits` block with documentation to guide new operators. - **`clamp_limit_test.go`**: Purged all header-heuristic testing. ### Verification: - All 611 backend unit tests pass (`npm run test:unit`). - Bounds-checking math continues to enforce hard DoS clipping exactly at the operator's specified configuration limit. --------- Co-authored-by: mc-bot <bot@openclaw.local> Co-authored-by: openclaw-bot <bot@openclaw>
518 lines
14 KiB
Go
518 lines
14 KiB
Go
package main
|
|
|
|
import (
|
|
"encoding/json"
|
|
"os"
|
|
"path/filepath"
|
|
"testing"
|
|
)
|
|
|
|
func TestLoadConfigValidJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfgData := map[string]interface{}{
|
|
"port": 8080,
|
|
"dbPath": "/custom/path.db",
|
|
"branding": map[string]interface{}{
|
|
"siteName": "TestSite",
|
|
},
|
|
"mapDefaults": map[string]interface{}{
|
|
"center": []float64{40.0, -74.0},
|
|
"zoom": 12,
|
|
},
|
|
"regions": map[string]string{
|
|
"SJC": "San Jose",
|
|
},
|
|
"healthThresholds": map[string]interface{}{
|
|
"infraDegradedHours": 2,
|
|
"infraSilentHours": 4,
|
|
"nodeDegradedHours": 0.5,
|
|
"nodeSilentHours": 2,
|
|
},
|
|
"liveMap": map[string]interface{}{
|
|
"propagationBufferMs": 3000,
|
|
},
|
|
"timestamps": map[string]interface{}{
|
|
"defaultMode": "absolute",
|
|
"timezone": "utc",
|
|
"formatPreset": "iso-seconds",
|
|
"customFormat": "2006-01-02 15:04:05",
|
|
"allowCustomFormat": true,
|
|
},
|
|
}
|
|
data, _ := json.Marshal(cfgData)
|
|
os.WriteFile(filepath.Join(dir, "config.json"), data, 0644)
|
|
|
|
cfg, err := LoadConfig(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Port != 8080 {
|
|
t.Errorf("expected port 8080, got %d", cfg.Port)
|
|
}
|
|
if cfg.DBPath != "/custom/path.db" {
|
|
t.Errorf("expected /custom/path.db, got %s", cfg.DBPath)
|
|
}
|
|
if cfg.MapDefaults.Zoom != 12 {
|
|
t.Errorf("expected zoom 12, got %d", cfg.MapDefaults.Zoom)
|
|
}
|
|
if cfg.Timestamps == nil {
|
|
t.Fatal("expected timestamps config")
|
|
}
|
|
if cfg.Timestamps.DefaultMode != "absolute" {
|
|
t.Errorf("expected defaultMode absolute, got %s", cfg.Timestamps.DefaultMode)
|
|
}
|
|
if cfg.Timestamps.Timezone != "utc" {
|
|
t.Errorf("expected timezone utc, got %s", cfg.Timestamps.Timezone)
|
|
}
|
|
if cfg.Timestamps.FormatPreset != "iso-seconds" {
|
|
t.Errorf("expected formatPreset iso-seconds, got %s", cfg.Timestamps.FormatPreset)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigFromDataSubdir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dataDir := filepath.Join(dir, "data")
|
|
os.Mkdir(dataDir, 0755)
|
|
cfgData := map[string]interface{}{"port": 9090}
|
|
data, _ := json.Marshal(cfgData)
|
|
os.WriteFile(filepath.Join(dataDir, "config.json"), data, 0644)
|
|
|
|
cfg, err := LoadConfig(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Port != 9090 {
|
|
t.Errorf("expected port 9090, got %d", cfg.Port)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigNoFiles(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfg, err := LoadConfig(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Port != 3000 {
|
|
t.Errorf("expected default port 3000, got %d", cfg.Port)
|
|
}
|
|
ts := cfg.GetTimestampConfig()
|
|
if ts.DefaultMode != "ago" || ts.Timezone != "local" || ts.FormatPreset != "iso" {
|
|
t.Errorf("expected default timestamp config ago/local/iso, got %s/%s/%s", ts.DefaultMode, ts.Timezone, ts.FormatPreset)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigInvalidJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "config.json"), []byte("{invalid"), 0644)
|
|
|
|
cfg, err := LoadConfig(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
// Should return defaults when JSON is invalid
|
|
if cfg.Port != 3000 {
|
|
t.Errorf("expected default port 3000, got %d", cfg.Port)
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigNoArgs(t *testing.T) {
|
|
cfg, err := LoadConfig()
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg == nil {
|
|
t.Fatal("expected non-nil config")
|
|
}
|
|
}
|
|
|
|
func TestLoadConfigTimestampNormalization(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfgData := map[string]interface{}{
|
|
"timestamps": map[string]interface{}{
|
|
"defaultMode": "banana",
|
|
"timezone": "mars",
|
|
"formatPreset": "weird",
|
|
},
|
|
}
|
|
data, _ := json.Marshal(cfgData)
|
|
os.WriteFile(filepath.Join(dir, "config.json"), data, 0644)
|
|
|
|
cfg, err := LoadConfig(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.Timestamps == nil {
|
|
t.Fatal("expected timestamps to be set")
|
|
}
|
|
if cfg.Timestamps.DefaultMode != "ago" {
|
|
t.Errorf("expected normalized defaultMode ago, got %s", cfg.Timestamps.DefaultMode)
|
|
}
|
|
if cfg.Timestamps.Timezone != "local" {
|
|
t.Errorf("expected normalized timezone local, got %s", cfg.Timestamps.Timezone)
|
|
}
|
|
if cfg.Timestamps.FormatPreset != "iso" {
|
|
t.Errorf("expected normalized formatPreset iso, got %s", cfg.Timestamps.FormatPreset)
|
|
}
|
|
}
|
|
|
|
func TestLoadThemeValidJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
themeData := map[string]interface{}{
|
|
"branding": map[string]interface{}{
|
|
"siteName": "CustomTheme",
|
|
},
|
|
"theme": map[string]interface{}{
|
|
"accent": "#ff0000",
|
|
},
|
|
"nodeColors": map[string]interface{}{
|
|
"repeater": "#00ff00",
|
|
},
|
|
}
|
|
data, _ := json.Marshal(themeData)
|
|
os.WriteFile(filepath.Join(dir, "theme.json"), data, 0644)
|
|
|
|
theme := LoadTheme(dir)
|
|
if theme.Branding == nil {
|
|
t.Fatal("expected branding")
|
|
}
|
|
if theme.Branding["siteName"] != "CustomTheme" {
|
|
t.Errorf("expected CustomTheme, got %v", theme.Branding["siteName"])
|
|
}
|
|
if theme.Theme["accent"] != "#ff0000" {
|
|
t.Errorf("expected #ff0000, got %v", theme.Theme["accent"])
|
|
}
|
|
}
|
|
|
|
func TestLoadThemeFromDataSubdir(t *testing.T) {
|
|
dir := t.TempDir()
|
|
dataDir := filepath.Join(dir, "data")
|
|
os.Mkdir(dataDir, 0755)
|
|
themeData := map[string]interface{}{
|
|
"branding": map[string]interface{}{"siteName": "DataTheme"},
|
|
}
|
|
data, _ := json.Marshal(themeData)
|
|
os.WriteFile(filepath.Join(dataDir, "theme.json"), data, 0644)
|
|
|
|
theme := LoadTheme(dir)
|
|
if theme.Branding == nil {
|
|
t.Fatal("expected branding")
|
|
}
|
|
if theme.Branding["siteName"] != "DataTheme" {
|
|
t.Errorf("expected DataTheme, got %v", theme.Branding["siteName"])
|
|
}
|
|
}
|
|
|
|
func TestLoadThemeNoFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
theme := LoadTheme(dir)
|
|
if theme == nil {
|
|
t.Fatal("expected non-nil theme")
|
|
}
|
|
}
|
|
|
|
func TestLoadThemeNoArgs(t *testing.T) {
|
|
theme := LoadTheme()
|
|
if theme == nil {
|
|
t.Fatal("expected non-nil theme")
|
|
}
|
|
}
|
|
|
|
func TestLoadThemeInvalidJSON(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "theme.json"), []byte("{bad json"), 0644)
|
|
theme := LoadTheme(dir)
|
|
// Should return empty theme
|
|
if theme == nil {
|
|
t.Fatal("expected non-nil theme")
|
|
}
|
|
}
|
|
|
|
func TestGetHealthThresholdsDefaults(t *testing.T) {
|
|
cfg := &Config{}
|
|
ht := cfg.GetHealthThresholds()
|
|
|
|
if ht.InfraDegradedHours != 24 {
|
|
t.Errorf("expected 24, got %v", ht.InfraDegradedHours)
|
|
}
|
|
if ht.InfraSilentHours != 72 {
|
|
t.Errorf("expected 72, got %v", ht.InfraSilentHours)
|
|
}
|
|
if ht.NodeDegradedHours != 1 {
|
|
t.Errorf("expected 1, got %v", ht.NodeDegradedHours)
|
|
}
|
|
if ht.NodeSilentHours != 24 {
|
|
t.Errorf("expected 24, got %v", ht.NodeSilentHours)
|
|
}
|
|
}
|
|
|
|
func TestGetHealthThresholdsCustom(t *testing.T) {
|
|
cfg := &Config{
|
|
HealthThresholds: &HealthThresholds{
|
|
InfraDegradedHours: 2,
|
|
InfraSilentHours: 4,
|
|
NodeDegradedHours: 0.5,
|
|
NodeSilentHours: 2,
|
|
},
|
|
}
|
|
ht := cfg.GetHealthThresholds()
|
|
|
|
if ht.InfraDegradedHours != 2 {
|
|
t.Errorf("expected 2, got %v", ht.InfraDegradedHours)
|
|
}
|
|
if ht.InfraSilentHours != 4 {
|
|
t.Errorf("expected 4, got %v", ht.InfraSilentHours)
|
|
}
|
|
if ht.NodeDegradedHours != 0.5 {
|
|
t.Errorf("expected 0.5, got %v", ht.NodeDegradedHours)
|
|
}
|
|
if ht.NodeSilentHours != 2 {
|
|
t.Errorf("expected 2, got %v", ht.NodeSilentHours)
|
|
}
|
|
}
|
|
|
|
func TestGetHealthThresholdsPartialCustom(t *testing.T) {
|
|
cfg := &Config{
|
|
HealthThresholds: &HealthThresholds{
|
|
InfraDegradedHours: 2,
|
|
// Others left as zero → should use defaults
|
|
},
|
|
}
|
|
ht := cfg.GetHealthThresholds()
|
|
|
|
if ht.InfraDegradedHours != 2 {
|
|
t.Errorf("expected 2, got %v", ht.InfraDegradedHours)
|
|
}
|
|
if ht.InfraSilentHours != 72 {
|
|
t.Errorf("expected default 72, got %v", ht.InfraSilentHours)
|
|
}
|
|
}
|
|
|
|
func TestGetHealthMs(t *testing.T) {
|
|
ht := HealthThresholds{
|
|
InfraDegradedHours: 24,
|
|
InfraSilentHours: 72,
|
|
NodeDegradedHours: 1,
|
|
NodeSilentHours: 24,
|
|
}
|
|
|
|
tests := []struct {
|
|
role string
|
|
wantDeg int
|
|
wantSilent int
|
|
}{
|
|
{"repeater", 86400000, 259200000},
|
|
{"room", 86400000, 259200000},
|
|
{"companion", 3600000, 86400000},
|
|
{"sensor", 3600000, 86400000},
|
|
{"unknown", 3600000, 86400000},
|
|
}
|
|
|
|
for _, tc := range tests {
|
|
t.Run(tc.role, func(t *testing.T) {
|
|
deg, sil := ht.GetHealthMs(tc.role)
|
|
if deg != tc.wantDeg {
|
|
t.Errorf("degraded: expected %d, got %d", tc.wantDeg, deg)
|
|
}
|
|
if sil != tc.wantSilent {
|
|
t.Errorf("silent: expected %d, got %d", tc.wantSilent, sil)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
func TestResolveDBPath(t *testing.T) {
|
|
t.Run("DBPath set", func(t *testing.T) {
|
|
cfg := &Config{DBPath: "/explicit/path.db"}
|
|
got := cfg.ResolveDBPath("/base")
|
|
if got != "/explicit/path.db" {
|
|
t.Errorf("expected /explicit/path.db, got %s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("env var", func(t *testing.T) {
|
|
cfg := &Config{}
|
|
t.Setenv("DB_PATH", "/env/path.db")
|
|
got := cfg.ResolveDBPath("/base")
|
|
if got != "/env/path.db" {
|
|
t.Errorf("expected /env/path.db, got %s", got)
|
|
}
|
|
})
|
|
|
|
t.Run("default", func(t *testing.T) {
|
|
cfg := &Config{}
|
|
t.Setenv("DB_PATH", "")
|
|
got := cfg.ResolveDBPath("/base")
|
|
expected := filepath.Join("/base", "data", "meshcore.db")
|
|
if got != expected {
|
|
t.Errorf("expected %s, got %s", expected, got)
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestPropagationBufferMs(t *testing.T) {
|
|
t.Run("default", func(t *testing.T) {
|
|
cfg := &Config{}
|
|
if cfg.PropagationBufferMs() != 5000 {
|
|
t.Errorf("expected 5000, got %d", cfg.PropagationBufferMs())
|
|
}
|
|
})
|
|
|
|
t.Run("custom", func(t *testing.T) {
|
|
cfg := &Config{}
|
|
cfg.LiveMap.PropagationBufferMs = 3000
|
|
if cfg.PropagationBufferMs() != 3000 {
|
|
t.Errorf("expected 3000, got %d", cfg.PropagationBufferMs())
|
|
}
|
|
})
|
|
}
|
|
|
|
func TestObserverDaysOrDefault(t *testing.T) {
|
|
tests := []struct {
|
|
name string
|
|
cfg *Config
|
|
want int
|
|
}{
|
|
{"nil retention", &Config{}, 14},
|
|
{"zero observer days", &Config{Retention: &RetentionConfig{ObserverDays: 0}}, 14},
|
|
{"positive value", &Config{Retention: &RetentionConfig{ObserverDays: 30}}, 30},
|
|
{"keep forever", &Config{Retention: &RetentionConfig{ObserverDays: -1}}, -1},
|
|
}
|
|
|
|
for _, tt := range tests {
|
|
t.Run(tt.name, func(t *testing.T) {
|
|
got := tt.cfg.ObserverDaysOrDefault()
|
|
if got != tt.want {
|
|
t.Errorf("ObserverDaysOrDefault() = %d, want %d", got, tt.want)
|
|
}
|
|
})
|
|
}
|
|
}
|
|
|
|
// Issue #1552 — observer health thresholds configurable.
|
|
|
|
func TestObserverThresholdsOverride(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfgData := map[string]interface{}{
|
|
"healthThresholds": map[string]interface{}{
|
|
"observerOnlineMinutes": 30,
|
|
"observerStaleMinutes": 120,
|
|
},
|
|
}
|
|
data, _ := json.Marshal(cfgData)
|
|
os.WriteFile(filepath.Join(dir, "config.json"), data, 0644)
|
|
cfg, err := LoadConfig(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := cfg.GetHealthThresholds()
|
|
if h.ObserverOnlineMinutes != 30 {
|
|
t.Errorf("ObserverOnlineMinutes = %d, want 30", h.ObserverOnlineMinutes)
|
|
}
|
|
if h.ObserverStaleMinutes != 120 {
|
|
t.Errorf("ObserverStaleMinutes = %d, want 120", h.ObserverStaleMinutes)
|
|
}
|
|
m := h.ToClientMs()
|
|
if m["observerOnlineMs"] != 30*60*1000 {
|
|
t.Errorf("observerOnlineMs = %d, want %d", m["observerOnlineMs"], 30*60*1000)
|
|
}
|
|
if m["observerStaleMs"] != 120*60*1000 {
|
|
t.Errorf("observerStaleMs = %d, want %d", m["observerStaleMs"], 120*60*1000)
|
|
}
|
|
}
|
|
|
|
func TestObserverThresholdsDefaults(t *testing.T) {
|
|
cfg := &Config{}
|
|
h := cfg.GetHealthThresholds()
|
|
if h.ObserverOnlineMinutes != 60 {
|
|
t.Errorf("default ObserverOnlineMinutes = %d, want 60", h.ObserverOnlineMinutes)
|
|
}
|
|
if h.ObserverStaleMinutes != 1440 {
|
|
t.Errorf("default ObserverStaleMinutes = %d, want 1440", h.ObserverStaleMinutes)
|
|
}
|
|
m := h.ToClientMs()
|
|
if m["observerOnlineMs"] != 3600000 {
|
|
t.Errorf("default observerOnlineMs = %d, want 3600000", m["observerOnlineMs"])
|
|
}
|
|
if m["observerStaleMs"] != 86400000 {
|
|
t.Errorf("default observerStaleMs = %d, want 86400000", m["observerStaleMs"])
|
|
}
|
|
}
|
|
|
|
// Loading a config with no healthThresholds block at all must still produce
|
|
// the new 60 / 1440 defaults (not zero, not the old 10 / 60).
|
|
func TestObserverThresholdsDefaultsFromEmptyConfigFile(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"port": 3000}`), 0644)
|
|
cfg, err := LoadConfig(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
h := cfg.GetHealthThresholds()
|
|
if h.ObserverOnlineMinutes != 60 {
|
|
t.Errorf("empty-config ObserverOnlineMinutes = %d, want 60 (new default)", h.ObserverOnlineMinutes)
|
|
}
|
|
if h.ObserverStaleMinutes != 1440 {
|
|
t.Errorf("empty-config ObserverStaleMinutes = %d, want 1440 (new default)", h.ObserverStaleMinutes)
|
|
}
|
|
}
|
|
|
|
func TestApplyListLimitsDefaults(t *testing.T) {
|
|
t.Run("defaults when block is absent", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
os.WriteFile(filepath.Join(dir, "config.json"), []byte(`{"port": 3000}`), 0644)
|
|
cfg, err := LoadConfig(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.ListLimits.PacketsMax != 10000 {
|
|
t.Errorf("expected 10000, got %d", cfg.ListLimits.PacketsMax)
|
|
}
|
|
if cfg.ListLimits.NodesMax != 2000 {
|
|
t.Errorf("expected 2000, got %d", cfg.ListLimits.NodesMax)
|
|
}
|
|
if cfg.ListLimits.AnalyticsMax != 200 {
|
|
t.Errorf("expected 200, got %d", cfg.ListLimits.AnalyticsMax)
|
|
}
|
|
if cfg.ListLimits.ChannelMessagesMax != 500 {
|
|
t.Errorf("expected 500, got %d", cfg.ListLimits.ChannelMessagesMax)
|
|
}
|
|
if cfg.ListLimits.BulkHealthMax != 200 {
|
|
t.Errorf("expected 200, got %d", cfg.ListLimits.BulkHealthMax)
|
|
}
|
|
})
|
|
|
|
t.Run("operator overrides honored", func(t *testing.T) {
|
|
dir := t.TempDir()
|
|
cfgData := map[string]interface{}{
|
|
"listLimits": map[string]interface{}{
|
|
"packetsMax": 50000,
|
|
"nodesMax": 5000,
|
|
"analyticsMax": 500,
|
|
"channelMessagesMax": 1000,
|
|
"bulkHealthMax": 300,
|
|
},
|
|
}
|
|
data, _ := json.Marshal(cfgData)
|
|
os.WriteFile(filepath.Join(dir, "config.json"), data, 0644)
|
|
cfg, err := LoadConfig(dir)
|
|
if err != nil {
|
|
t.Fatal(err)
|
|
}
|
|
if cfg.ListLimits.PacketsMax != 50000 {
|
|
t.Errorf("expected 50000, got %d", cfg.ListLimits.PacketsMax)
|
|
}
|
|
if cfg.ListLimits.NodesMax != 5000 {
|
|
t.Errorf("expected 5000, got %d", cfg.ListLimits.NodesMax)
|
|
}
|
|
if cfg.ListLimits.AnalyticsMax != 500 {
|
|
t.Errorf("expected 500, got %d", cfg.ListLimits.AnalyticsMax)
|
|
}
|
|
if cfg.ListLimits.ChannelMessagesMax != 1000 {
|
|
t.Errorf("expected 1000, got %d", cfg.ListLimits.ChannelMessagesMax)
|
|
}
|
|
if cfg.ListLimits.BulkHealthMax != 300 {
|
|
t.Errorf("expected 300, got %d", cfg.ListLimits.BulkHealthMax)
|
|
}
|
|
})
|
|
}
|