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