290 lines
8.3 KiB
Go
290 lines
8.3 KiB
Go
// svxdr — live-data daemon for Reflector Dashboard (nodelist + proxy + astmeta + xlink)
|
|
// Copyright (C) 2026 SA7AUX (Björn)
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it under
|
|
// the terms of the GNU Affero General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option) any
|
|
// later version. See the LICENSE file for details.
|
|
|
|
package nodelist
|
|
|
|
// All-Nodes flat layer: one total, deduped node list across the whole crawled
|
|
// network (federation via externalState.sources). Each node is attributed to
|
|
// the REAL reflector it is connected to (source tag; this box's own host is
|
|
// shown as the reflector's real name, never an IP). Mesh dupes collapse to one
|
|
// owning ref. Instead of re-shipping the whole list, this holds state
|
|
// server-side and streams only per-node deltas (added/changed/removed) over SSE.
|
|
|
|
import (
|
|
"encoding/json"
|
|
"fmt"
|
|
"net"
|
|
"net/http"
|
|
"sort"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"github.com/redis/go-redis/v9"
|
|
)
|
|
|
|
// Federation aggregate (reflector-network.com/api/nodes): callsign -> node with
|
|
// the REAL reflector name. Populated by the gap loop; used here to (a) cover the
|
|
// whole network, not just what our own crawl reaches, and (b) give real ref
|
|
// names instead of a bare IP. UPPER(callsign) keyed.
|
|
var aggNodes = struct {
|
|
mu sync.RWMutex
|
|
m map[string]flatNode
|
|
}{m: map[string]flatNode{}}
|
|
|
|
func setAggNodes(m map[string]flatNode) {
|
|
aggNodes.mu.Lock()
|
|
aggNodes.m = m
|
|
aggNodes.mu.Unlock()
|
|
}
|
|
|
|
type flatNode struct {
|
|
Callsign string `json:"callsign"`
|
|
Ref string `json:"ref"`
|
|
TG string `json:"tg"`
|
|
}
|
|
|
|
var flatState = struct {
|
|
mu sync.RWMutex
|
|
nodes map[string]flatNode // keyed by UPPER(callsign)
|
|
ver uint64
|
|
}{nodes: map[string]flatNode{}}
|
|
|
|
// --- SSE subscriber hub ----------------------------------------------------
|
|
type flatSub struct{ ch chan []byte }
|
|
|
|
var flatHub = struct {
|
|
mu sync.Mutex
|
|
subs map[*flatSub]struct{}
|
|
}{subs: map[*flatSub]struct{}{}}
|
|
|
|
func flatSubscribe() *flatSub {
|
|
s := &flatSub{ch: make(chan []byte, 8)}
|
|
flatHub.mu.Lock()
|
|
flatHub.subs[s] = struct{}{}
|
|
flatHub.mu.Unlock()
|
|
return s
|
|
}
|
|
func flatUnsubscribe(s *flatSub) {
|
|
flatHub.mu.Lock()
|
|
delete(flatHub.subs, s)
|
|
flatHub.mu.Unlock()
|
|
}
|
|
func flatBroadcast(msg []byte) {
|
|
flatHub.mu.Lock()
|
|
for s := range flatHub.subs {
|
|
select {
|
|
case s.ch <- msg:
|
|
default: // slow client — drop; it will re-sync on next full snapshot/reconnect
|
|
}
|
|
}
|
|
flatHub.mu.Unlock()
|
|
}
|
|
|
|
// tgOf extracts the "tg" (with "*" prefix for restrictedTG) from a node's JSON.
|
|
func tgOf(raw json.RawMessage) string {
|
|
var n struct {
|
|
TG json.Number `json:"tg"`
|
|
RestrictedTG bool `json:"restrictedTG"`
|
|
}
|
|
if json.Unmarshal(raw, &n) != nil {
|
|
return ""
|
|
}
|
|
s := n.TG.String()
|
|
if s == "" {
|
|
return ""
|
|
}
|
|
if n.RestrictedTG {
|
|
return "*" + s
|
|
}
|
|
return s
|
|
}
|
|
|
|
var localRefName string // real name of THIS reflector (from svxreflector.conf), never an IP
|
|
|
|
// buildFlat assembles the deduped map straight from the federation crawl. Each
|
|
// node is attributed to the REAL reflector it is connected to (source.Tag).
|
|
// First ref by tag wins (sources are tag-sorted → deterministic). A source
|
|
// whose tag is this box's own host is renamed to the reflector's real name so
|
|
// the ref column is never "127.0.0.1"/an IP. No local-status overlay — that mix
|
|
// carried trunk-side (remote) nodes and mis-tagged them as local.
|
|
func buildFlat() map[string]flatNode {
|
|
out := make(map[string]flatNode, 256)
|
|
selfHost := hostFromURL(reflectorURL)
|
|
externalState.mu.RLock()
|
|
for _, s := range externalState.sources {
|
|
ref := s.Tag
|
|
// Never expose a raw http(s)://host:port/status URL as the ref — those
|
|
// are FEDERATION_SEEDS whose tag is the seed URL. Show only the host.
|
|
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
|
|
ref = hostFromURL(ref)
|
|
}
|
|
if (hostFromURL(s.URL) == selfHost || hostFromURL(s.Tag) == selfHost) && localRefName != "" {
|
|
ref = localRefName
|
|
}
|
|
// Bare IP → prefer a hostname alias (a name resolving to the same IP).
|
|
if net.ParseIP(ref) != nil {
|
|
for _, a := range s.Aliases {
|
|
if a != "" && net.ParseIP(a) == nil {
|
|
ref = a
|
|
break
|
|
}
|
|
}
|
|
}
|
|
for cs, raw := range s.Nodes {
|
|
key := strings.ToUpper(cs)
|
|
if _, seen := out[key]; seen {
|
|
continue
|
|
}
|
|
out[key] = flatNode{Callsign: cs, Ref: ref, TG: tgOf(raw)}
|
|
}
|
|
}
|
|
externalState.mu.RUnlock()
|
|
|
|
// Cover the WHOLE network (not just what our own crawl reaches): fold in the
|
|
// federation aggregate. Crawl wins for nodes we see live (fresh TG); the
|
|
// aggregate adds everyone else AND supplies a real reflector name where the
|
|
// crawl only had a bare IP.
|
|
aggNodes.mu.RLock()
|
|
for k, an := range aggNodes.m {
|
|
if ex, ok := out[k]; ok {
|
|
if an.Ref != "" && net.ParseIP(ex.Ref) != nil {
|
|
ex.Ref = an.Ref
|
|
out[k] = ex
|
|
}
|
|
continue
|
|
}
|
|
out[k] = flatNode{Callsign: an.Callsign, Ref: an.Ref}
|
|
}
|
|
aggNodes.mu.RUnlock()
|
|
return out
|
|
}
|
|
|
|
// rebuildFlat rebuilds, diffs against the current state and — only when the set
|
|
// actually changed — bumps the version and streams the delta to SSE clients.
|
|
func rebuildFlat() {
|
|
next := buildFlat()
|
|
|
|
flatState.mu.Lock()
|
|
prev := flatState.nodes
|
|
var added, changed []flatNode
|
|
var removed []string
|
|
for k, nv := range next {
|
|
if pv, ok := prev[k]; !ok {
|
|
added = append(added, nv)
|
|
} else if pv.Ref != nv.Ref || pv.TG != nv.TG {
|
|
changed = append(changed, nv)
|
|
}
|
|
}
|
|
for k, pv := range prev {
|
|
if _, ok := next[k]; !ok {
|
|
removed = append(removed, pv.Callsign)
|
|
}
|
|
}
|
|
if len(added) == 0 && len(changed) == 0 && len(removed) == 0 {
|
|
flatState.mu.Unlock()
|
|
return
|
|
}
|
|
flatState.nodes = next
|
|
flatState.ver++
|
|
ver := flatState.ver
|
|
flatState.mu.Unlock()
|
|
|
|
payload, _ := json.Marshal(struct {
|
|
Added []flatNode `json:"added"`
|
|
Changed []flatNode `json:"changed"`
|
|
Removed []string `json:"removed"`
|
|
Ver uint64 `json:"v"`
|
|
}{added, changed, removed, ver})
|
|
flatBroadcast([]byte("event: nodes-delta\ndata: " + string(payload) + "\n\n"))
|
|
}
|
|
|
|
// flatSnapshotJSON returns the full current list (for /api/nodes and the SSE
|
|
// initial frame), sorted by callsign.
|
|
func flatSnapshotJSON() ([]byte, uint64) {
|
|
flatState.mu.RLock()
|
|
rows := make([]flatNode, 0, len(flatState.nodes))
|
|
for _, v := range flatState.nodes {
|
|
rows = append(rows, v)
|
|
}
|
|
ver := flatState.ver
|
|
flatState.mu.RUnlock()
|
|
sort.Slice(rows, func(i, j int) bool { return rows[i].Callsign < rows[j].Callsign })
|
|
b, _ := json.Marshal(struct {
|
|
Nodes []flatNode `json:"nodes"`
|
|
Ver uint64 `json:"v"`
|
|
}{rows, ver})
|
|
return b, ver
|
|
}
|
|
|
|
// nodeFlatStart wires the endpoints and the update driver. Called from main()
|
|
// once rdb exists. The driver rebuilds on every pubsubChan tick (instant local
|
|
// changes) and on a slow safety ticker (catches federation crawl updates).
|
|
func nodeFlatStart(mux *http.ServeMux, rdb *redis.Client) {
|
|
localRefName, _ = localReflectorMeta() // real reflector name for the "local" tag
|
|
rebuildFlat() // prime
|
|
|
|
mux.HandleFunc("/api/nodes", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
b, _ := flatSnapshotJSON()
|
|
writeMaybeGzip(w, r, b)
|
|
})
|
|
|
|
mux.HandleFunc("/api/nodes-stream", func(w http.ResponseWriter, r *http.Request) {
|
|
flusher, ok := w.(http.Flusher)
|
|
if !ok {
|
|
http.Error(w, "SSE not supported", 500)
|
|
return
|
|
}
|
|
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
|
|
w.Header().Set("Cache-Control", "no-cache")
|
|
w.Header().Set("Connection", "keep-alive")
|
|
|
|
// initial full snapshot
|
|
snap, _ := flatSnapshotJSON()
|
|
fmt.Fprintf(w, "event: nodes-full\ndata: %s\n\n", snap)
|
|
flusher.Flush()
|
|
|
|
sub := flatSubscribe()
|
|
defer flatUnsubscribe(sub)
|
|
ping := time.NewTicker(25 * time.Second)
|
|
defer ping.Stop()
|
|
|
|
for {
|
|
select {
|
|
case <-r.Context().Done():
|
|
return
|
|
case <-ping.C:
|
|
fmt.Fprint(w, "event: ping\ndata: 1\n\n")
|
|
flusher.Flush()
|
|
case msg := <-sub.ch:
|
|
w.Write(msg)
|
|
flusher.Flush()
|
|
}
|
|
}
|
|
})
|
|
|
|
// Driver: instant on pubsub, plus a safety ticker for crawl-side changes.
|
|
go func() {
|
|
sub := rdb.Subscribe(ctx, pubsubChan)
|
|
defer sub.Close()
|
|
ch := sub.Channel()
|
|
tick := time.NewTicker(3 * time.Second)
|
|
defer tick.Stop()
|
|
for {
|
|
select {
|
|
case <-ch:
|
|
rebuildFlat()
|
|
case <-tick.C:
|
|
rebuildFlat()
|
|
}
|
|
}
|
|
}()
|
|
}
|