446 lines
15 KiB
Go
446 lines
15 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
|
|
|
|
// Monitor Nodes, the simple way: read ONE reflector /status endpoint that
|
|
// already carries the whole picture (local nodes + every trunk with its nodes)
|
|
// and reshape it into {local, trunks:[{name,host,nodes}]}. No recursive crawl.
|
|
//
|
|
// Which URL is read is per-box config MONITOR_STATUS_URL (falls back to
|
|
// REFLECTOR_STATUS_URL):
|
|
// hub box → https://<master-reflector>/geu/status/ (the master's full view)
|
|
// bridge box → http://127.0.0.1:8080/status (its own reflector)
|
|
|
|
import (
|
|
"encoding/json"
|
|
"io"
|
|
"net/http"
|
|
"regexp"
|
|
"sort"
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"svxportal/svxdr/statusnorm"
|
|
)
|
|
|
|
var monitorStatusURL = env("MONITOR_STATUS_URL", reflectorURL)
|
|
|
|
// monitorInterval is how often REMOTE status reads (a Monitor->Nodes URL or a
|
|
// trunk_status topology URL that points at another server) run. Gentle by default
|
|
// so a shared hub is never hammered; override via svxdr.json nodelist.monitor_interval.
|
|
// A LOCAL read (127.0.0.1) is free and refreshes fast — see localReadInterval.
|
|
var monitorInterval = 60 * time.Second
|
|
|
|
// localReadInterval: reading our OWN reflector costs nothing, so do it often.
|
|
var localReadInterval = 5 * time.Second
|
|
|
|
// isLocalURL reports whether a status URL points at this host (loopback). Local
|
|
// reads may be frequent; remote reads must be throttled to monitorInterval.
|
|
func isLocalURL(u string) bool {
|
|
return strings.Contains(u, "127.0.0.1") || strings.Contains(u, "localhost") || strings.Contains(u, "[::1]")
|
|
}
|
|
|
|
// readInterval picks the fast (local) or gentle (remote) cadence for a URL.
|
|
func readInterval(u string) time.Duration {
|
|
if isLocalURL(u) {
|
|
return localReadInterval
|
|
}
|
|
return monitorInterval
|
|
}
|
|
|
|
type monNode struct {
|
|
Callsign string `json:"callsign"`
|
|
TG string `json:"tg,omitempty"`
|
|
NodeClass string `json:"nodeClass,omitempty"`
|
|
Mode string `json:"mode,omitempty"`
|
|
Talker bool `json:"talker,omitempty"`
|
|
}
|
|
|
|
// uniquifyNames makes every node name in the view unique WITHOUT ever touching the
|
|
// Local (top-level) nodes — those are the reflector's own map keys, exactly what the
|
|
// Reflector clients view shows, so renaming them would make the two views disagree.
|
|
// Only trunk/satellite array nodes (which have no key and never appear in Reflector
|
|
// clients) are numbered, and only enough to stay clear of every name already taken
|
|
// (Local keys + earlier array nodes): SA7AUX -> SA7AUX-1, SA7AUX-2, …
|
|
func uniquifyNames(out *monStatus) {
|
|
taken := map[string]bool{}
|
|
for _, n := range out.Local { // canonical, never renamed
|
|
taken[n.Callsign] = true
|
|
}
|
|
next := map[string]int{}
|
|
for i := range out.Trunks {
|
|
for j := range out.Trunks[i].Nodes {
|
|
base := out.Trunks[i].Nodes[j].Callsign
|
|
if !taken[base] {
|
|
taken[base] = true
|
|
continue
|
|
}
|
|
for {
|
|
next[base]++
|
|
cand := base + "-" + strconv.Itoa(next[base])
|
|
if !taken[cand] {
|
|
taken[cand] = true
|
|
out.Trunks[i].Nodes[j].Callsign = cand
|
|
break
|
|
}
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
type monTrunk struct {
|
|
Name string `json:"name"`
|
|
Host string `json:"host,omitempty"`
|
|
Connected bool `json:"connected"`
|
|
Nodes []monNode `json:"nodes"`
|
|
}
|
|
|
|
type monStatus struct {
|
|
Local []monNode `json:"local"`
|
|
Trunks []monTrunk `json:"trunks"`
|
|
Source string `json:"source"`
|
|
UpdatedAt string `json:"updated_at"`
|
|
}
|
|
|
|
var monState = struct {
|
|
mu sync.RWMutex
|
|
data monStatus
|
|
}{}
|
|
|
|
// rawNode is one node as it appears either as a value in the top-level "nodes"
|
|
// object (keyed by callsign) or as an item in a trunk's "nodes" array.
|
|
type rawNode struct {
|
|
Callsign string `json:"callsign"`
|
|
SW string `json:"sw"`
|
|
TG json.Number `json:"tg"`
|
|
NodeClass string `json:"nodeClass"`
|
|
RestrictedTG bool `json:"restrictedTG"`
|
|
IsTalker bool `json:"isTalker"`
|
|
System string `json:"system"` // per-node link/type from the data (e.g. "DMR", "DMR/YSF", "DMR/Hytera")
|
|
Reflector string `json:"Reflector"` // which reflector this node is on (master status carries it)
|
|
MonitoredTGs []json.Number `json:"monitoredTGs"`
|
|
Links []struct {
|
|
LocalTg json.Number `json:"localTg"`
|
|
} `json:"links"`
|
|
}
|
|
|
|
// masterReflectorName picks a label for the monitored master's own nodes purely
|
|
// from the DATA (the Reflector field the nodes report) — no dependency on the
|
|
// local conf format, so every box derives it the same way.
|
|
func masterReflectorName(nodes map[string]json.RawMessage) string {
|
|
count := map[string]int{}
|
|
for _, raw := range nodes {
|
|
var r struct {
|
|
Reflector string `json:"Reflector"`
|
|
}
|
|
if json.Unmarshal(raw, &r) == nil && strings.TrimSpace(r.Reflector) != "" {
|
|
count[r.Reflector]++
|
|
}
|
|
}
|
|
best, n := "", 0
|
|
for k, c := range count {
|
|
if c > n {
|
|
best, n = k, c
|
|
}
|
|
}
|
|
return best
|
|
}
|
|
|
|
// monModeOf: real type from the software string (svx nodes carry no "mode").
|
|
func monModeOf(sw string) string {
|
|
s := strings.ToLower(sw)
|
|
switch {
|
|
case strings.Contains(s, "chan_pbx"):
|
|
return "PBX"
|
|
case strings.Contains(s, "chan_svx_sat"):
|
|
return "AllStar"
|
|
case strings.Contains(s, "chan_svx"):
|
|
return "AllStar"
|
|
case strings.Contains(s, "svxlink"):
|
|
return "SVX"
|
|
case strings.Contains(s, "mmdvm"), strings.Contains(s, "brandmeister"), strings.Contains(s, "dmr"):
|
|
return "DMR"
|
|
case strings.Contains(s, "ysf"):
|
|
return "YSF"
|
|
case strings.Contains(s, "nxdn"):
|
|
return "NXDN"
|
|
case strings.Contains(s, "m17"):
|
|
return "M17"
|
|
case sw != "":
|
|
return sw
|
|
default:
|
|
return ""
|
|
}
|
|
}
|
|
|
|
// A bridge node just links systems together (chan_svx asterisk, a DMR/trunk
|
|
// bridge, or an -AC/-DMR/-T suffix) — not a real user/radio client, so we never
|
|
// show it. chan_svx_sat IS a real AllStar node over the satellite trunk and is
|
|
// kept. Mirrors the TG Monitor's isBridge so both views show only real users.
|
|
var bridgeSuffix = regexp.MustCompile(`(?i)-(AC|DMR|T)$`)
|
|
|
|
func isBridgeNode(callsign, sw, nodeClass string) bool {
|
|
s := strings.ToLower(sw)
|
|
// AllStar radio-network peers ARE shown — the links to svx/dmr/ysf/nxdn/m17
|
|
// are exactly what the user wants visible under their node/trunk. chan_svx and
|
|
// chan_svx_sat both count. Only pure non-radio infrastructure stays hidden.
|
|
if strings.Contains(s, "chan_svx") || strings.Contains(s, "chan_pbx") { // chan_svx + chan_svx_sat + chan_pbx_svx (PBX)
|
|
return false
|
|
}
|
|
nc := strings.ToLower(nodeClass)
|
|
switch nc {
|
|
case "dmr", "ysf", "nxdn", "m17":
|
|
return false // radio-network peer — show
|
|
case "xlx", "mumble", "stream", "echolink", "sip", "zello":
|
|
return true // non-radio / D-Star infra — hide
|
|
}
|
|
// sw-based radio bridges (DMR_/YSF_/NXDN_/M17_Bridge) → show; the remaining
|
|
// "*bridge" (XLX_/Mumble_/Janus Bridge) is non-radio infra → hide.
|
|
if strings.Contains(s, "dmr") || strings.Contains(s, "ysf") ||
|
|
strings.Contains(s, "nxdn") || strings.Contains(s, "m17") {
|
|
return false
|
|
}
|
|
if strings.Contains(s, "bridge") {
|
|
return true
|
|
}
|
|
// pure infrastructure gateway callsigns are never real peers.
|
|
u := strings.ToUpper(callsign)
|
|
if strings.HasPrefix(u, "SIP") || strings.HasPrefix(u, "DCS") ||
|
|
strings.HasPrefix(u, "XLX") || strings.Contains(u, "MUMBLE") {
|
|
return true
|
|
}
|
|
return false // default: show (real users + radio peers)
|
|
}
|
|
|
|
func toMonNode(callsign string, raw json.RawMessage) (monNode, bool) {
|
|
var r rawNode
|
|
_ = json.Unmarshal(raw, &r)
|
|
// Display name = the node's KEY (its registered identity — exactly what the
|
|
// Reflector clients view shows via the /status map key), so the two views can
|
|
// never disagree on a name. Trunk/satellite array nodes have no key -> use the
|
|
// callsign field (which statusnorm may have set to the per-TG group name).
|
|
cs := callsign
|
|
if cs == "" {
|
|
cs = r.Callsign
|
|
}
|
|
// A configured node (in nodes[]) is an intentional own node — always show it,
|
|
// even if its nodeClass would otherwise read as infra (e.g. a DMR gateway as
|
|
// "Stream"). Everything else keeps the bridge/infra filter.
|
|
if !isConfiguredName(cs) && isBridgeNode(cs, r.SW, r.NodeClass) {
|
|
return monNode{}, false // bridge, not a real user — hide it
|
|
}
|
|
// TG: the node's own tg, else its first monitored/linked TG.
|
|
tg := ""
|
|
if s := r.TG.String(); s != "" && s != "0" {
|
|
tg = s
|
|
} else if len(r.MonitoredTGs) > 0 {
|
|
tg = r.MonitoredTGs[0].String()
|
|
} else if len(r.Links) > 0 {
|
|
tg = r.Links[0].LocalTg.String()
|
|
}
|
|
if r.RestrictedTG && tg != "" {
|
|
tg = "*" + tg
|
|
}
|
|
// Per-TG name: several sat nodes share ONE reflector login (KT4ROY-ASL), so
|
|
// under a trunk they'd all show that same callsign. If svxdr.json configures a
|
|
// per-TG via for this node's TG, show THAT (KT4ROY-ASL1..4) — the trunk's own
|
|
// name is unaffected. One place (svxdr.json) drives it.
|
|
// Type/mode, in priority order (no hardcoding of any mode):
|
|
// 1) config nodes[].mode (explicit override, keyed by display name)
|
|
// 2) the node's own "system" field from the data (DMR, DMR/YSF, DMR/Hytera…)
|
|
// 3) type derived from the software string
|
|
mode := modeForName(cs)
|
|
if mode == "" {
|
|
mode = r.System
|
|
}
|
|
if mode == "" {
|
|
mode = monModeOf(r.SW)
|
|
}
|
|
return monNode{Callsign: cs, TG: tg, NodeClass: r.NodeClass, Mode: mode, Talker: r.IsTalker}, true
|
|
}
|
|
|
|
// ownLocalNodes reads OUR OWN reflector (reflectorURL) and returns its top-level
|
|
// nodes — the ones genuinely local to THIS server. Used to keep Local accurate
|
|
// when MONITOR_STATUS_URL points at a foreign master.
|
|
func ownLocalNodes(client *http.Client) ([]monNode, bool) {
|
|
resp, err := client.Get(reflectorURL)
|
|
if err != nil {
|
|
return nil, false
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return nil, false
|
|
}
|
|
var raw struct {
|
|
Nodes map[string]json.RawMessage `json:"nodes"`
|
|
}
|
|
if json.NewDecoder(resp.Body).Decode(&raw) != nil {
|
|
return nil, false
|
|
}
|
|
var out []monNode
|
|
for cs, rn := range raw.Nodes {
|
|
if mn, ok := toMonNode(cs, rn); ok {
|
|
out = append(out, mn)
|
|
}
|
|
}
|
|
return out, true
|
|
}
|
|
|
|
func buildMonitor(client *http.Client, generated string) (monStatus, bool) {
|
|
resp, err := client.Get(monitorStatusURL)
|
|
if err != nil {
|
|
return monStatus{}, false
|
|
}
|
|
defer resp.Body.Close()
|
|
if resp.StatusCode != http.StatusOK {
|
|
return monStatus{}, false
|
|
}
|
|
body, err := io.ReadAll(resp.Body)
|
|
if err != nil {
|
|
return monStatus{}, false
|
|
}
|
|
// Normalize at ingest: sat-login node callsigns -> per-TG name. Covers the
|
|
// case where monitorStatusURL is a foreign master (e.g. a hub box reads a foreign master
|
|
// where its satellite nodes federate in). A local read via the proxy is already
|
|
// normalized; re-normalizing is a harmless no-op.
|
|
body = statusnorm.Normalize(body, renameSatNode)
|
|
var raw struct {
|
|
Nodes map[string]json.RawMessage `json:"nodes"`
|
|
Trunks map[string]struct {
|
|
Host string `json:"host"`
|
|
Connected bool `json:"connected"`
|
|
Nodes []json.RawMessage `json:"nodes"`
|
|
} `json:"trunks"`
|
|
// Satellite trunks (chan_svx_sat): each id (e.g. KT4ROY-ASL) carries N
|
|
// AllStar nodes. They live here, NOT in top-level nodes, so Monitor→Nodes
|
|
// must read them too or the sat nodes are invisible.
|
|
Satellites map[string]struct {
|
|
Authenticated bool `json:"authenticated"`
|
|
Nodes []json.RawMessage `json:"nodes"`
|
|
} `json:"satellites"`
|
|
}
|
|
if json.Unmarshal(body, &raw) != nil {
|
|
return monStatus{}, false
|
|
}
|
|
|
|
out := monStatus{Source: monitorStatusURL, UpdatedAt: generated}
|
|
|
|
// Local = only OUR reflector's own nodes. Whenever MONITOR_STATUS_URL is a
|
|
// REMOTE master (not our own reflector), ALWAYS split: read our OWN reflector for
|
|
// the local set and put the master's own top-level nodes under a section instead.
|
|
// This must behave the SAME on every box regardless of its svxreflector.conf
|
|
// dialect — so the split is decided purely by "is the master remote", and the
|
|
// section label is derived from the DATA (masterReflectorName) when the conf
|
|
// doesn't name the uplink trunk. No per-box special cases.
|
|
doSplit := reflectorURL != monitorStatusURL
|
|
masterLabel := ""
|
|
if doSplit {
|
|
if masterLabel = masterReflectorName(raw.Nodes); masterLabel == "" {
|
|
masterLabel = "uplink"
|
|
}
|
|
}
|
|
selfCalls := map[string]bool{}
|
|
if doSplit {
|
|
if selfNodes, ok := ownLocalNodes(client); ok {
|
|
for _, mn := range selfNodes {
|
|
out.Local = append(out.Local, mn)
|
|
selfCalls[strings.ToUpper(mn.Callsign)] = true
|
|
}
|
|
}
|
|
}
|
|
|
|
var foreignTop []monNode
|
|
for cs, rn := range raw.Nodes {
|
|
mn, ok := toMonNode(cs, rn)
|
|
if !ok {
|
|
continue
|
|
}
|
|
if !doSplit {
|
|
out.Local = append(out.Local, mn)
|
|
} else if !selfCalls[strings.ToUpper(mn.Callsign)] {
|
|
foreignTop = append(foreignTop, mn) // the master's own node → its trunk section
|
|
}
|
|
}
|
|
sort.Slice(out.Local, func(i, j int) bool { return out.Local[i].Callsign < out.Local[j].Callsign })
|
|
|
|
// The master's own nodes become one trunk section (we reach it over a trunk).
|
|
if len(foreignTop) > 0 {
|
|
sort.Slice(foreignTop, func(i, j int) bool { return foreignTop[i].Callsign < foreignTop[j].Callsign })
|
|
out.Trunks = append(out.Trunks, monTrunk{Name: masterLabel, Connected: true, Nodes: foreignTop})
|
|
}
|
|
|
|
for name, t := range raw.Trunks {
|
|
mt := monTrunk{Name: name, Host: t.Host, Connected: t.Connected}
|
|
for _, rn := range t.Nodes {
|
|
if mn, ok := toMonNode("", rn); ok {
|
|
mt.Nodes = append(mt.Nodes, mn)
|
|
}
|
|
}
|
|
sort.Slice(mt.Nodes, func(i, j int) bool { return mt.Nodes[i].Callsign < mt.Nodes[j].Callsign })
|
|
out.Trunks = append(out.Trunks, mt)
|
|
}
|
|
|
|
// Satellite trunks: one section per satellite id (KT4ROY-ASL), its AllStar
|
|
// nodes (KT4ROY-ASL1..4 via toMonNode's per-TG rename) shown under it.
|
|
for id, s := range raw.Satellites {
|
|
mt := monTrunk{Name: id, Connected: s.Authenticated}
|
|
for _, rn := range s.Nodes {
|
|
if mn, ok := toMonNode("", rn); ok {
|
|
mt.Nodes = append(mt.Nodes, mn)
|
|
}
|
|
}
|
|
sort.Slice(mt.Nodes, func(i, j int) bool { return mt.Nodes[i].Callsign < mt.Nodes[j].Callsign })
|
|
out.Trunks = append(out.Trunks, mt)
|
|
}
|
|
// Connected trunks first, then by name — stable, tidy sections.
|
|
sort.Slice(out.Trunks, func(i, j int) bool {
|
|
if out.Trunks[i].Connected != out.Trunks[j].Connected {
|
|
return out.Trunks[i].Connected
|
|
}
|
|
return out.Trunks[i].Name < out.Trunks[j].Name
|
|
})
|
|
// Make every node name unique across the whole view (backend, before the web).
|
|
uniquifyNames(&out)
|
|
return out, true
|
|
}
|
|
|
|
func monitorNodesStart(mux *http.ServeMux) {
|
|
client := &http.Client{Timeout: 12 * time.Second}
|
|
|
|
refresh := func() {
|
|
// stamp with a wall-clock time computed off the request (Date.now is
|
|
// unavailable in workflow scripts, but this is normal server code).
|
|
if next, ok := buildMonitor(client, time.Now().UTC().Format(time.RFC3339)); ok {
|
|
monState.mu.Lock()
|
|
monState.data = next
|
|
monState.mu.Unlock()
|
|
}
|
|
}
|
|
refresh()
|
|
go func() {
|
|
// Local reflector reads are free (fast); a remote master URL is throttled.
|
|
t := time.NewTicker(readInterval(monitorStatusURL))
|
|
defer t.Stop()
|
|
for range t.C {
|
|
refresh()
|
|
}
|
|
}()
|
|
|
|
mux.HandleFunc("/api/monitor-nodes", func(w http.ResponseWriter, r *http.Request) {
|
|
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
|
monState.mu.RLock()
|
|
b, _ := json.Marshal(monState.data)
|
|
monState.mu.RUnlock()
|
|
writeMaybeGzip(w, r, b)
|
|
})
|
|
}
|