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

300 lines
8.7 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 → Trunks lists ONLY the trunks THIS reflector is configured to connect
// to — read straight from the local svxreflector.conf. Two config dialects exist
// in the wild and both are supported:
//
// 1) newer GEU fork:
// [ReflectorTrunk]
// Peers=SE-AUX
// [TrunkPeer#SE-AUX]
// Host=reflector.reflector-network.se
//
// 2) older fork:
// [TRUNK_US_SE]
// HOST=reflector.reflector-network.se
// PORT=5302
// REMOTE_PREFIX=240
//
// The configured trunk NAME is matched against the LOCAL reflector's /status to
// fill online/offline + node count. The host is used only for that match and is
// never sent to the client (host/IP must never be exposed).
import (
"bufio"
"encoding/json"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
)
// svxreflectorConf is declared in main.go (default /etc/svxlink/svxreflector.conf).
// satTG is one talkgroup carried by a satellite trunk (per-TG label + tg number).
type satTG struct {
Name string `json:"name"`
TG string `json:"tg"`
}
type localTrunk struct {
Name string `json:"name"`
Connected bool `json:"connected"`
Nodes int `json:"nodes"`
TGs []satTG `json:"tgs,omitempty"` // satellite trunks: the TGs it carries
}
type confTrunk struct {
name string
hosts []string // normalized, for status matching only — never exposed
}
// normTrunkHost strips scheme, path and port and lowercases a host.
func normTrunkHost(h string) string {
h = strings.ToLower(strings.TrimSpace(h))
if i := strings.Index(h, "://"); i >= 0 {
h = h[i+3:]
}
if i := strings.IndexByte(h, '/'); i >= 0 {
h = h[:i]
}
if i := strings.LastIndexByte(h, ':'); i >= 0 {
h = h[:i]
}
return strings.Trim(h, "[]")
}
// parseConfTrunks reads svxreflector.conf and returns the configured trunks
// (both dialects). Order is preserved; duplicates by name are dropped.
func parseConfTrunks(path string) []confTrunk {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
type section struct {
name string
kv map[string]string
}
var order []*section
byName := map[string]*section{}
var cur *section
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
cur = &section{name: line[1 : len(line)-1], kv: map[string]string{}}
order = append(order, cur)
byName[cur.name] = cur
continue
}
if cur == nil {
continue
}
if i := strings.IndexByte(line, '='); i > 0 {
cur.kv[strings.ToUpper(strings.TrimSpace(line[:i]))] = strings.TrimSpace(line[i+1:])
}
}
var out []confTrunk
seen := map[string]bool{}
add := func(name, hostCSV string) {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
return
}
seen[name] = true
var hosts []string
for _, h := range strings.Split(hostCSV, ",") {
if hh := normTrunkHost(h); hh != "" {
hosts = append(hosts, hh)
}
}
out = append(out, confTrunk{name: name, hosts: hosts})
}
// Dialect 1: [ReflectorTrunk] Peers=A,B -> [TrunkPeer#A] Host=...
if rt := byName["ReflectorTrunk"]; rt != nil {
for _, p := range strings.Split(rt.kv["PEERS"], ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
host := ""
if peer := byName["TrunkPeer#"+p]; peer != nil {
host = peer.kv["HOST"]
}
add(p, host)
}
}
// Dialect 2: [TRUNK_xxx] HOST=...
for _, s := range order {
if strings.HasPrefix(strings.ToUpper(s.name), "TRUNK_") {
add(s.name, s.kv["HOST"])
}
}
return out
}
var localTrunkState = struct {
mu sync.RWMutex
data []localTrunk
src string
}{data: []localTrunk{}}
// buildLocalTrunks parses the conf and matches each trunk against the LOCAL
// reflector's /status (by name, then host) for online + node count.
func buildLocalTrunks(client *http.Client) {
conf := parseConfTrunks(svxreflectorConf)
type liveT struct {
connected bool
nodes int
host string
}
live := map[string]liveT{}
// Satellite trunks (chan_svx_sat) live status, keyed by reflector sat id.
type liveSat struct {
connected bool
nodes int
}
liveSats := map[string]liveSat{}
if resp, err := client.Get(reflectorURL); err == nil {
func() {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
}
var raw struct {
Trunks map[string]struct {
Host string `json:"host"`
Connected bool `json:"connected"`
Nodes []json.RawMessage `json:"nodes"`
} `json:"trunks"`
TrunksUDP []struct {
Name string `json:"name"`
Status bool `json:"status"`
Host string `json:"host"`
} `json:"trunks_UDP"`
Satellites map[string]struct {
Authenticated bool `json:"authenticated"`
Nodes []json.RawMessage `json:"nodes"`
} `json:"satellites"`
}
if json.NewDecoder(resp.Body).Decode(&raw) == nil {
for n, t := range raw.Trunks {
live[n] = liveT{connected: t.Connected, nodes: len(t.Nodes), host: normTrunkHost(t.Host)}
}
for id, s := range raw.Satellites {
liveSats[id] = liveSat{connected: s.Authenticated, nodes: len(s.Nodes)}
}
// trunks_UDP peers are named like "TrunkPeer#SE-AUX"; index them
// both raw and with the prefix stripped so a conf name matches.
for _, t := range raw.TrunksUDP {
v := liveT{connected: t.Status, host: normTrunkHost(t.Host)}
live[t.Name] = v
if s := strings.TrimPrefix(t.Name, "TrunkPeer#"); s != t.Name {
live[s] = v
}
}
}
}()
}
out := make([]localTrunk, 0, len(conf))
for _, c := range conf {
lt := localTrunk{Name: c.name}
if v, ok := live[c.name]; ok { // exact name match — most reliable
lt.Connected, lt.Nodes = v.connected, v.nodes
} else {
for _, v := range live { // fall back to host match
if v.host == "" {
continue
}
for _, h := range c.hosts {
if v.host == h || strings.Contains(v.host, h) || strings.Contains(h, v.host) {
lt.Connected, lt.Nodes = v.connected, v.nodes
}
}
}
}
out = append(out, lt)
}
// Satellite trunks (chan_svx_sat): ONE trunk per satellite, named by the
// satellite id (e.g. KT4ROY-ASL). All chan_svx_sat entries that share the same
// satellite_id are the SAME trunk carrying N talkgroups — they are grouped, not
// listed as one-trunk-per-TG. The per-TG labels (callsign + tg) are attached as
// TGs so the UI can tell the talkgroups apart under the single trunk name.
// Fully dynamic: the satellite set is the union of the configured [SATELLITE]
// ids in svxreflector.conf and any live-authenticated sat in the reflector
// /status, so new satellites (Chicago, …) appear automatically and offline ones
// still show. Secrets from [SATELLITE] are never read/exposed — only the id.
satSet := map[string]bool{}
for id := range liveSats {
satSet[id] = true
}
for _, id := range satelliteIDsFromReflectorConf(svxreflectorConf) {
satSet[id] = true
}
for _, id := range satTrunkIDs() { // configured satellites always show, even offline
satSet[id] = true
}
if len(satSet) > 0 {
ids := make([]string, 0, len(satSet))
for id := range satSet {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
s := liveSats[id] // zero value (offline, 0 nodes) if not live
out = append(out, localTrunk{
Name: id, // trunk name = satellite id (KT4ROY-ASL)
Connected: s.connected,
Nodes: s.nodes,
TGs: satTGsForTrunk(id), // TGs + per-TG names straight from svxdr.json
})
}
}
localTrunkState.mu.Lock()
localTrunkState.data = out
localTrunkState.src = svxreflectorConf
localTrunkState.mu.Unlock()
}
func localTrunksStart(mux *http.ServeMux) {
client := &http.Client{Timeout: 8 * time.Second}
buildLocalTrunks(client)
go func() {
t := time.NewTicker(10 * time.Second)
defer t.Stop()
for range t.C {
buildLocalTrunks(client)
}
}()
mux.HandleFunc("/api/local-trunks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
localTrunkState.mu.RLock()
b, _ := json.Marshal(map[string]interface{}{"trunks": localTrunkState.data, "source": localTrunkState.src})
localTrunkState.mu.RUnlock()
writeMaybeGzip(w, r, b)
})
}