Add svxdr: the live-data backend (nodelist + proxy; astmeta/xlink optional), config example, systemd unit, INSTALL section 9
svxdr sources from the internal repo with AGPL headers; install/svxdr.json.example is a plain-reflector layout (reflector 8099 -> proxy 8090 -> nodelist 8098) with CHANGEME secrets and no site values. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,445 @@
|
||||
// svxdr — live-data daemon for SvxPortal (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):
|
||||
// speed → https://reflector.sm2ampr.net/geu/status/ (the master's full view)
|
||||
// aldmr → 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. speed reads the SE hub
|
||||
// where aldmr's sat 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)
|
||||
})
|
||||
}
|
||||
Reference in New Issue
Block a user