367 lines
12 KiB
Go
367 lines
12 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
|
|
|
|
// svxdr config wiring. applyConfig overrides the package's env-derived globals
|
|
// with values from svxdr.json (a set field wins; an unset field keeps the
|
|
// built-in default). applyRenderFromConfig builds the lastheard render config
|
|
// (Location formats + reflector names) from svxdr.json instead of a separate
|
|
// lastheard.json / nodes.json. Nothing else in nodelist changed.
|
|
|
|
import (
|
|
"strconv"
|
|
"strings"
|
|
"sync"
|
|
"time"
|
|
|
|
"svxportal/svxdr/config"
|
|
)
|
|
|
|
// Per-TG expected via (from svxdr.json nodes[].via). talkerMetaFor matches the
|
|
// incoming meta against THIS instead of the reflector-log talker name, so the
|
|
// callsign/via is defined in ONE place (svxdr.json) and svx_sat.conf's callsign
|
|
// no longer has to equal it.
|
|
var (
|
|
tgViaMu sync.RWMutex
|
|
groupTgVia = map[string]map[int]string{} // group login -> tg -> via (per group, so groups may share a TG)
|
|
tgViaAny = map[int]map[string]bool{} // tg -> set of every configured via (for talkerMetaFor)
|
|
)
|
|
|
|
func setTgVia(gv map[string]map[int]string, any map[int]map[string]bool) {
|
|
tgViaMu.Lock()
|
|
groupTgVia, tgViaAny = gv, any
|
|
tgViaMu.Unlock()
|
|
}
|
|
func viaFor(login string, tg int) string {
|
|
tgViaMu.RLock()
|
|
defer tgViaMu.RUnlock()
|
|
if m := groupTgVia[login]; m != nil {
|
|
return m[tg]
|
|
}
|
|
return ""
|
|
}
|
|
func anyViaForTG(tg int) bool {
|
|
tgViaMu.RLock()
|
|
defer tgViaMu.RUnlock()
|
|
return len(tgViaAny[tg]) > 0
|
|
}
|
|
func viaConfiguredForTG(tg int, via string) bool {
|
|
tgViaMu.RLock()
|
|
defer tgViaMu.RUnlock()
|
|
return tgViaAny[tg][via]
|
|
}
|
|
|
|
// Per-node mode/type (from nodes[].mode), keyed by the node's display name (via or
|
|
// callsign). Config wins over the data-derived type; empty = fall back to data.
|
|
var (
|
|
modeByNameMu sync.RWMutex
|
|
modeByNameMap = map[string]string{}
|
|
)
|
|
|
|
func setModeByName(m map[string]string) { modeByNameMu.Lock(); modeByNameMap = m; modeByNameMu.Unlock() }
|
|
func modeForName(name string) string {
|
|
modeByNameMu.RLock()
|
|
defer modeByNameMu.RUnlock()
|
|
return modeByNameMap[name]
|
|
}
|
|
|
|
// Per-TG reflector/system name (from nodes[].system), so ANY talker on that TG —
|
|
// AllStar, DMR or a bare reflector client — falls back to the same Location label
|
|
// (e.g. TG 314093 -> "WHITELIGHTNING"). Used by locForTG when there is no live
|
|
// gateway location for the TG.
|
|
var (
|
|
tgSystemMu sync.RWMutex
|
|
tgSystemMap = map[int]string{}
|
|
)
|
|
|
|
func setTgSystem(m map[int]string) { tgSystemMu.Lock(); tgSystemMap = m; tgSystemMu.Unlock() }
|
|
|
|
// Canonical TG->system name fetched from the shared tglist (svx-web /tglist.php).
|
|
// FROZEN DESIGN: the tglist is the SOLE source of TG names/Location shown in
|
|
// Lastheard — there is NO fallback (not nodes[].system, not live gateway/astmeta
|
|
// data). A TG absent from the tglist has no name, by design; the list is edited
|
|
// on the master and every box syncs it, so the display is identical everywhere.
|
|
var (
|
|
tglistSystemMu sync.RWMutex
|
|
tglistSystemMap = map[int]string{}
|
|
)
|
|
|
|
func setTglistSystem(m map[int]string) { tglistSystemMu.Lock(); tglistSystemMap = m; tglistSystemMu.Unlock() }
|
|
|
|
func systemForTG(tg int) string {
|
|
tglistSystemMu.RLock()
|
|
defer tglistSystemMu.RUnlock()
|
|
return tglistSystemMap[tg]
|
|
}
|
|
|
|
// Configured node display names (every nodes[] via/callsign). A configured node is
|
|
// an intentional own node and is never hidden by the bridge/infra filter.
|
|
var (
|
|
confNamesMu sync.RWMutex
|
|
confNamesSet = map[string]bool{}
|
|
)
|
|
|
|
func setConfiguredNames(m map[string]bool) { confNamesMu.Lock(); confNamesSet = m; confNamesMu.Unlock() }
|
|
func isConfiguredName(name string) bool {
|
|
confNamesMu.RLock()
|
|
defer confNamesMu.RUnlock()
|
|
return confNamesSet[name]
|
|
}
|
|
|
|
// satLogins = the shared reflector login(s) our sat nodes use (= trunk
|
|
// satellite_id, e.g. KT4ROY-ASL). The per-TG rename applies ONLY to a node
|
|
// whose callsign IS such a login — so a FOREIGN node that merely monitors the
|
|
// same TG (e.g. SA7AUX on 3149050) is never mislabeled KT4ROY-ASL2.
|
|
var (
|
|
satLoginsMu sync.RWMutex
|
|
satLoginsSet = map[string]bool{}
|
|
)
|
|
|
|
func setSatLogins(m map[string]bool) { satLoginsMu.Lock(); satLoginsSet = m; satLoginsMu.Unlock() }
|
|
func isSatLogin(cs string) bool {
|
|
satLoginsMu.RLock()
|
|
defer satLoginsMu.RUnlock()
|
|
return satLoginsSet[cs]
|
|
}
|
|
|
|
// renameSatNode is THE rule for statusnorm.Normalize: a node whose login (its
|
|
// callsign for trunk/sat nodes, its key for top-level nodes) is a configured group
|
|
// -> that group's per-TG name. Keyed by (group, tg), so two different nodes (e.g. an
|
|
// ASL one and a DMR one) can share a TG and each keeps its own group's name.
|
|
func renameSatNode(login string, tg int) string {
|
|
// DISABLED (2026-08-25): the sat nodes now announce network-unique names at
|
|
// the source (callsign + per-TG suffix, e.g. KT4ROY-ASL-A93 / -050), so the
|
|
// bare shared login no longer appears and this per-TG rewrite is redundant —
|
|
// and we must NOT re-rewrite the already-unique source names. Always keep the
|
|
// source name; duplicate-safety still lives in uniquifyNames (view-only).
|
|
_ = login
|
|
_ = tg
|
|
return ""
|
|
}
|
|
|
|
// satTrunkTGs = the satellite trunk's talkgroups straight from svxdr.json nodes[]
|
|
// (grouped under the trunk satellite_id), names = the per-TG via. ONE source; used
|
|
// by Monitor->Trunks so it needs neither svx_sat.conf nor an override.
|
|
var (
|
|
satTrunkTGsMu sync.RWMutex
|
|
satTrunkTGs = map[string][]satTG{}
|
|
)
|
|
|
|
func setSatTrunkTGs(m map[string][]satTG) { satTrunkTGsMu.Lock(); satTrunkTGs = m; satTrunkTGsMu.Unlock() }
|
|
func satTGsForTrunk(id string) []satTG {
|
|
satTrunkTGsMu.RLock()
|
|
defer satTrunkTGsMu.RUnlock()
|
|
return satTrunkTGs[id]
|
|
}
|
|
func satTrunkIDs() []string {
|
|
satTrunkTGsMu.RLock()
|
|
defer satTrunkTGsMu.RUnlock()
|
|
ids := make([]string, 0, len(satTrunkTGs))
|
|
for id := range satTrunkTGs {
|
|
ids = append(ids, id)
|
|
}
|
|
return ids
|
|
}
|
|
|
|
func setStr(dst *string, v string) {
|
|
if v != "" {
|
|
*dst = v
|
|
}
|
|
}
|
|
func setDur(dst *time.Duration, v config.Duration) {
|
|
if v.D() > 0 {
|
|
*dst = v.D()
|
|
}
|
|
}
|
|
|
|
func applyConfig(c *config.Config) {
|
|
n := c.Nodelist
|
|
setStr(&listenAddr, n.ListenAddr)
|
|
setStr(&reflectorURL, n.ReflectorStatus)
|
|
// monitor status defaults to the (now-resolved) reflector URL unless set.
|
|
monitorStatusURL = reflectorURL
|
|
setStr(&monitorStatusURL, n.MonitorStatusURL)
|
|
debug = n.Debug
|
|
setDur(&pollInterval, n.PollInterval)
|
|
setStr(&reflectorLogPath, n.ReflectorLogPath)
|
|
setStr(&networkJSONPath, n.NetworkJSONPath)
|
|
setStr(&astdbURL, n.AstdbURL)
|
|
setDur(&talkerMetaTTL, n.TalkerMetaTTL)
|
|
setStr(&ingestToken, n.TalkerIngestToken)
|
|
setDur(&allstarNodesTTL, n.AllstarNodesTTL)
|
|
setDur(&nodeOnlineTTL, n.NodeOnlineTTL)
|
|
setDur(&nodeTTL, n.NodeTTL)
|
|
setStr(&mqttBroker, n.MqttBroker)
|
|
setStr(&mqttUser, n.MqttUser)
|
|
setStr(&mqttPass, n.MqttPass)
|
|
setStr(&mqttPrefix, n.MqttPrefix)
|
|
|
|
e := n.External
|
|
setStr(&externalPullURL, e.PullURL)
|
|
setStr(&externalPullScheme, e.PullScheme)
|
|
setStr(&externalPullPort, e.PullPort)
|
|
setStr(&externalPullPath, e.PullPath)
|
|
setDur(&externalPullInterval, e.PullInterval)
|
|
if len(e.StatusPorts) > 0 {
|
|
externalStatusPorts = strings.Join(e.StatusPorts, ",")
|
|
}
|
|
setStr(&externalStatusPath, e.StatusPath)
|
|
setStr(&externalProxyPath, e.ProxyPath)
|
|
setStr(&externalDetailPath, e.DetailPath)
|
|
if e.MaxRefs > 0 {
|
|
externalMaxRefs = e.MaxRefs
|
|
}
|
|
setDur(&externalStaleTTL, e.StaleTTL)
|
|
|
|
setDur(&monitorInterval, n.MonitorInterval)
|
|
|
|
f := n.Federation
|
|
if len(f.Seeds) > 0 {
|
|
federationSeeds = strings.Join(f.Seeds, ",")
|
|
}
|
|
setStr(&federationAggURL, f.AggregateURL)
|
|
setDur(&federationAggInterval, f.AggregateEvery)
|
|
|
|
r := c.Redis
|
|
setStr(&redisSock, r.Socket)
|
|
setStr(&redisPass, r.Password)
|
|
setStr(&statusKey, r.StatusKey)
|
|
setStr(&statusHashKey, r.StatusHashKey)
|
|
setStr(&pubsubChan, r.PubsubChan)
|
|
setStr(&nodesOnlineKey, r.NodesOnlineKey)
|
|
setStr(&nodeKeyPrefix, r.NodeKeyPrefix)
|
|
setStr(&nodeConnectChan, r.NodeConnectChan)
|
|
setStr(&nodeDisconnectChan, r.NodeDisconnectChan)
|
|
setStr(&nodeUpdateChan, r.NodeUpdateChan)
|
|
setStr(&lastheardKey, r.LastheardKey)
|
|
|
|
// xlink only when this box has the module enabled.
|
|
xlinkEnabled = c.Modules.Xlink
|
|
if c.Modules.Xlink {
|
|
x := c.Xlink
|
|
setStr(&xlinkBroker, x.Broker)
|
|
setStr(&xlinkUser, x.User)
|
|
setStr(&xlinkPass, x.Pass)
|
|
setStr(&xlinkTopic, x.Topic)
|
|
setStr(&xlinkOrigin, x.Origin)
|
|
}
|
|
}
|
|
|
|
// applyRenderFromConfig sets the lastheard render config (Location formats +
|
|
// reflector names) from svxdr.json. reflector_names is keyed the way
|
|
// bridgeIdentity looks it up: bridge label (via/callsign) and far AllStar node.
|
|
func applyRenderFromConfig(c *config.Config) {
|
|
rc := defaultRenderCfg
|
|
rc.ReflectorNames = map[string]string{}
|
|
if s := strings.TrimSpace(c.Lastheard.BridgeLocationFormat); s != "" {
|
|
rc.BridgeLocationFormat = s
|
|
}
|
|
if s := strings.TrimSpace(c.Lastheard.BridgeLocationFormatNoSystem); s != "" {
|
|
rc.BridgeLocationFormatNoSystem = s
|
|
}
|
|
for k, v := range c.Lastheard.ReflectorNames {
|
|
if v != "" {
|
|
rc.ReflectorNames[k] = v
|
|
}
|
|
}
|
|
// Skip via/callsign labels that several nodes share with DIFFERENT systems
|
|
// (e.g. a uniform trunk callsign KT4ROY-ASL across East Coast + WHITELIGHTNING):
|
|
// keying reflector_names by them collapses and then overrides astmeta's
|
|
// correct per-TG System. Only add unambiguous labels.
|
|
const conflict = "\x00CONFLICT"
|
|
label := map[string]string{}
|
|
mark := func(k, sys string) {
|
|
if k == "" || sys == "" {
|
|
return
|
|
}
|
|
if prev, ok := label[k]; ok && prev != sys {
|
|
label[k] = conflict
|
|
} else if !ok {
|
|
label[k] = sys
|
|
}
|
|
}
|
|
for _, e := range c.Nodes {
|
|
if !e.IsEnabled() {
|
|
continue
|
|
}
|
|
mark(e.Via, e.System)
|
|
mark(e.Callsign, e.System)
|
|
}
|
|
for _, e := range c.Nodes {
|
|
if !e.IsEnabled() || e.System == "" {
|
|
continue
|
|
}
|
|
if e.Via != "" && label[e.Via] != conflict {
|
|
rc.ReflectorNames[e.Via] = e.System
|
|
}
|
|
if e.Callsign != "" && label[e.Callsign] != conflict {
|
|
rc.ReflectorNames[e.Callsign] = e.System
|
|
}
|
|
if e.Allstar != 0 {
|
|
rc.ReflectorNames[strconv.Itoa(e.Allstar)] = e.System
|
|
}
|
|
}
|
|
// ONE pass over nodes[] builds everything, purely config-driven, group-aware:
|
|
// gv[group][tg]=via per-(group,TG) rename — two nodes (ASL + DMR) may share a TG
|
|
// any[tg]={via,...} every configured via for a TG — talkerMetaFor match
|
|
// logins/groupTGs the group trunks (satLogins + per-TG names)
|
|
// modeMap[name]=mode free-text type per node
|
|
// NOTHING about ASL/DMR/etc is hardcoded — add a group/mode in config and it
|
|
// renders the same as any other.
|
|
gv := map[string]map[int]string{}
|
|
any := map[int]map[string]bool{}
|
|
logins := map[string]bool{}
|
|
groupTGs := map[string][]satTG{}
|
|
modeMap := map[string]string{}
|
|
confNames := map[string]bool{}
|
|
tgSys := map[int]string{}
|
|
for _, e := range c.Nodes {
|
|
if !e.IsEnabled() || e.TG == 0 {
|
|
continue
|
|
}
|
|
if e.System != "" {
|
|
tgSys[e.TG] = e.System // TG -> reflector name, Location fallback for any talker on this TG
|
|
}
|
|
name := e.Via
|
|
if name == "" {
|
|
name = e.Callsign
|
|
}
|
|
if name == "" {
|
|
continue
|
|
}
|
|
confNames[name] = true
|
|
if any[e.TG] == nil {
|
|
any[e.TG] = map[string]bool{}
|
|
}
|
|
any[e.TG][name] = true
|
|
if e.Mode != "" {
|
|
modeMap[name] = e.Mode
|
|
}
|
|
if e.Group != "" {
|
|
logins[e.Group] = true
|
|
if gv[e.Group] == nil {
|
|
gv[e.Group] = map[int]string{}
|
|
}
|
|
gv[e.Group][e.TG] = name
|
|
groupTGs[e.Group] = append(groupTGs[e.Group], satTG{Name: name, TG: strconv.Itoa(e.TG)})
|
|
}
|
|
}
|
|
setTgVia(gv, any)
|
|
setSatLogins(logins)
|
|
setSatTrunkTGs(groupTGs)
|
|
setModeByName(modeMap)
|
|
setConfiguredNames(confNames)
|
|
setTgSystem(tgSys)
|
|
|
|
renderCfgMu.Lock()
|
|
renderCfg = rc
|
|
renderCfgMu.Unlock()
|
|
}
|