Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
20 commits
Select commit Hold shift + click to select a range
1881c92
feat: geofilter customizer tab + PUT /api/config/geo-filter (#669 M3)
efiten Apr 14, 2026
e92a233
feat: geofilter map modal + light tile theme (#669)
efiten Apr 14, 2026
5719b9e
fix(geo-filter): address PR #736 security review feedback
efiten Apr 19, 2026
4a016e4
merge: resolve upstream/master conflicts for PR #736
efiten May 17, 2026
67e52eb
fix(#1250): trim mobile VCR bar h-padding 8px→4px to clear 0.83px LCD…
Kpa-clawbot May 17, 2026
a58c21a
fix(#1249): IATA badge missing on fixture + mobile clipping (#1252)
Kpa-clawbot May 17, 2026
047df38
fix(#1254): trim .badge-iata h-padding on mobile to clear 1.25px clip…
Kpa-clawbot May 17, 2026
7caafb9
merge: resolve upstream/master conflicts for PR #736
efiten May 18, 2026
e8e223c
Merge branch 'master' into feat/geofilter-m3-customizer
efiten May 18, 2026
b3d52e0
Merge branch 'master' into feat/geofilter-m3-customizer
efiten May 18, 2026
022a363
merge: resolve upstream/master conflicts for PR #736 (round 2)
efiten May 19, 2026
bb19a1b
fix(geo-filter): SaveGeoFilter preserves config.json file mode (#736)
efiten May 20, 2026
4c3881c
fix(geo-filter): validate bufferKm range (finite, non-negative, ≤2000…
efiten May 20, 2026
8fd5c78
fix(geo-filter): serialize concurrent PUT disk writes via saveMu (#736)
efiten May 20, 2026
c2d1f8d
Merge branch 'feat/geofilter-m3-customizer' of https://github.com/efi…
efiten May 20, 2026
bb05b0f
chore: remove local-only docs/superpowers from branch index (#736)
efiten May 20, 2026
b380be3
fix(docker): add COPY internal/dbschema/ for server and ingestor buil…
efiten May 20, 2026
d719513
Merge branch 'master' into feat/geofilter-m3-customizer
efiten May 20, 2026
e01b5f5
test(routes): assert writeEnabled is NOT exposed in public geo-filter…
May 21, 2026
dd2f812
fix(routes): stop leaking apiKey presence via writeEnabled on public GET
May 21, 2026
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
2 changes: 2 additions & 0 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ COPY internal/sigvalidate/ ../../internal/sigvalidate/
COPY internal/packetpath/ ../../internal/packetpath/
COPY internal/dbconfig/ ../../internal/dbconfig/
COPY internal/perfio/ ../../internal/perfio/
COPY internal/dbschema/ ../../internal/dbschema/
RUN go mod download
COPY cmd/server/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
Expand All @@ -33,6 +34,7 @@ COPY internal/sigvalidate/ ../../internal/sigvalidate/
COPY internal/packetpath/ ../../internal/packetpath/
COPY internal/dbconfig/ ../../internal/dbconfig/
COPY internal/perfio/ ../../internal/perfio/
COPY internal/dbschema/ ../../internal/dbschema/
RUN go mod download
COPY cmd/ingestor/ ./
RUN CGO_ENABLED=0 GOOS=${TARGETOS} GOARCH=${TARGETARCH} \
Expand Down
67 changes: 65 additions & 2 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 @@ -447,6 +448,68 @@ 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')

// Preserve the original file mode so operators' chmod 0600 survives the write.
origMode := os.FileMode(0644)
if fi, err := os.Stat(configPath); err == nil {
origMode = fi.Mode().Perm()
}

// Atomic write: temp file + rename.
tmp := configPath + ".tmp"
if err := os.WriteFile(tmp, out, origMode); 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
}

// obsBlacklistSet lazily builds and caches the observerBlacklist as a set for O(1) lookups.
func (c *Config) obsBlacklistSet() map[string]bool {
c.obsBlacklistOnce.Do(func() {
Expand Down Expand Up @@ -485,8 +548,8 @@ func (c *Config) IsObserverBlacklisted(id string) bool {
// RecomputeIntervalSeconds keys (all optional):
// topology, rf, distance, channels, hashCollisions, hashSizes, roles, observersClockSkew, nodesClockSkew
type AnalyticsConfig struct {
DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"`
RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"`
DefaultIntervalSeconds int `json:"defaultIntervalSeconds,omitempty"`
RecomputeIntervalSeconds map[string]int `json:"recomputeIntervalSeconds,omitempty"`
}

// AnalyticsDefaultRecomputeInterval returns the configured default
Expand Down
23 changes: 23 additions & 0 deletions cmd/server/config_test.go
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import (
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
)

Expand Down Expand Up @@ -387,3 +388,25 @@ func TestObserverDaysOrDefault(t *testing.T) {
})
}
}

func TestSaveGeoFilter_PreservesFileMode(t *testing.T) {
if runtime.GOOS == "windows" {
t.Skip("Unix file permissions not supported on Windows")
}
dir := t.TempDir()
path := filepath.Join(dir, "config.json")
if err := os.WriteFile(path, []byte(`{"port":3000}`), 0600); err != nil {
t.Fatal(err)
}
gf := &GeoFilterConfig{Polygon: [][2]float64{{1, 2}, {3, 4}, {5, 6}}, BufferKm: 0}
if err := SaveGeoFilter(dir, gf); err != nil {
t.Fatal(err)
}
info, err := os.Stat(path)
if err != nil {
t.Fatal(err)
}
if got := info.Mode().Perm(); got != 0600 {
t.Errorf("file mode downgraded: want 0600, got %04o", got)
}
}
1 change: 1 addition & 0 deletions cmd/server/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -273,6 +273,7 @@ func main() {

// HTTP server
srv := NewServer(database, cfg, hub)
srv.configDir = configDir
srv.store = store
router := mux.NewRouter()
srv.RegisterRoutes(router)
Expand Down
99 changes: 91 additions & 8 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,20 @@ 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

// Serializes concurrent PUT /api/config/geo-filter disk writes so requests
// can't race on the .tmp file or interleave disk/memory updates.
saveMu sync.Mutex

// 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 +68,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 @@ -120,6 +141,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")

// Readiness endpoint (gated on background init completion)
r.HandleFunc("/api/healthz", s.handleHealthz).Methods("GET")
Expand Down Expand Up @@ -444,14 +466,78 @@ 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()
// NOTE: do NOT include any field that derives from APIKey presence/strength here.
// This endpoint is intentionally public; leaking whether a write-capable key is
// configured is an info-disclosure. Clients that want to write should just try
// PUT and handle 401/403. See PR #736 review.
if gf == nil || len(gf.Polygon) == 0 {
writeJSON(w, map[string]interface{}{"polygon": nil, "bufferKm": 0})
return
}
writeJSON(w, map[string]interface{}{"polygon": gf.Polygon, "bufferKm": gf.BufferKm})
}

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
}
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
}
}

// bufferKm must be finite, non-negative, and ≤ 20000 km (half Earth circumference).
if math.IsNaN(body.BufferKm) || math.IsInf(body.BufferKm, 0) ||
body.BufferKm < 0 || body.BufferKm > 20000 {
writeError(w, http.StatusBadRequest, "bufferKm must be a finite number in [0, 20000]")
return
}

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

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

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 ---

func (s *Server) handleHealth(w http.ResponseWriter, r *http.Request) {
Expand Down Expand Up @@ -1145,20 +1231,17 @@ 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 {
// Foreign-flagged nodes (#730) are kept even when their GPS lies
// outside the geofilter polygon — that's the whole point of the
// flag: operators need to SEE bridged/leaked nodes, not have them
// filtered away. The ingestor sets foreign_advert=1 when its
// configured geo_filter rejected the advert; the server must
// surface those.
// outside the geofilter polygon — operators need to SEE bridged/
// leaked nodes, not have them filtered away.
if isForeign, _ := node["foreign"].(bool); isForeign {
filtered = append(filtered, node)
continue
}
if NodePassesGeoFilter(node["lat"], node["lon"], s.cfg.GeoFilter) {
if NodePassesGeoFilter(node["lat"], node["lon"], gf) {
filtered = append(filtered, node)
}
}
Expand Down
Loading