300 lines
8.7 KiB
Go
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 = §ion{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)
|
|
})
|
|
}
|