Files
reflector-dashboard/svxdr/nodelist/svxdrcfg.go
T

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