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
}
58 changes: 58 additions & 0 deletions cmd/server/db.go
Original file line number Diff line number Diff line change
Expand Up @@ -2344,3 +2344,61 @@ func (db *DB) GetSignatureDropCount() int64 {
}
return count
}

// NodeForGeoPrune holds the minimal fields needed for geo-filter pruning.
type NodeForGeoPrune struct {
PubKey string
Name string
Lat *float64
Lon *float64
}

// GetNodesForGeoPrune returns all nodes with their coordinates for geo-filter evaluation.
func (db *DB) GetNodesForGeoPrune() ([]NodeForGeoPrune, error) {
rows, err := db.conn.Query("SELECT public_key, name, lat, lon FROM nodes ORDER BY name")
if err != nil {
return nil, err
}
defer rows.Close()

var nodes []NodeForGeoPrune
for rows.Next() {
var pk string
var name sql.NullString
var lat, lon sql.NullFloat64
if err := rows.Scan(&pk, &name, &lat, &lon); err != nil {
continue
}
n := NodeForGeoPrune{PubKey: pk, Name: name.String}
if lat.Valid {
v := lat.Float64
n.Lat = &v
}
if lon.Valid {
v := lon.Float64
n.Lon = &v
}
nodes = append(nodes, n)
}
return nodes, rows.Err()
}

// DeleteNodesByPubkeys deletes nodes by their public keys and returns the count deleted.
// Only the nodes table is affected — references in transmissions or other tables are
// not cascaded (no FK constraints exist today; revisit if schema adds them).
func (db *DB) DeleteNodesByPubkeys(pubkeys []string) (int64, error) {
if len(pubkeys) == 0 {
return 0, nil
}
placeholders := strings.Repeat("?,", len(pubkeys))
placeholders = placeholders[:len(placeholders)-1]
args := make([]interface{}, len(pubkeys))
for i, pk := range pubkeys {
args[i] = pk
}
result, err := db.conn.Exec("DELETE FROM nodes WHERE public_key IN ("+placeholders+")", args...)
if err != nil {
return 0, err
}
return result.RowsAffected()
}
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
169 changes: 164 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,13 +134,15 @@ 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")
r.HandleFunc("/api/stats", s.handleStats).Methods("GET")
r.HandleFunc("/api/perf", s.handlePerf).Methods("GET")
r.Handle("/api/perf/reset", s.requireAPIKey(http.HandlerFunc(s.handlePerfReset))).Methods("POST")
r.Handle("/api/admin/prune", s.requireAPIKey(http.HandlerFunc(s.handleAdminPrune))).Methods("POST")
r.Handle("/api/admin/prune-geo-filter", s.requireAPIKey(http.HandlerFunc(s.handlePruneGeoFilter))).Methods("POST")
r.Handle("/api/debug/affinity", s.requireAPIKey(http.HandlerFunc(s.handleDebugAffinity))).Methods("GET")
r.Handle("/api/dropped-packets", s.requireAPIKey(http.HandlerFunc(s.handleDroppedPackets))).Methods("GET")

Expand Down Expand Up @@ -430,12 +449,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 +1168,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 Expand Up @@ -2577,6 +2651,91 @@ func (s *Server) handleAdminPrune(w http.ResponseWriter, r *http.Request) {
writeJSON(w, results)
}

// handlePruneGeoFilter identifies (dry_run=true, default) or deletes (confirm=true)
// nodes whose GPS coordinates fall outside the currently configured geo_filter.
// Nodes with no GPS fix are always kept. Requires geo_filter to be configured.
// Confirm requires the pubkeys from the preview in the request body to prevent
// TOCTOU races: only nodes in the passed list AND still outside the filter are deleted.
func (s *Server) handlePruneGeoFilter(w http.ResponseWriter, r *http.Request) {
if s.cfg.GeoFilter == nil || len(s.cfg.GeoFilter.Polygon) < 3 {
writeError(w, http.StatusBadRequest, "no geo_filter configured")
return
}

nodes, err := s.db.GetNodesForGeoPrune()
if err != nil {
writeError(w, http.StatusInternalServerError, "db error")
return
}

type nodeResult struct {
PubKey string `json:"pubkey"`
Name string `json:"name"`
Lat *float64 `json:"lat"`
Lon *float64 `json:"lon"`
}

var outside []nodeResult
for _, n := range nodes {
if n.Lat == nil || n.Lon == nil {
continue // no GPS — always keep
}
if !NodePassesGeoFilter(*n.Lat, *n.Lon, s.cfg.GeoFilter) {
outside = append(outside, nodeResult{PubKey: n.PubKey, Name: n.Name, Lat: n.Lat, Lon: n.Lon})
}
}

if r.URL.Query().Get("confirm") != "true" {
// Dry run — return preview without deleting
writeJSON(w, map[string]interface{}{
"dryRun": true,
"count": len(outside),
"nodes": outside,
})
return
}

// Confirmed delete — require pubkeys from the preview to prevent TOCTOU:
// only nodes that were shown in preview AND are still outside the filter are deleted.
var body struct {
Pubkeys []string `json:"pubkeys"`
}
if err := json.NewDecoder(r.Body).Decode(&body); err != nil || len(body.Pubkeys) == 0 {
writeError(w, http.StatusBadRequest, "confirm requires pubkeys from preview in request body")
return
}
allowed := make(map[string]bool, len(body.Pubkeys))
for _, pk := range body.Pubkeys {
allowed[pk] = true
}

var toDelete []nodeResult
for _, n := range outside {
if allowed[n.PubKey] {
toDelete = append(toDelete, n)
}
}

pubkeys := make([]string, len(toDelete))
for i, n := range toDelete {
pubkeys[i] = n.PubKey
}
deleted, err := s.db.DeleteNodesByPubkeys(pubkeys)
if err != nil {
writeError(w, http.StatusInternalServerError, "delete failed")
return
}
for _, n := range toDelete {
log.Printf("[geo-prune] deleted node %q (%s)", n.Name, n.PubKey)
}
log.Printf("[geo-prune] deleted %d nodes outside geo filter", deleted)
writeJSON(w, map[string]interface{}{
"dryRun": false,
"deleted": deleted,
"nodes": toDelete,
})
}

// constantTimeEqual compares two strings in constant time to prevent timing attacks.
func constantTimeEqual(a, b string) bool {
return subtle.ConstantTimeCompare([]byte(a), []byte(b)) == 1
Expand Down
Loading