Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
57 changes: 57 additions & 0 deletions cmd/server/config.go
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@ package main

import (
"encoding/json"
"fmt"
"log"
"os"
"path/filepath"
Expand Down Expand Up @@ -404,3 +405,59 @@ func (c *Config) IsBlacklisted(pubkey string) bool {
}
return c.blacklistSet()[strings.ToLower(strings.TrimSpace(pubkey))]
}

// SaveGeoFilter writes the geo_filter section back to config.json on disk.
// Pass gf=nil to remove the filter. The rest of config.json is preserved as-is.
func SaveGeoFilter(configDir string, gf *GeoFilterConfig) error {
var configPath string
for _, p := range []string{
filepath.Join(configDir, "config.json"),
filepath.Join(configDir, "data", "config.json"),
} {
if _, err := os.Stat(p); err == nil {
configPath = p
break
}
}
if configPath == "" {
return fmt.Errorf("config.json not found in %s", configDir)
}

data, err := os.ReadFile(configPath)
if err != nil {
return fmt.Errorf("read config: %w", err)
}

// Parse as a raw map so non-struct fields (_comment, etc.) are preserved.
var raw map[string]interface{}
if err := json.Unmarshal(data, &raw); err != nil {
return fmt.Errorf("parse config: %w", err)
}

if gf == nil || len(gf.Polygon) == 0 {
delete(raw, "geo_filter")
} else {
// Round-trip through JSON to get a plain interface{} value.
b, _ := json.Marshal(gf)
var v interface{}
_ = json.Unmarshal(b, &v)
raw["geo_filter"] = v
}

out, err := json.MarshalIndent(raw, "", " ")
if err != nil {
return fmt.Errorf("marshal config: %w", err)
}
out = append(out, '\n')

// Atomic write: temp file + rename.
tmp := configPath + ".tmp"
if err := os.WriteFile(tmp, out, 0644); err != nil {
return fmt.Errorf("write config: %w", err)
}
if err := os.Rename(tmp, configPath); err != nil {
os.Remove(tmp)
return fmt.Errorf("rename config: %w", err)
}
return nil
}
1 change: 1 addition & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -238,6 +238,7 @@ func main() {

// HTTP server
srv := NewServer(database, cfg, hub)
srv.configDir = configDir
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
Expand Down
83 changes: 78 additions & 5 deletions cmd/server/routes.go
Original file line number Diff line number Diff line change
Expand Up @@ -6,6 +6,7 @@ import (
"encoding/json"
"fmt"
"log"
"math"
"net/http"
"regexp"
"runtime"
Expand All @@ -25,12 +26,16 @@ type Server struct {
cfg *Config
hub *Hub
store *PacketStore // in-memory packet store (nil = fallback to DB)
configDir string // directory containing config.json (for write-back)
startedAt time.Time
perfStats *PerfStats
version string
commit string
buildTime string

// Guards s.cfg.GeoFilter — read by ingest/handler goroutines, written by PUT handler
cfgMu sync.RWMutex

// Cached runtime.MemStats to avoid stop-the-world pauses on every health check
memStatsMu sync.Mutex
memStatsCache runtime.MemStats
Expand Down Expand Up @@ -59,6 +64,18 @@ type PerfStats struct {
StartedAt time.Time
}

func (s *Server) getGeoFilter() *GeoFilterConfig {
s.cfgMu.RLock()
defer s.cfgMu.RUnlock()
return s.cfg.GeoFilter
}

func (s *Server) setGeoFilter(gf *GeoFilterConfig) {
s.cfgMu.Lock()
defer s.cfgMu.Unlock()
s.cfg.GeoFilter = gf
}

type EndpointPerf struct {
Count int
TotalMs float64
Expand Down Expand Up @@ -117,6 +134,7 @@ func (s *Server) RegisterRoutes(r *mux.Router) {
r.HandleFunc("/api/config/theme", s.handleConfigTheme).Methods("GET")
r.HandleFunc("/api/config/map", s.handleConfigMap).Methods("GET")
r.HandleFunc("/api/config/geo-filter", s.handleConfigGeoFilter).Methods("GET")
r.Handle("/api/config/geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePutConfigGeoFilter))).Methods("PUT")

// System endpoints
r.HandleFunc("/api/health", s.handleHealth).Methods("GET")
Expand Down Expand Up @@ -430,12 +448,67 @@ func (s *Server) handleConfigMap(w http.ResponseWriter, r *http.Request) {
}

func (s *Server) handleConfigGeoFilter(w http.ResponseWriter, r *http.Request) {
gf := s.cfg.GeoFilter
gf := s.getGeoFilter()
// writeEnabled leaks whether the server has a strong API key to unauthenticated
// callers. Risk accepted: the information (key is/isn't configured) is low-sensitivity
// and the GET endpoint is intentionally public for read-only clients.
writeEnabled := s.cfg != nil && s.cfg.APIKey != "" && !IsWeakAPIKey(s.cfg.APIKey)
if gf == nil || len(gf.Polygon) == 0 {
writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0})
writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0, "writeEnabled": writeEnabled})
return
}
writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm, "writeEnabled": writeEnabled})
}

func (s *Server) handlePutConfigGeoFilter(w http.ResponseWriter, r *http.Request) {
r.Body = http.MaxBytesReader(w, r.Body, 1<<20) // 1 MB cap

var body struct {
Polygon [][2]float64 `json:"polygon"`
BufferKm float64 `json:"bufferKm"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON")
return
}

// Allow clearing (empty/null polygon) or a valid polygon with ≥ 3 points.
if len(body.Polygon) > 0 && len(body.Polygon) < 3 {
writeError(w, http.StatusBadRequest, "polygon must have at least 3 points")
return
}
writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm})
if len(body.Polygon) > 1000 {
writeError(w, http.StatusBadRequest, "polygon must have at most 1000 points")
return
}
for _, pt := range body.Polygon {
if math.IsNaN(pt[0]) || math.IsNaN(pt[1]) || math.IsInf(pt[0], 0) || math.IsInf(pt[1], 0) ||
pt[0] < -90 || pt[0] > 90 || pt[1] < -180 || pt[1] > 180 {
writeError(w, http.StatusBadRequest, "polygon point out of range: lat must be in [-90,90], lon in [-180,180]")
return
}
}

var gf *GeoFilterConfig
if len(body.Polygon) >= 3 {
gf = &GeoFilterConfig{Polygon: body.Polygon, BufferKm: body.BufferKm}
}

if s.configDir != "" {
if err := SaveGeoFilter(s.configDir, gf); err != nil {
log.Printf("[geofilter] save failed: %v", err)
writeError(w, http.StatusInternalServerError, "failed to save config")
return
}
}

s.setGeoFilter(gf)

if gf != nil {
writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm})
} else {
writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0})
}
}

// --- System Handlers ---
Expand Down Expand Up @@ -1094,10 +1167,10 @@ func (s *Server) handleNodes(w http.ResponseWriter, r *http.Request) {
}
}
}
if s.cfg.GeoFilter != nil {
if gf := s.getGeoFilter(); gf != nil {
filtered := nodes[:0]
for _, node := range nodes {
if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) {
if NodePassesGeoFilter(node["lat"], node["lon"], gf) {
filtered = append(filtered, node)
}
}
Expand Down
Loading