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

4316 lines
133 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 is the svxdr nodelist subsystem — the reflector status crawl,
// lastheard, talker-meta API, external federation and xlink, folded in from the
// standalone svx-web/nodelist. The logic is preserved verbatim; only config
// wiring changed: env-var globals are overridden by applyConfig() from
// svxdr.json, and main() became Run(ctx, cfg). See svxdrcfg.go.
package nodelist
import (
"bufio"
"compress/gzip"
"context"
"crypto/sha256"
"crypto/subtle"
"crypto/tls"
"encoding/hex"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"net/url"
"os"
"path/filepath"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/redis/go-redis/v9"
"svxportal/svxdr/config"
)
var (
ctx = context.Background()
listenAddr = env("LISTEN_ADDR", ":8098")
reflectorURL = env("REFLECTOR_STATUS_URL", "http://127.0.0.1:8090/status")
pollInterval = envDur("POLL_INTERVAL", 1*time.Second)
redisSock = env("REDIS_SOCKET", "/run/redis/redis.sock")
redisPass = env("REDIS_PASSWORD", "")
statusKey = env("REDIS_STATUS_KEY", "/registry/svxlink/status/json")
statusHashKey = env("REDIS_STATUS_HASH_KEY", "/registry/svxlink/status/hash")
pubsubChan = env("REDIS_PUBSUB_CHAN", "/registry/svxlink/status/update")
nodesOnlineKey = env("REDIS_NODES_ONLINE_KEY", "/registry/svxlink/nodes/online")
nodeKeyPrefix = env("REDIS_NODE_KEY_PREFIX", "/registry/svxlink/node")
nodeConnectChan = env("REDIS_NODE_CONNECT_CHAN", "/registry/svxlink/node/connect")
nodeDisconnectChan = env("REDIS_NODE_DISCONNECT_CHAN", "/registry/svxlink/node/disconnect")
nodeUpdateChan = env("REDIS_NODE_UPDATE_CHAN", "/registry/svxlink/node/update")
lastheardKey = env("REDIS_LASTHEARD_KEY", "/registry/svxlink/lastheard")
lastheardWindowSec = int64(24 * 60 * 60)
reflectorLogPath = env("REFLECTOR_LOG_PATH", "/var/log/svxreflector")
nodeOnlineTTL = envDur("NODE_ONLINE_TTL", 0)
nodeTTL = envDur("NODE_TTL", 0)
// How long a talker-meta entry (POST /api/talker-meta) stays valid
// without refresh (astmeta re-injects USRP INFO every ~10s while the
// talker is keyed, which re-fires the TCL hook that posts here).
talkerMetaTTL = envDur("TALKER_META_TTL", 30*time.Second)
// Shared secret for POST /api/talker-meta (the svxlink TCL hook on the
// USRP bridge). Empty = endpoint disabled.
ingestToken = env("TALKER_INGEST_TOKEN", "")
// How long a bridge-heard station stays in the /api/allstar-nodes list
// after its last keyup.
allstarNodesTTL = envDur("ALLSTAR_NODES_TTL", 30*time.Minute)
// MQTT (reflector publishes the full picture incl. trunk-side nodes here).
mqttBroker = env("MQTT_BROKER", "") // e.g. tcp://127.0.0.1:1883
mqttUser = env("MQTT_USERNAME", "")
mqttPass = env("MQTT_PASSWORD", "")
mqttPrefix = env("MQTT_TOPIC_PREFIX", "") // e.g. svxreflector/sa7aux
// Cross-link talker mesh (SVX side). Publishes local SVX talkers to the mesh
// and injects DMR/AllStar talkers into the SVX lastheard. The DMR side owns
// the TG maps + translation, so this side only ever deals in its own svx_tg.
xlinkEnabled = envBool("XLINK_ENABLED", false)
xlinkBroker = env("XLINK_BROKER", "") // tcp://host:port
xlinkUser = env("XLINK_USER", "")
xlinkPass = env("XLINK_PASS", "")
xlinkTopic = env("XLINK_TOPIC", "callmgr/xlink/talker")
xlinkOrigin = env("XLINK_ORIGIN", "") // defaults to svx@<host>
// External network pull: upstream aggregate of every reflector in the
// network (sources + their nodes tagged _source + all network trunks).
// The uplink host is derived from svxreflector.conf at runtime — NO address
// is baked into the binary. Scheme/port/path are env-tunable because the
// topology HTTP port varies per system/trunk; port is never assumed.
svxreflectorConf = env("SVXREFLECTOR_CONF", "/etc/svxlink/svxreflector.conf")
// chan_svx_sat config: maps each ENABLED satellite trunk's satellite_id
// (e.g. sat403872) to its friendly callsign (e.g. KT4ROY-SAT1). Disabled
// (commented) sections are absent, so only enabled sat trunks are named/shown.
svxSatConf = env("SVX_SAT_CONF", "/etc/asterisk/svx_sat.conf")
externalPullURL = env("EXTERNAL_PULL_URL", "") // optional full override; else derived from conf
externalPullScheme = env("EXTERNAL_PULL_SCHEME", "https")
externalPullPort = env("EXTERNAL_PULL_PORT", "") // topology (svx_externalpull.php): empty = scheme default (443/80)
externalPullPath = env("EXTERNAL_PULL_PATH", "/svx_externalpull.php")
externalPullInterval = envDur("EXTERNAL_PULL_INTERVAL", 60*time.Second) // gentle: spread crawl, ~1/min
// Per-ref /status crawl. Each reflector's own /status gives its nodes AND its
// trunks, whose hosts branch out to more reflectors — so we crawl the graph.
externalStatusPorts = env("EXTERNAL_STATUS_PORTS", "") // per-server /status ports (comma array) to try beyond 443/80 — spec in /etc/default/nodelist; NO port baked in (svxref is often 8080 but owners vary, e.g. 8090)
externalStatusPath = env("EXTERNAL_STATUS_PATH", "/status")
externalProxyPath = env("EXTERNAL_PROXY_PATH", "/proxy_status.php") // dashboard's real per-ref status (nodes+trunks+trunks_UDP+routing)
externalDetailPath = env("EXTERNAL_DETAIL_PATH", "/geu/status/") // merged roster with full per-node detail (location/freq/sw), enriches route nodes
externalMaxRefs = envInt("EXTERNAL_MAX_REFS", 100) // crawl bound (cycle-safe), 0 = unlimited
externalStaleTTL = envDur("EXTERNAL_STALE_TTL", 5*time.Minute) // keep a reflector's last-good trunks this long when its probe times out — smooths flicker; 0 disables retention
// Federation seeds: reflector /status URLs that are NOT reachable via our own
// trunk topology — they don't advertise themselves in the mesh, so the crawl
// never finds them on its own (e.g. the Swedish Reflector-network core). Seed
// them explicitly; the recursive crawl then reads each one's nodes and follows
// its trunks like any other reflector. Comma-separated full /status URLs,
// set in the site config (FEDERATION_SEEDS env / svxdr.json federation.seeds).
// Empty = no seeding. There is deliberately NO built-in list: seeds are
// site-specific runtime config and must never be hardcoded here.
federationSeeds = env("FEDERATION_SEEDS", "") // no built-in seeds: configure per site, empty = no seeding
// Fas 2: federation gap detector. Periodically fetch the network-wide
// aggregate (sa2blv's reflector-network) and diff it against what our own
// recursive crawl covers, so we can see which reflectors/nodes we are missing
// and add them to FEDERATION_SEEDS. Read-only — never merged into the roster
// (our own fresh crawl/MQTT data stays the single source of truth). Empty URL
// disables the detector.
federationAggURL = env("FEDERATION_AGGREGATE_URL", "") // site config; empty disables the detector
federationAggInterval = envDur("FEDERATION_AGGREGATE_INTERVAL", 5*time.Minute)
debug = envBool("DEBUG", false)
)
// mqttState holds the latest node lists published per source on MQTT
// (<prefix>/nodes/local and <prefix>/nodes/<trunk-host>). Keyed src -> callsign -> raw.
var mqttState = struct {
mu sync.RWMutex
bySrc map[string]map[string]json.RawMessage
}{bySrc: map[string]map[string]json.RawMessage{}}
// mergeMqttNodes returns local nodes (authoritative) overlaid on every MQTT
// source's nodes, so trunk-side nodes (SE-EASTCOAST, SM-RO, …) appear too.
func mergeMqttNodes(local map[string]json.RawMessage) map[string]json.RawMessage {
merged := map[string]json.RawMessage{}
mqttState.mu.RLock()
for src, m := range mqttState.bySrc {
if src == "local" {
continue // HTTP /status is authoritative for local nodes
}
for cs, raw := range m {
merged[cs] = raw
}
}
mqttState.mu.RUnlock()
for cs, raw := range local {
merged[cs] = raw
}
return merged
}
// onNodesMsg handles a <prefix>/nodes/<src> message: {"nodes":[{callsign,...},...]}.
func onNodesMsg(topic string, payload []byte) {
src := topic[strings.LastIndex(topic, "/")+1:]
var p struct {
Nodes []json.RawMessage `json:"nodes"`
}
if err := json.Unmarshal(payload, &p); err != nil {
return
}
m := map[string]json.RawMessage{}
for _, raw := range p.Nodes {
var h struct {
Callsign string `json:"callsign"`
}
if json.Unmarshal(raw, &h) != nil || h.Callsign == "" {
continue
}
stored := raw
// Tag trunk-sourced nodes so the UI can tell them apart from local
// clients (only local clients can be blocked via NODE BLOCK).
if src != "local" {
var obj map[string]json.RawMessage
if json.Unmarshal(raw, &obj) == nil {
if sj, err := json.Marshal(src); err == nil {
obj["remoteTrunk"] = sj
if nb, err := json.Marshal(obj); err == nil {
stored = nb
}
}
}
}
m[h.Callsign] = stored
}
mqttState.mu.Lock()
mqttState.bySrc[src] = m
mqttState.mu.Unlock()
if debug {
log.Printf("DEBUG mqtt nodes src=%s count=%d", src, len(m))
}
}
func startMqtt() {
defer func() {
if r := recover(); r != nil {
log.Printf("mqtt goroutine panic: %v", r)
}
}()
topic := strings.TrimRight(mqttPrefix, "/") + "/nodes/+"
opts := mqtt.NewClientOptions().
AddBroker(mqttBroker).
SetClientID("nodelist").
SetUsername(mqttUser).
SetPassword(mqttPass).
SetAutoReconnect(true).
SetConnectRetry(true).
SetConnectRetryInterval(5 * time.Second).
SetOnConnectHandler(func(c mqtt.Client) {
if t := c.Subscribe(topic, 0, func(_ mqtt.Client, msg mqtt.Message) {
onNodesMsg(msg.Topic(), msg.Payload())
}); t.Wait() && t.Error() != nil {
log.Printf("mqtt subscribe failed: %v", t.Error())
} else {
log.Printf("mqtt subscribed: %s", topic)
}
}).
SetConnectionLostHandler(func(_ mqtt.Client, err error) {
log.Printf("mqtt connection lost: %v", err)
})
c := mqtt.NewClient(opts)
if t := c.Connect(); t.Wait() && t.Error() != nil {
log.Printf("mqtt connect failed: %v (broker=%s)", t.Error(), mqttBroker)
}
}
// ---------------------------------------------------------- cross-link mesh --
// xlinkMsg is one talker announcement on the cross-link topic. tg is always in
// the RECEIVER's numbering (the DMR side translates before publishing), so this
// side only ever deals in its own svx_tg.
type xlinkMsg struct {
Origin string `json:"origin"`
Network string `json:"network"` // dmr | svx | allstar (the SIDE, for routing/loop-guard)
Callsign string `json:"callsign"`
System string `json:"system"` // system/reflector name (e.g. East Coast Reflector), distinct from callsign
Trunk string `json:"trunk"` // trunk/link the talker enters through (e.g. KT4ROY-SAT1) → "callsign via trunk"
Node string `json:"node"` // source node/id (e.g. AllStar node 45225) → DMR src_id
TG int `json:"tg"`
Backend string `json:"backend"` // display backend: AllStar / SVXLink / trunk …
Event string `json:"event"` // start | end
TS int64 `json:"ts"`
}
var xlinkClient mqtt.Client
// xlinkStart connects the SVX side to the cross-link mesh (if enabled) and
// subscribes for peer talkers to inject into the SVX lastheard.
func xlinkStart(rdb *redis.Client) {
if !xlinkEnabled || xlinkBroker == "" {
return
}
if xlinkOrigin == "" {
h, _ := os.Hostname()
xlinkOrigin = "svx@" + h
}
opts := mqtt.NewClientOptions().
AddBroker(xlinkBroker).
SetClientID(fmt.Sprintf("nodelist-xlink-%d", time.Now().UnixNano())).
SetUsername(xlinkUser).
SetPassword(xlinkPass).
SetAutoReconnect(true).
SetConnectRetry(true).
SetConnectRetryInterval(5 * time.Second).
SetOnConnectHandler(func(c mqtt.Client) {
if t := c.Subscribe(xlinkTopic, 0, func(_ mqtt.Client, m mqtt.Message) {
xlinkOnMessage(rdb, m.Payload())
}); t.Wait() && t.Error() != nil {
log.Printf("xlink subscribe failed: %v", t.Error())
} else {
log.Printf("xlink subscribed: %s", xlinkTopic)
}
})
xlinkClient = mqtt.NewClient(opts)
xlinkClient.Connect()
log.Printf("xlink: mesh enabled broker=%s topic=%s origin=%s", xlinkBroker, xlinkTopic, xlinkOrigin)
}
// xlinkPublishTalker announces a local SVX talker to the mesh (own numbering).
// The DMR side gates on its TG map, so publishing every talker is harmless.
func xlinkPublishTalker(callsign, system, trunk, node string, tg int, backend, event string, ts int64) {
if xlinkClient == nil || tg <= 0 {
return
}
if backend == "" {
backend = "SVXLink"
}
b, _ := json.Marshal(xlinkMsg{Origin: xlinkOrigin, Network: "svx",
Callsign: callsign, System: system, Trunk: trunk, Node: node, TG: tg, Backend: backend, Event: event, TS: ts})
xlinkClient.Publish(xlinkTopic, 0, false, b)
}
// xlinkOnMessage injects a peer (DMR/AllStar) talker into the SVX lastheard.
// Own-origin and svx-origin messages are ignored (loop guard); injected rows go
// straight to Redis (not the reflector log), so they are never re-published.
func xlinkOnMessage(rdb *redis.Client, payload []byte) {
var m xlinkMsg
if json.Unmarshal(payload, &m) != nil {
return
}
if m.Origin == xlinkOrigin || m.Network == "svx" || m.Network == "" {
return
}
if m.Event != "start" || m.TG <= 0 {
return // show on start; the DMR side is authoritative on gating/TG
}
ts := m.TS
if ts == 0 {
ts = time.Now().Unix()
}
backend := m.Backend
if backend == "" {
backend = "DMR"
}
writeLastheardEvent(rdb, lastheardEvent{
Ts: ts, Callsign: m.Callsign, TG: m.TG, Duration: 1,
Source: "crosslink", Network: backend,
// Location = the TG's reflector name (e.g. 314093 -> "WHITELIGHTNING"), so a
// cross-linked DMR talker gets the same Location label as an AllStar talker
// on that TG. From nodes[].system (svx_sat.conf), via locForTG's fallback.
Location: locForTG(m.TG),
})
}
// extTrunkRef is one trunk as owned by a specific server: its name, the host on
// the other end, and whether it's up. Keeping ownership lets the topology graph
// draw the real server->server links instead of hanging everything on center.
type extTrunkRef struct {
Name string `json:"name"`
Host string `json:"host"`
Connected bool `json:"connected"`
Kind string `json:"kind"` // "trunk" | "udp" | "satellite"
}
// extSource is one reflector (server) in the network: the nodes connected to it
// and the trunks it owns. IP/HTTPPort/Aliases feed the admin info box.
type extSource struct {
Tag string `json:"tag"`
URL string `json:"url"`
OK bool `json:"ok"`
Version string `json:"version,omitempty"`
Mode string `json:"mode,omitempty"`
IP string `json:"ip,omitempty"`
HTTPPort string `json:"http_port,omitempty"`
Aliases []string `json:"aliases,omitempty"` // other hostnames resolving to the same IP
Nodes map[string]json.RawMessage `json:"nodes"`
Trunks []extTrunkRef `json:"trunks"`
Routing []routeEntry `json:"-"` // this ref's Routing_table (callsign -> trunk IP), for node attribution
Detail map[string]json.RawMessage `json:"-"` // merged-roster per-node detail, to enrich route nodes
}
// externalState holds the latest network-wide picture pulled from
// EXTERNAL_PULL_URL: sources (each with its grouped nodes, sorted by tag) and
// the raw trunks map covering the whole network.
var externalState = struct {
mu sync.RWMutex
sources []extSource // sorted by Tag
trunks json.RawMessage // raw {name: trunkobj} map
routing map[string]string // trunk IP -> callsign (from reflectors' Routing_table)
generated string
lastOKAt time.Time
lastErr string
// Network-wide bridge directory: every crawled reflector's OWN bridges
// (its /api/bridge-nodes), unioned. Lets any box show "connected to
// <system> (node)" for a bridge that lives on another server, derived
// purely from the topology it already crawls — no per-box config.
bridgeNodes map[string]bridgeInfo // UPPER(via) -> {node, system}
}{}
// pullSource is one reflector as listed in the topology (svx_externalpull.php).
// Its URL is the owner-published status endpoint we read directly for nodes.
type pullSource struct {
Tag string `json:"tag"`
URL string `json:"url"`
OK bool `json:"ok"`
Version string `json:"version"`
Mode string `json:"mode"`
}
// externalPull mirrors the fields we consume from svx_externalpull.php. It is
// used purely as the TOPOLOGY (the list of reflectors + the network trunk list);
// per-ref nodes are read directly from each reflector's own /status, not taken
// from this aggregate.
type externalPull struct {
Generated string `json:"generated"`
Sources []pullSource `json:"sources"`
Trunks json.RawMessage `json:"trunks"`
}
// uplinkHostsFromConf parses svxreflector.conf and returns the trunk-peer hosts
// that form this reflector's uplink(s):
//
// [ReflectorTrunk] Peers=SE-AUX,... -> [TrunkPeer#SE-AUX] Host=<host>
//
// It also handles the twin2 form where each trunk is its own [TRUNK_*] section
// with the peer HOST inline. No address is baked into the binary; the uplink
// follows the config. Note the peer's Port= is the trunk protocol port
// (5500/5302), NOT the HTTP topology port, so it is deliberately ignored here.
func uplinkHostsFromConf(path string) []string {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var peers []string
hostByPeer := map[string]string{}
var directHosts []string // twin2 [TRUNK_*] sections carry the peer host inline
curPeer := "" // current [TrunkPeer#<peer>] section
inTrunk := false // inside [ReflectorTrunk]
inTrunkSec := false // inside a twin2 [TRUNK_*] section
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
sec := line[1 : len(line)-1]
inTrunk = strings.EqualFold(sec, "ReflectorTrunk")
inTrunkSec = strings.HasPrefix(strings.ToUpper(sec), "TRUNK_")
curPeer = ""
if i := strings.IndexByte(sec, '#'); i >= 0 && strings.EqualFold(sec[:i], "TrunkPeer") {
curPeer = sec[i+1:]
}
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
key := strings.TrimSpace(line[:eq])
val := strings.TrimSpace(line[eq+1:])
switch {
case inTrunk && strings.EqualFold(key, "Peers"):
for _, p := range strings.Split(val, ",") {
if p = strings.TrimSpace(p); p != "" {
peers = append(peers, p)
}
}
case curPeer != "" && strings.EqualFold(key, "Host"):
hostByPeer[curPeer] = val
case inTrunkSec && strings.EqualFold(key, "Host"):
directHosts = append(directHosts, val)
}
}
var hosts []string
seen := map[string]struct{}{}
add := func(h string) {
if h == "" {
return
}
if _, ok := seen[h]; ok {
return
}
seen[h] = struct{}{}
hosts = append(hosts, h)
}
for _, p := range peers {
add(hostByPeer[p])
}
for _, h := range directHosts {
add(h)
}
return hosts
}
// satTGsFromConf parses chan_svx_sat's config (svx_sat.conf) and groups every
// ENABLED entry by its satellite_id, returning the talkgroups (per-TG callsign +
// tg) each satellite carries. Several entries sharing a satellite_id are the SAME
// trunk with several TGs, so they are grouped — not collapsed to the last callsign.
func satTGsFromConf(path string) map[string][]satTG {
out := map[string][]satTG{}
f, err := os.Open(path)
if err != nil {
return out
}
defer f.Close()
var satID, call, tg string
flush := func() {
if satID != "" {
out[satID] = append(out[satID], satTG{Name: call, TG: tg})
}
satID, call, tg = "", "", ""
}
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, "]") {
flush()
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
switch strings.ToLower(strings.TrimSpace(line[:eq])) {
case "satellite_id":
satID = strings.TrimSpace(line[eq+1:])
case "callsign":
call = strings.TrimSpace(line[eq+1:])
case "tg":
tg = strings.TrimSpace(line[eq+1:])
}
}
flush()
return out
}
// satelliteIDsFromReflectorConf parses svxreflector.conf's [SATELLITE] section and
// returns the configured satellite ids (the <id> in each SECRET_<id>=… line). Only
// the id is read — the secret value is never touched or exposed. Lets the Trunks
// view list every configured satellite (online or offline) fully dynamically.
func satelliteIDsFromReflectorConf(path string) []string {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
var ids []string
seen := map[string]bool{}
inSat := false
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, "]") {
inSat = strings.EqualFold(line, "[SATELLITE]")
continue
}
if !inSat {
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
key := strings.TrimSpace(line[:eq])
if len(key) > 7 && strings.EqualFold(key[:7], "SECRET_") {
if id := key[7:]; id != "" && !seen[id] {
seen[id] = true
ids = append(ids, id)
}
}
}
return ids
}
// satNamesFromConf parses chan_svx_sat's config (svx_sat.conf) and returns a map
// from each ENABLED satellite trunk's satellite_id (e.g. "sat403872") to its
// friendly callsign (e.g. "KT4ROY-SAT1"). Commented-out (disabled) sections and
// keys are skipped, so only enabled sat trunks are surfaced. Empty if unreadable.
func satNamesFromConf(path string) map[string]string {
out := map[string]string{}
f, err := os.Open(path)
if err != nil {
return out
}
defer f.Close()
var satID, call string
flush := func() {
if satID != "" && call != "" {
out[satID] = call
}
satID, call = "", ""
}
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, ";") || strings.HasPrefix(line, "#") {
continue // blank or disabled/commented → skip
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
flush() // section boundary — commit the previous section
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
switch strings.ToLower(strings.TrimSpace(line[:eq])) {
case "satellite_id":
satID = strings.TrimSpace(line[eq+1:])
case "callsign":
call = strings.TrimSpace(line[eq+1:])
}
}
flush()
return out
}
// externalPullURLs returns the topology URL(s) to fetch. EXTERNAL_PULL_URL wins
// if set; otherwise one URL per uplink host derived from the conf. Neither host
// nor port is ever baked in — port comes from EXTERNAL_PULL_PORT (empty = scheme
// default) because it varies per system/trunk.
func externalPullURLs() []string {
if externalPullURL != "" {
return []string{externalPullURL}
}
hosts := uplinkHostsFromConf(svxreflectorConf)
scheme := externalPullScheme
if scheme == "" {
scheme = "https"
}
path := externalPullPath
if !strings.HasPrefix(path, "/") {
path = "/" + path
}
urls := make([]string, 0, len(hosts))
for _, h := range hosts {
host := h
if externalPullPort != "" {
host = net.JoinHostPort(h, externalPullPort)
}
urls = append(urls, fmt.Sprintf("%s://%s%s", scheme, host, path))
}
return urls
}
// fetchExternalPull GETs and parses one topology endpoint (svx_externalpull.php).
func fetchExternalPull(client *http.Client, u string) (externalPull, error) {
var p externalPull
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
return p, err
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return p, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
_, _ = io.Copy(io.Discard, resp.Body)
return p, fmt.Errorf("http %d", resp.StatusCode)
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return p, err
}
if err := json.Unmarshal(body, &p); err != nil {
return p, fmt.Errorf("parse: %w", err)
}
return p, nil
}
// hostFromURL extracts the host (no port) from a URL, "" on failure.
func hostFromURL(raw string) string {
if raw == "" {
return ""
}
u, err := url.Parse(raw)
if err != nil {
return ""
}
return u.Hostname()
}
// normalizeHost lowercases and strips scheme/port for dedupe keying.
func normalizeHost(h string) string {
h = strings.TrimSpace(strings.ToLower(h))
if h == "" {
return ""
}
if strings.Contains(h, "://") {
if hh := hostFromURL(h); hh != "" {
return strings.ToLower(hh)
}
}
if host, _, err := net.SplitHostPort(h); err == nil {
return host
}
return h
}
// resolveCache memoises hostname -> primary IP so the same host isn't looked up
// twice within a crawl.
var (
resolveMu sync.Mutex
resolveCache = map[string]string{}
)
// refCacheEntry is one reflector's last SUCCESSFUL crawl contribution, retained so
// a single timed-out probe (or a briefly-unreachable subtree) doesn't erase its
// trunks. Keyed by hostKey — the same dedup key the crawl uses for `visited`.
type refCacheEntry struct {
src extSource // the reflector's extSource incl. its owned trunk refs
trunks map[string]json.RawMessage // raw trunk/udp/satellite blobs it contributed (name -> raw)
routing []routeEntry // its Routing_table entries (trunk IP -> callsign)
at time.Time // when this reflector was last read OK
}
// refCache holds last-known-good per reflector so crawlNetwork can resurrect a
// reflector whose probe failed this cycle, until it's been unreachable for
// externalStaleTTL. Touched only in the single-threaded fold, never by probe
// goroutines; the mutex is belt-and-suspenders.
var (
refCacheMu sync.Mutex
refCache = map[string]refCacheEntry{}
)
// putRefCache stores a reflector's just-read good contribution, stamped now.
func putRefCache(key string, e refCacheEntry) {
e.at = time.Now()
refCacheMu.Lock()
refCache[key] = e
refCacheMu.Unlock()
}
// freshRefCache returns a reflector's retained contribution if present and read
// within externalStaleTTL; stale entries are pruned so the map stays bounded.
func freshRefCache(key string) (refCacheEntry, bool) {
if key == "" || externalStaleTTL <= 0 {
return refCacheEntry{}, false
}
refCacheMu.Lock()
defer refCacheMu.Unlock()
e, ok := refCache[key]
if !ok {
return refCacheEntry{}, false
}
if time.Since(e.at) > externalStaleTTL {
delete(refCache, key)
return refCacheEntry{}, false
}
return e, true
}
// hostKey returns a dedup key for a host: its resolved IP when possible (so two
// hostnames for the same physical reflector — e.g. sm2ampr.net and
// reflector-network.se both 44.5.25.40 — collapse to one), else the bare host.
func hostKey(host string) string {
h := normalizeHost(host)
if h == "" {
return ""
}
resolveMu.Lock()
if v, ok := resolveCache[h]; ok {
resolveMu.Unlock()
return v
}
resolveMu.Unlock()
key := h
if ips, err := net.LookupHost(h); err == nil && len(ips) > 0 {
sort.Strings(ips)
key = ips[0]
}
resolveMu.Lock()
resolveCache[h] = key
resolveMu.Unlock()
return key
}
// isIPStr reports whether h is a bare IP literal.
func isIPStr(h string) bool {
return net.ParseIP(strings.Trim(h, "[]")) != nil
}
// trunkHost reads the "host" field from a trunk status object.
func trunkHost(raw json.RawMessage) string {
var t struct {
Host string `json:"host"`
}
if json.Unmarshal(raw, &t) != nil {
return ""
}
return strings.TrimSpace(t.Host)
}
// trunkConnected reads the "connected" flag from a trunk object.
func trunkConnected(raw json.RawMessage) bool {
var t struct {
Connected bool `json:"connected"`
}
_ = json.Unmarshal(raw, &t)
return t.Connected
}
func ensureSlash(p string) string {
if !strings.HasPrefix(p, "/") {
return "/" + p
}
return p
}
// statusCandidates returns the ordered status URLs to try for a reflector. The
// dashboard's proxy_status.php is tried FIRST — it exposes the real, full status
// (nodes + trunks + trunks_UDP + Routing_table); a raw /status can be empty on
// hub reflectors. The HTTP port varies per owner and isn't published, so we try
// 443 (https) and 80 (http) — common behind a proxy — plus each configured extra
// port (EXTERNAL_STATUS_PORTS, spec'd per server — no port assumed), with proxy + /status.
func statusCandidates(host, publishedURL string, ports []string) []string {
h := host
if h == "" {
h = hostFromURL(publishedURL)
}
proxy := ensureSlash(externalProxyPath)
stat := ensureSlash(externalStatusPath)
var c []string
if h != "" {
c = append(c, "https://"+h+proxy) // proxy_status.php over 443 first
}
if publishedURL != "" {
c = append(c, publishedURL)
}
if h != "" {
// 443 (https) and 80 (http) — common behind nginx/apache
c = append(c, "https://"+h+stat, "http://"+h+proxy, "http://"+h+stat)
// ports learned from the network (each svxref's http_port) + configured seeds
for _, p := range ports {
p = strings.TrimSpace(p)
if p == "" {
continue
}
hp := net.JoinHostPort(h, p)
c = append(c, "http://"+hp+proxy, "http://"+hp+stat)
}
}
return c
}
// refStatus is one reflector's own status: its nodes, its named trunks, its UDP
// trunk peers, and the HTTP port it reports for itself (http_port) — the crawl
// learns real ports from the network instead of guessing.
type routeEntry struct {
Callsign string
Trunk string // the trunk IP this callsign is reachable via
TG int
MonitoredTGs []int
}
type refStatus struct {
Nodes map[string]json.RawMessage
Trunks map[string]json.RawMessage
TrunksUDP []json.RawMessage
Satellites map[string]json.RawMessage // id -> full satellite obj (incl. its nodes[])
HTTPPort string
Version string
Mode string
Routing []routeEntry
Detail map[string]json.RawMessage // full per-node detail from the merged roster (enriches route nodes)
}
// jsonToPort reads http_port whether emitted as a string ("8085") or a number.
func jsonToPort(raw json.RawMessage) string {
var s string
if json.Unmarshal(raw, &s) == nil {
return strings.TrimSpace(s)
}
var n json.Number
if json.Unmarshal(raw, &n) == nil {
return n.String()
}
return ""
}
// parseNodes accepts a reflector's "nodes" either as an object keyed by callsign
// or as an array of node objects (different reflectors/versions emit both), and
// returns a callsign-keyed map.
func parseNodes(raw json.RawMessage) map[string]json.RawMessage {
var m map[string]json.RawMessage
if json.Unmarshal(raw, &m) == nil {
return m
}
var arr []json.RawMessage
if json.Unmarshal(raw, &arr) == nil {
out := make(map[string]json.RawMessage, len(arr))
for _, r := range arr {
var h struct {
Callsign string `json:"callsign"`
}
if json.Unmarshal(r, &h) == nil && h.Callsign != "" {
out[h.Callsign] = r
}
}
return out
}
return map[string]json.RawMessage{}
}
// parseRefStatus parses a /status body into nodes + trunks. Requires a top-level
// "nodes" key so a stray 200 (HTML/redirect page) is rejected.
func parseRefStatus(body []byte) (refStatus, bool) {
var top map[string]json.RawMessage
if json.Unmarshal(body, &top) != nil {
return refStatus{}, false
}
if _, ok := top["nodes"]; !ok {
return refStatus{}, false
}
rs := refStatus{Nodes: parseNodes(top["nodes"]), Trunks: map[string]json.RawMessage{}}
if raw, ok := top["trunks"]; ok {
var tr map[string]json.RawMessage
if json.Unmarshal(raw, &tr) == nil {
rs.Trunks = tr
}
}
if raw, ok := top["trunks_UDP"]; ok {
var arr []json.RawMessage
if json.Unmarshal(raw, &arr) == nil {
rs.TrunksUDP = arr
}
}
if raw, ok := top["satellites"]; ok {
var m map[string]json.RawMessage
if json.Unmarshal(raw, &m) == nil {
rs.Satellites = m
}
}
if raw, ok := top["http_port"]; ok {
rs.HTTPPort = jsonToPort(raw)
}
if raw, ok := top["version"]; ok {
_ = json.Unmarshal(raw, &rs.Version)
}
if raw, ok := top["mode"]; ok {
_ = json.Unmarshal(raw, &rs.Mode)
}
if raw, ok := top["Routing_table"]; ok {
var arr []struct {
Callsign string `json:"callsign"`
Trunk string `json:"trunk"`
TG int `json:"tg"`
TGMonitor []int `json:"tg_monitor"`
}
if json.Unmarshal(raw, &arr) == nil {
for _, e := range arr {
if e.Callsign != "" && e.Trunk != "" {
rs.Routing = append(rs.Routing, routeEntry{
Callsign: e.Callsign, Trunk: strings.TrimSpace(e.Trunk),
TG: e.TG, MonitoredTGs: e.TGMonitor,
})
}
}
}
}
return rs, true
}
// normalizeUDPTrunk turns a trunks_UDP entry into a uniform trunk object: it
// maps status->connected so the same renderer works for named + UDP trunks, and
// returns the peer name and host for dedup/branching.
func normalizeUDPTrunk(raw json.RawMessage) (name string, out json.RawMessage, host string) {
var m map[string]json.RawMessage
if json.Unmarshal(raw, &m) != nil {
return "", raw, ""
}
if b, ok := m["name"]; ok {
_ = json.Unmarshal(b, &name)
}
if b, ok := m["host"]; ok {
_ = json.Unmarshal(b, &host)
}
if st, ok := m["status"]; ok {
if _, has := m["connected"]; !has {
m["connected"] = st
}
}
if b, err := json.Marshal(m); err == nil {
out = b
} else {
out = raw
}
return name, out, strings.TrimSpace(host)
}
// readRefStatus tries each candidate URL until one returns a parseable /status.
// fetchDetailRoster fetches the reflector's MERGED node roster (full per-node
// detail: location, freqs, sw, sysop) from externalDetailPath derived off the
// working status URL (same scheme+host, different path). proxy_status.php only
// lists a UDP-peer member's callsign in Routing_table, so this fills in detail.
// Best-effort; returns nil on any failure (route nodes then stay sparse).
func fetchDetailRoster(client *http.Client, usedURL string) map[string]json.RawMessage {
if externalDetailPath == "" || usedURL == "" {
return nil
}
u, err := url.Parse(usedURL)
if err != nil {
return nil
}
u.Path = ensureSlash(externalDetailPath)
u.RawQuery = ""
req, err := http.NewRequest(http.MethodGet, u.String(), nil)
if err != nil {
return nil
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
return nil
}
body, rerr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
resp.Body.Close()
if rerr != nil || resp.StatusCode != 200 {
return nil
}
var top map[string]json.RawMessage
if json.Unmarshal(body, &top) != nil {
return nil
}
nm := parseNodes(top["nodes"])
if len(nm) == 0 {
return nil
}
return nm
}
func readRefStatus(client *http.Client, host, publishedURL string, ports []string) (rs refStatus, usedURL string, ok bool) {
for _, u := range statusCandidates(host, publishedURL, ports) {
req, err := http.NewRequest(http.MethodGet, u, nil)
if err != nil {
continue
}
req.Header.Set("Accept", "application/json")
resp, err := client.Do(req)
if err != nil {
continue
}
body, rerr := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
resp.Body.Close()
if rerr != nil || resp.StatusCode != 200 {
continue
}
if r, parsed := parseRefStatus(body); parsed {
// A routing table means members reachable via UDP peers whose detail the
// primary endpoint omits — pull the merged roster to enrich them.
if len(r.Routing) > 0 {
if d := fetchDetailRoster(client, u); d != nil {
r.Detail = d
}
}
return r, u, true
}
}
return refStatus{Nodes: map[string]json.RawMessage{}}, "", false
}
type crawlSeed struct{ tag, host, url string }
// crawlNetwork discovers reflectors from the uplink topology, then reads each
// reflector's own proxy_status.php for its nodes AND its trunks, following those
// trunks' hosts out to further reflectors. Breadth-first by level with bounded
// concurrency; cycle-safe (visited hosts), bounded (EXTERNAL_MAX_REFS, depth),
// best-effort (unreachable refs listed OK=false, bare-IP trunk hosts skipped).
func crawlNetwork(client *http.Client) (sources []extSource, trunks map[string]json.RawMessage, routing map[string]string, generated string, err error) {
topoURLs := externalPullURLs()
trunks = map[string]json.RawMessage{}
routing = map[string]string{}
visited := map[string]struct{}{}
// Enabled satellite trunks, satellite_id -> friendly callsign (KT4ROY-SAT1),
// read from this box's chan_svx_sat config. Used to show the sat trunk under
// its real name instead of the raw reflector id (sat403872).
satNames := satNamesFromConf(svxSatConf)
hostsByKey := map[string]map[string]struct{}{} // IP key -> hostnames seen for it
var frontier []crawlSeed
lastErr := ""
topoOK := false
recordHost := func(h string) {
h = normalizeHost(h)
if h == "" || isIPStr(h) {
return
}
k := hostKey(h)
if k == "" {
return
}
if hostsByKey[k] == nil {
hostsByKey[k] = map[string]struct{}{}
}
hostsByKey[k][h] = struct{}{}
}
addTrunk := func(name string, raw json.RawMessage) {
if name == "" {
return
}
if _, dup := trunks[name]; !dup {
trunks[name] = raw
}
}
enqueue := func(next *[]crawlSeed, s crawlSeed) {
recordHost(s.host)
recordHost(hostFromURL(s.url))
key := hostKey(s.host)
if key == "" {
key = hostKey(hostFromURL(s.url))
}
if key == "" {
return
}
if _, seen := visited[key]; seen {
return
}
*next = append(*next, s)
}
// replayCached folds a reflector's retained last-good contribution into this
// cycle's result set (routing, raw trunks, source) and re-seeds its trunk hosts
// so the subtree it fronts is still followed. Used when its probe timed out.
replayCached := func(e refCacheEntry, next *[]crawlSeed) {
for _, en := range e.routing {
if _, ok := routing[en.Trunk]; !ok {
routing[en.Trunk] = en.Callsign
}
}
for name, raw := range e.trunks {
addTrunk(name, raw)
}
for _, t := range e.src.Trunks {
if t.Host != "" {
enqueue(next, crawlSeed{tag: t.Host, host: t.Host})
}
}
sources = append(sources, e.src)
}
// Seed from the local reflector's OWN live status: every link it reports —
// TCP trunks and UDP trunk peers alike — carries the peer host in runtime
// data. No trunk mechanism is derived from config-format knowledge here; the
// reflector itself is the source of truth for what it is linked to.
if body, _, ferr := fetchReflectorStatus(reflectorURL); ferr == nil {
if rs, ok := parseRefStatus(body); ok {
for _, raw := range rs.Trunks {
if h := trunkHost(raw); h != "" {
enqueue(&frontier, crawlSeed{tag: h, host: h})
}
}
for _, raw := range rs.TrunksUDP {
if _, _, h := normalizeUDPTrunk(raw); h != "" {
enqueue(&frontier, crawlSeed{tag: h, host: h})
}
}
}
}
// Seed the crawl with the configured federation reflectors (the Swedish core
// etc). They are not in our trunk topology, so without this the crawl never
// reaches them; once seeded, the recursive crawl reads each one's /status
// (its nodes) and follows its trunks like any other reflector.
for _, su := range strings.Split(federationSeeds, ",") {
if su = strings.TrimSpace(su); su != "" {
enqueue(&frontier, crawlSeed{tag: su, host: hostFromURL(su), url: su})
}
}
if len(topoURLs) == 0 && len(frontier) == 0 {
return nil, nil, nil, "", fmt.Errorf("no uplink derived from %s or %s", svxreflectorConf, reflectorURL)
}
// Seed from the topology aggregate: the uplink host itself, its sources
// (owner-published url), and its trunks (recorded + branched).
for _, tu := range topoURLs {
p, e := fetchExternalPull(client, tu)
if e != nil {
lastErr = e.Error()
continue
}
topoOK = true
if p.Generated != "" {
generated = p.Generated
}
if uh := hostFromURL(tu); uh != "" {
enqueue(&frontier, crawlSeed{tag: uh, host: uh})
}
for _, s := range p.Sources {
enqueue(&frontier, crawlSeed{tag: s.Tag, host: hostFromURL(s.URL), url: s.URL})
}
var tr map[string]json.RawMessage
if len(p.Trunks) > 0 && json.Unmarshal(p.Trunks, &tr) == nil {
for name, raw := range tr {
addTrunk(name, raw)
if h := trunkHost(raw); h != "" {
enqueue(&frontier, crawlSeed{tag: h, host: h})
}
}
}
}
if !topoOK && len(frontier) == 0 {
return nil, nil, nil, "", fmt.Errorf("topology unreachable: %s", lastErr)
}
// Ports to try per host: seeded from the per-server config array (empty by
// default — nothing baked in), plus our own reflector's port, then grown with
// every real http_port a reached svxref reports (data-driven, no assumption).
learnedPorts := map[string]struct{}{}
for _, p := range strings.Split(externalStatusPorts, ",") {
if p = strings.TrimSpace(p); p != "" {
learnedPorts[p] = struct{}{}
}
}
// The port our OWN reflector serves /status on (from REFLECTOR_STATUS_URL)
// is a natural candidate for peers running the same software.
if u, uerr := url.Parse(reflectorURL); uerr == nil {
if p := u.Port(); p != "" {
learnedPorts[p] = struct{}{}
}
}
const maxDepth = 4
for depth := 0; depth < maxDepth && len(frontier) > 0; depth++ {
ports := make([]string, 0, len(learnedPorts))
for p := range learnedPorts {
ports = append(ports, p)
}
sort.Strings(ports)
// Claim this level's hosts (dedup vs visited, respect the ref cap).
var batch []crawlSeed
for _, s := range frontier {
key := hostKey(s.host)
if key == "" {
key = hostKey(hostFromURL(s.url))
}
if key == "" {
continue
}
if _, seen := visited[key]; seen {
continue
}
if externalMaxRefs > 0 && len(visited) >= externalMaxRefs {
log.Printf("external crawl: hit EXTERNAL_MAX_REFS=%d — some refs not read", externalMaxRefs)
break
}
visited[key] = struct{}{}
batch = append(batch, s)
}
if len(batch) == 0 {
break
}
// Read the whole level concurrently (bounded).
type readResult struct {
seed crawlSeed
src extSource
rs refStatus
}
results := make([]readResult, len(batch))
sem := make(chan struct{}, 10)
var wg sync.WaitGroup
for i, s := range batch {
wg.Add(1)
go func(i int, s crawlSeed) {
defer wg.Done()
sem <- struct{}{}
defer func() { <-sem }()
rs, usedURL, ok := readRefStatus(client, s.host, s.url, ports)
results[i] = readResult{
seed: s,
src: extSource{Tag: s.tag, URL: usedURL, OK: ok, Version: rs.Version, Mode: rs.Mode, Nodes: rs.Nodes, Routing: rs.Routing, Detail: rs.Detail},
rs: rs,
}
}(i, s)
}
wg.Wait()
// Fold results single-threaded: record trunks (global + per owning server),
// then branch to the next level.
var next []crawlSeed
for _, r := range results {
// Learn this reflector's real HTTP port for the next levels.
if r.rs.HTTPPort != "" {
learnedPorts[r.rs.HTTPPort] = struct{}{}
}
key := hostKey(r.seed.host)
if key == "" {
key = hostKey(hostFromURL(r.seed.url))
}
// Probe failed this cycle: rather than dropping this reflector (and all
// its trunks) — the flicker cause — fall back to its retained last-good
// contribution if still fresh, and keep branching from it.
if !r.src.OK {
if e, ok := freshRefCache(key); ok {
replayCached(e, &next)
continue
}
// no fresh cache — record the empty source as before
}
// Accumulate the routing table (trunk IP -> callsign) for self/identity tagging.
for _, e := range r.rs.Routing {
if _, ok := routing[e.Trunk]; !ok {
routing[e.Trunk] = e.Callsign
}
}
src := r.src
src.HTTPPort = r.rs.HTTPPort
src.IP = hostKey(r.seed.host)
if src.IP == "" {
src.IP = hostKey(hostFromURL(r.seed.url))
}
if !isIPStr(src.IP) {
src.IP = "" // unresolved host — no IP to show
}
owned := []extTrunkRef{} // never nil → never serialized as JSON null
contrib := map[string]json.RawMessage{} // this ref's own raw blobs, kept for retention
for name, raw := range r.rs.Trunks {
addTrunk(name, raw)
contrib[name] = raw
h := trunkHost(raw)
owned = append(owned, extTrunkRef{Name: name, Host: h, Connected: trunkConnected(raw), Kind: "trunk"})
if h != "" {
enqueue(&next, crawlSeed{tag: h, host: h})
}
}
for _, raw := range r.rs.TrunksUDP {
name, norm, h := normalizeUDPTrunk(raw)
if name == "" {
continue
}
addTrunk(name, norm)
contrib[name] = norm
owned = append(owned, extTrunkRef{Name: name, Host: h, Connected: trunkConnected(norm), Kind: "udp"})
if h != "" {
enqueue(&next, crawlSeed{tag: h, host: h})
}
}
for id, sraw := range r.rs.Satellites {
var meta struct {
Authenticated bool `json:"authenticated"`
}
_ = json.Unmarshal(sraw, &meta)
// Show the ENABLED sat trunk under its real callsign (KT4ROY-SAT1)
// from this box's chan_svx_sat config, not the raw reflector id
// (sat403872). Not in the config → keep the raw id as a fallback.
name := satNames[id]
if name == "" {
name = id
}
addTrunk(name, sraw) // so the flat list captures the satellite's nodes[]
contrib[name] = sraw
owned = append(owned, extTrunkRef{Name: name, Connected: meta.Authenticated, Kind: "satellite"})
}
src.Trunks = owned
sources = append(sources, src)
// Retain this good read so a future timed-out cycle can resurrect it.
if r.src.OK && key != "" && externalStaleTTL > 0 {
putRefCache(key, refCacheEntry{src: src, trunks: contrib, routing: r.rs.Routing})
}
}
frontier = next
}
// Resurrection pass: reflectors reachable recently but not crawled AT ALL this
// cycle (their only path in went through a reflector that timed out, so they
// were never enqueued) keep their retained last-good trunks until the staleness
// window expires. Reflectors already handled above are in `visited` → skipped,
// so nothing is duplicated.
if externalStaleTTL > 0 {
refCacheMu.Lock()
var revive []refCacheEntry
for k, e := range refCache {
if time.Since(e.at) > externalStaleTTL {
delete(refCache, k) // prune so the cache stays bounded
continue
}
if _, seen := visited[k]; seen {
continue
}
revive = append(revive, e)
}
refCacheMu.Unlock()
var discard []crawlSeed // last-known picture — do not branch further
for _, e := range revive {
replayCached(e, &discard)
}
}
// Attach alias hostnames: every other hostname that resolves to this ref's IP
// (e.g. reflector-network.se also answers as sm2ampr.net). Done after the crawl
// so all hostnames have been recorded.
for i := range sources {
key := sources[i].IP
if key == "" {
key = hostKey(sources[i].Tag)
}
primary := normalizeHost(sources[i].Tag)
for h := range hostsByKey[key] {
if h != primary {
sources[i].Aliases = append(sources[i].Aliases, h)
}
}
sort.Strings(sources[i].Aliases)
}
// Keep ALL discovered reflectors (online + firewalled/offline). The offline
// ones must stay so Topology can show them; Monitor Nodes filters to online
// in the frontend.
sort.Slice(sources, func(i, j int) bool { return sources[i].Tag < sources[j].Tag })
return sources, trunks, routing, generated, nil
}
// bridgeHTTP fetches other reflectors' /api/bridge-nodes during the crawl. Skips
// TLS verification: this is a best-effort read of a public bridge directory
// across many independently-operated servers (varied/self-signed certs).
var bridgeHTTP = &http.Client{
Timeout: 2500 * time.Millisecond,
Transport: &http.Transport{TLSClientConfig: &tls.Config{InsecureSkipVerify: true}},
}
// crawlBridgeNodes fetches each crawled reflector's OWN /api/bridge-nodes and
// unions them into a network-wide bridge directory. A host that doesn't run
// svxportal (no endpoint / 404 / timeout) is simply skipped. Every host comes
// from the topology the crawl already produced — nothing is hard-coded.
func crawlBridgeNodes(sources []extSource) map[string]bridgeInfo {
out := map[string]bridgeInfo{}
seen := map[string]bool{}
var wg sync.WaitGroup
var mu sync.Mutex
for _, s := range sources {
host := hostFromURL(s.URL) // status URL is often empty here...
if host == "" {
host = s.IP // ...so fall back to the reflector IP (topology-derived)
}
if host == "" || seen[host] {
continue
}
seen[host] = true
hp := host
if strings.Contains(host, ":") {
hp = "[" + host + "]" // bracket IPv6 literals
}
wg.Add(1)
go func(hp string) { // parallel: whole crawl bounded by one client timeout
defer wg.Done()
resp, err := bridgeHTTP.Get("https://" + hp + "/svxportal/api/bridge-nodes")
if err != nil {
return
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 256<<10))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
}
var m map[string]bridgeInfo
if json.Unmarshal(body, &m) != nil {
return
}
mu.Lock()
for via, bi := range m {
if bi.Node != "" {
out[strings.ToUpper(via)] = bi
}
}
mu.Unlock()
}(hp)
}
wg.Wait()
return out
}
// externalPuller periodically crawls the network (topology → per-ref /status →
// follow trunks) and stores the result in externalState. Runs as its own
// goroutine, off the status-serving hot path. Re-derives the uplink from the
// conf each cycle so a peer change is followed without a restart.
func externalPuller() {
defer func() {
if r := recover(); r != nil {
log.Printf("externalPuller panic: %v", r)
}
}()
client := &http.Client{Timeout: 2500 * time.Millisecond} // per request; crawl is parallel + best-effort, off the hot path. Higher = fewer transient timeouts (last-good retention covers the rest)
loggedNoUplink := false
pullOnce := func() {
if len(externalPullURLs()) == 0 {
if !loggedNoUplink {
log.Printf("external crawl: no uplink host in %s and no EXTERNAL_PULL_URL set; disabled", svxreflectorConf)
loggedNoUplink = true
}
return
}
loggedNoUplink = false
sources, trunks, routing, generated, err := crawlNetwork(client)
if err != nil {
setExternalErr(err.Error())
return
}
trunksRaw, _ := json.Marshal(trunks)
totalNodes := 0
for _, s := range sources {
totalNodes += len(s.Nodes)
}
// Piggyback on the crawl: fetch each discovered reflector's OWN bridge
// directory and union it. Hosts come from the topology → nothing is
// hard-coded. Best-effort; keep last-good on an empty round.
bridges := crawlBridgeNodes(sources)
externalState.mu.Lock()
externalState.sources = sources
externalState.trunks = trunksRaw
externalState.routing = routing
externalState.generated = generated
externalState.lastOKAt = time.Now()
externalState.lastErr = ""
if len(bridges) > 0 {
externalState.bridgeNodes = bridges
}
externalState.mu.Unlock()
if debug {
log.Printf("DEBUG external crawl: refs=%d nodes=%d trunks=%d", len(sources), totalNodes, len(trunks))
}
}
pullOnce()
t := time.NewTicker(externalPullInterval)
defer t.Stop()
for range t.C {
pullOnce()
}
}
func setExternalErr(msg string) {
externalState.mu.Lock()
externalState.lastErr = msg
externalState.mu.Unlock()
if debug {
log.Printf("DEBUG external pull failed: %s (url=%s)", msg, externalPullURL)
}
}
// tglistPuller keeps the canonical TG->system map fresh from the shared tglist
// (svx-web /tglist.php). Mirrors externalPuller: fetch on a gentle ticker, keep
// last-good on failure, cache to disk so names survive a restart before the
// first successful fetch. URL empty = feature off — the per-box nodes[].system
// stays the only source, so nothing changes until a box opts in via config.
func tglistPuller(ctx context.Context, c *config.Config) {
defer func() {
if r := recover(); r != nil {
log.Printf("tglistPuller panic: %v", r)
}
}()
file := strings.TrimSpace(c.Tglist.File)
url := strings.TrimSpace(c.Tglist.URL)
if file == "" && url == "" {
log.Printf("tglist: no file/url set; Location uses per-box nodes[].system only")
return
}
cacheFile := strings.TrimSpace(c.Tglist.CacheFile)
if cacheFile == "" {
cacheFile = "/var/lib/svxdr/tglist.json"
}
interval := c.Tglist.RefreshInterval.D()
if interval <= 0 {
interval = 15 * time.Minute
}
client := &http.Client{Timeout: 4 * time.Second}
// Parse a /tglist.php body into the TG->system map (system, else name).
apply := func(body []byte) int {
var doc struct {
TGs []struct {
TG int `json:"tg"`
Name string `json:"name"`
System string `json:"system"`
} `json:"tgs"`
}
if err := json.Unmarshal(body, &doc); err != nil {
return -1
}
m := make(map[int]string, len(doc.TGs))
for _, e := range doc.TGs {
if e.TG <= 0 {
continue
}
s := strings.TrimSpace(e.System)
if s == "" {
s = strings.TrimSpace(e.Name)
}
if s != "" {
m[e.TG] = s
}
}
setTglistSystem(m)
return len(m)
}
// Cold start: seed from the disk cache so names are present even before the
// first fetch (and if the list host is unreachable at boot).
if b, err := os.ReadFile(cacheFile); err == nil && len(b) > 0 {
if n := apply(b); n >= 0 {
log.Printf("tglist: seeded %d TG names from cache %s", n, cacheFile)
}
}
pullOnce := func() {
// SVX source of truth is a local JSON file — read it directly, no HTTP.
if file != "" {
body, err := os.ReadFile(file)
if err != nil {
if debug {
log.Printf("DEBUG tglist read failed: %v (file=%s)", err, file)
}
return
}
if n := apply(body); n >= 0 && debug {
log.Printf("DEBUG tglist: %d TG names from %s", n, file)
}
return
}
req, err := http.NewRequestWithContext(ctx, http.MethodGet, url, nil)
if err != nil {
return
}
resp, err := client.Do(req)
if err != nil {
if debug {
log.Printf("DEBUG tglist fetch failed: %v (url=%s)", err, url)
}
return
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
if debug {
log.Printf("DEBUG tglist fetch status %d (url=%s)", resp.StatusCode, url)
}
return
}
body, err := io.ReadAll(io.LimitReader(resp.Body, 1<<20))
if err != nil {
return
}
n := apply(body)
if n < 0 {
return // parse error: keep last-good
}
if err := os.MkdirAll(filepath.Dir(cacheFile), 0o755); err == nil {
_ = os.WriteFile(cacheFile, body, 0o644)
}
if debug {
log.Printf("DEBUG tglist: %d TG names from %s", n, url)
}
}
pullOnce()
t := time.NewTicker(interval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
pullOnce()
}
}
}
// ---- Complete network structure (nodelist collects everything; the website
// picks what it needs). Every slice is always allocated so nothing serializes as
// JSON null. Nothing is stripped: online + offline reflectors are all kept, tagged.
type netNode struct {
Callsign string `json:"callsign"`
TG int `json:"tg"`
RestrictedTG bool `json:"restrictedTG"`
Location string `json:"location"`
NodeClass string `json:"nodeClass"`
SW string `json:"sw"`
Sysop string `json:"sysop"`
MonitoredTGs []int `json:"monitoredTGs"`
IsTalker bool `json:"isTalker"`
RxFreq string `json:"rxFreq"`
TxFreq string `json:"txFreq"`
RxCtcss string `json:"rxCtcss"` // node's REPORT_CTCSS (the subtone it transmits)
TxCtcss string `json:"txCtcss"` // access tone selecting the current TG
}
type netTrunkRef struct {
Name string `json:"name"`
Host string `json:"host"`
Connected bool `json:"connected"`
Kind string `json:"kind"`
}
type netReflector struct {
Type string `json:"type"` // "local" | "reflector"
Self bool `json:"self"`
Uplink bool `json:"uplink"`
Name string `json:"name"`
Hostname string `json:"hostname"`
IP string `json:"ip"`
Port string `json:"port"`
Online bool `json:"online"` // up in the network (HTTP-read OR a trunk to it is connected)
Firewalled bool `json:"firewalled"` // online via trunk but HTTP status unreachable (no node detail)
Version string `json:"version"`
Mode string `json:"mode"`
Aliases []string `json:"aliases"`
Nodes []netNode `json:"nodes"`
Trunks []netTrunkRef `json:"trunks"`
}
type netTrunk struct {
Name string `json:"name"`
Owner string `json:"owner"`
Host string `json:"host"`
Connected bool `json:"connected"`
NodeCount int `json:"node_count"`
Kind string `json:"kind"`
Nodes []netNode `json:"nodes"` // callsigns this trunk carries (the "external" node list)
}
type networkDoc struct {
Generated string `json:"generated"`
Reflectors []netReflector `json:"reflectors"`
Trunks []netTrunk `json:"trunks"`
}
var (
networkMu sync.RWMutex
networkJSON = []byte(`{"generated":"","reflectors":[],"trunks":[]}`)
lastNetworkHash string
networkJSONPath = env("NETWORK_JSON_PATH", "/var/www/html/svxportal/network.json")
)
// writeMaybeGzip writes b with a 200, gzip-compressing when the client accepts it.
// The status/network blobs are large (~900 KB) and served to every browser on each
// update, so gzip (~6x smaller) is a major bandwidth win. The caller sets Content-Type.
func writeMaybeGzip(w http.ResponseWriter, r *http.Request, b []byte) {
if strings.Contains(r.Header.Get("Accept-Encoding"), "gzip") {
w.Header().Set("Content-Encoding", "gzip")
w.Header().Set("Vary", "Accept-Encoding")
w.WriteHeader(200)
gz := gzip.NewWriter(w)
_, _ = gz.Write(b)
_ = gz.Close()
return
}
w.WriteHeader(200)
_, _ = w.Write(b)
}
// rawScalarStr returns a JSON scalar (string or number) as a trimmed string.
func rawScalarStr(r json.RawMessage) string {
if len(r) == 0 {
return ""
}
var s string
if json.Unmarshal(r, &s) == nil {
return strings.TrimSpace(s)
}
return strings.TrimSpace(strings.Trim(string(r), `"`))
}
// toNetNode extracts the fields the website needs from a raw node object.
func toNetNode(callsign string, raw json.RawMessage) netNode {
var n struct {
Callsign string `json:"callsign"`
TG int `json:"tg"`
RestrictedTG bool `json:"restrictedTG"`
NodeLocation string `json:"nodeLocation"`
NodeClass string `json:"nodeClass"`
SW string `json:"sw"`
SwVer string `json:"swVer"`
Sysop string `json:"sysop"`
MonitoredTGs []int `json:"monitoredTGs"`
IsTalker bool `json:"isTalker"`
RXFREQ json.RawMessage `json:"RXFREQ"`
TXFREQ json.RawMessage `json:"TXFREQ"`
ReportCtcss json.RawMessage `json:"reportCtcss"`
ToneToTG map[string]int `json:"toneToTalkgroup"`
QTH []struct {
Name string `json:"name"`
RX map[string]struct {
Freq json.RawMessage `json:"freq"`
} `json:"rx"`
TX map[string]struct {
Freq json.RawMessage `json:"freq"`
} `json:"tx"`
} `json:"qth"`
}
_ = json.Unmarshal(raw, &n)
cs := callsign
if cs == "" {
cs = n.Callsign
}
loc := n.NodeLocation
if loc == "" {
for _, q := range n.QTH {
if q.Name != "" {
loc = q.Name
break
}
}
}
// Frequencies: top-level RXFREQ/TXFREQ, else the first qth rx/tx freq.
rxFreq, txFreq := rawScalarStr(n.RXFREQ), rawScalarStr(n.TXFREQ)
if rxFreq == "" || txFreq == "" {
for _, q := range n.QTH {
if rxFreq == "" {
for _, p := range q.RX {
if f := rawScalarStr(p.Freq); f != "" {
rxFreq = f
break
}
}
}
if txFreq == "" {
for _, p := range q.TX {
if f := rawScalarStr(p.Freq); f != "" {
txFreq = f
break
}
}
}
if rxFreq != "" && txFreq != "" {
break
}
}
}
// Subtones: RX = the node's REPORT_CTCSS; TX = the access tone selecting the
// current TG (from toneToTalkgroup).
rxCtcss := rawScalarStr(n.ReportCtcss)
txCtcss := ""
if n.TG != 0 {
for tone, tg := range n.ToneToTG {
if tg == n.TG {
txCtcss = tone
break
}
}
}
sw := strings.TrimSpace(n.SW + " " + n.SwVer)
mt := n.MonitoredTGs
if mt == nil {
mt = []int{}
}
return netNode{Callsign: cs, TG: n.TG, RestrictedTG: n.RestrictedTG, Location: loc,
NodeClass: n.NodeClass, SW: sw, Sysop: n.Sysop, MonitoredTGs: mt, IsTalker: n.IsTalker,
RxFreq: rxFreq, TxFreq: txFreq, RxCtcss: rxCtcss, TxCtcss: txCtcss}
}
// aliasesNorm returns the normalized-host form of each alias.
func aliasesNorm(aliases []string) []string {
out := make([]string, 0, len(aliases))
for _, a := range aliases {
out = append(out, normalizeHost(a))
}
return out
}
// localReflectorMeta reads the local reflector's name (SERVER_CERT ORG) and the
// identifiers (hostname/IP) that mark a crawled ref as "self".
func localReflectorMeta() (name string, selfKeys map[string]struct{}) {
selfKeys = map[string]struct{}{}
name = "LOCAL"
f, err := os.Open(svxreflectorConf)
if err != nil {
return
}
defer f.Close()
sc := bufio.NewScanner(f)
inCert := false
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
inCert = strings.EqualFold(line[1:len(line)-1], "SERVER_CERT")
continue
}
if !inCert {
continue
}
i := strings.IndexByte(line, '=')
if i < 0 {
continue
}
k := strings.ToUpper(strings.TrimSpace(line[:i]))
v := strings.TrimSpace(line[i+1:])
switch k {
case "ORG":
if v != "" {
name = v
}
case "COMMON_NAME":
if v != "" {
selfKeys[normalizeHost(v)] = struct{}{}
if ip := hostKey(v); ip != "" {
selfKeys[ip] = struct{}{}
}
}
case "SUBJECT_ALT_NAME":
for _, part := range strings.Split(v, ",") {
part = strings.TrimSpace(part)
up := strings.ToUpper(part)
if strings.HasPrefix(up, "DNS:") {
h := normalizeHost(part[4:])
selfKeys[h] = struct{}{}
if ip := hostKey(h); ip != "" {
selfKeys[ip] = struct{}{}
}
} else if strings.HasPrefix(up, "IP:") {
selfKeys[strings.TrimSpace(part[3:])] = struct{}{}
}
}
}
}
return
}
// buildNetworkDoc assembles the complete structure from the local reflector
// status (body) + the crawl (externalState). Strips nothing; tags everything.
func buildNetworkDoc(body []byte) networkDoc {
externalState.mu.RLock()
sources := externalState.sources
rawTrunks := externalState.trunks
routing := externalState.routing
generated := externalState.generated
externalState.mu.RUnlock()
doc := networkDoc{Generated: generated, Reflectors: []netReflector{}, Trunks: []netTrunk{}}
localName, selfKeys := localReflectorMeta()
uplinks := map[string]struct{}{}
for _, h := range uplinkHostsFromConf(svxreflectorConf) {
uplinks[normalizeHost(h)] = struct{}{}
}
// Local reflector entry (SA7AUX): nodes without remoteTrunk + local trunks.
var top map[string]json.RawMessage
_ = json.Unmarshal(body, &top)
localNodes := []netNode{}
if raw, ok := top["nodes"]; ok {
var m map[string]json.RawMessage
if json.Unmarshal(raw, &m) == nil {
for cs, nr := range m {
var chk struct {
RemoteTrunk json.RawMessage `json:"remoteTrunk"`
}
_ = json.Unmarshal(nr, &chk)
if len(chk.RemoteTrunk) > 0 {
continue
}
localNodes = append(localNodes, toNetNode(cs, nr))
}
}
}
sort.Slice(localNodes, func(i, j int) bool { return localNodes[i].Callsign < localNodes[j].Callsign })
localTrunks := []netTrunkRef{}
if raw, ok := top["trunks"]; ok {
var m map[string]json.RawMessage
if json.Unmarshal(raw, &m) == nil {
for name, tr := range m {
localTrunks = append(localTrunks, netTrunkRef{Name: name, Host: trunkHost(tr), Connected: trunkConnected(tr), Kind: "trunk"})
}
}
}
if raw, ok := top["trunks_UDP"]; ok {
var arr []json.RawMessage
if json.Unmarshal(raw, &arr) == nil {
for _, tr := range arr {
name, norm, h := normalizeUDPTrunk(tr)
if name == "" {
continue
}
localTrunks = append(localTrunks, netTrunkRef{Name: name, Host: h, Connected: trunkConnected(norm), Kind: "udp"})
}
}
}
if raw, ok := top["satellites"]; ok {
var m map[string]struct {
Authenticated bool `json:"authenticated"`
}
if json.Unmarshal(raw, &m) == nil {
satNames := satNamesFromConf(svxSatConf)
for id, sv := range m {
// Real callsign (KT4ROY-SAT1) from chan_svx_sat, not the raw id.
name := satNames[id]
if name == "" {
name = id
}
localTrunks = append(localTrunks, netTrunkRef{Name: name, Connected: sv.Authenticated, Kind: "satellite"})
}
}
}
doc.Reflectors = append(doc.Reflectors, netReflector{
Type: "local", Self: true, Name: localName, Online: true,
Aliases: []string{}, Nodes: localNodes, Trunks: localTrunks,
})
// Parse the merged trunk map once: for node counts (flat list below) and to
// know which hosts have a connected trunk (up in the network even if their
// HTTP status is firewalled from here).
var trmap map[string]json.RawMessage
_ = json.Unmarshal(rawTrunks, &trmap)
if trmap == nil {
trmap = map[string]json.RawMessage{}
}
// The local reflector's own /status carries each trunk's peer nodes (pushed via
// the reflector's trunk protocol) — take them straight from there. We poll NO
// ONE for network info; the local status is the single source.
if bt, ok := top["trunks"]; ok {
var bm map[string]json.RawMessage
if json.Unmarshal(bt, &bm) == nil {
for name, raw := range bm {
if _, dup := trmap[name]; !dup {
trmap[name] = raw
}
}
}
}
// Satellite trunks: their nodes live in the body's "satellites" block, keyed by
// sat id; index them under the same name localTrunks uses so their nodes show.
if bs, ok := top["satellites"]; ok {
var sm map[string]json.RawMessage
if json.Unmarshal(bs, &sm) == nil {
satNames := satNamesFromConf(svxSatConf)
for id, raw := range sm {
name := satNames[id]
if name == "" {
name = id
}
if _, dup := trmap[name]; !dup {
trmap[name] = raw
}
}
}
}
connectedHosts := map[string]struct{}{}
for _, tr := range trmap {
var m struct {
Host string `json:"host"`
Connected bool `json:"connected"`
}
if json.Unmarshal(tr, &m) == nil && m.Connected && m.Host != "" {
connectedHosts[normalizeHost(m.Host)] = struct{}{}
}
}
// Route-based node attribution (mirrors SvxReflectorDashboard's
// merge_trunk_status_nodes): every crawled reflector's Routing_table maps a
// callsign to the trunk IP it is reachable via. Group by IP so trunks and
// reflectors whose own node list is unreadable (firewalled UDP peers) still
// show their members. Deduped per IP; our own callsign is never added (no
// self-dupe).
// Full per-node detail (location/freq/sw/sysop) pulled from reflectors' merged
// rosters — used to enrich route nodes the Routing_table only names sparsely.
detailByCallsign := map[string]json.RawMessage{}
for _, s := range sources {
for cs, nr := range s.Detail {
if _, ok := detailByCallsign[cs]; !ok {
detailByCallsign[cs] = nr
}
}
}
routeByIP := map[string][]netNode{}
routeSeen := map[string]map[string]struct{}{}
for _, s := range sources {
for _, e := range s.Routing {
ip := normalizeHost(e.Trunk)
if ip == "" || e.Callsign == "" {
continue
}
if localName != "" && strings.EqualFold(e.Callsign, localName) {
continue
}
if routeSeen[ip] == nil {
routeSeen[ip] = map[string]struct{}{}
}
if _, dup := routeSeen[ip][e.Callsign]; dup {
continue
}
routeSeen[ip][e.Callsign] = struct{}{}
// Prefer the full-detail node from the merged roster; fall back to the
// sparse Routing_table fields (callsign + tg + monitored TGs).
if draw, ok := detailByCallsign[e.Callsign]; ok {
routeByIP[ip] = append(routeByIP[ip], toNetNode(e.Callsign, draw))
continue
}
mon := e.MonitoredTGs
if mon == nil {
mon = []int{}
}
routeByIP[ip] = append(routeByIP[ip], netNode{Callsign: e.Callsign, TG: e.TG, MonitoredTGs: mon})
}
}
// Crawled reflectors — all of them, tagged (nothing dropped).
for _, s := range sources {
nodes := []netNode{}
for cs, nr := range s.Nodes {
nodes = append(nodes, toNetNode(cs, nr))
}
// Fill members we can't read directly (firewalled peers) from the routing
// tables of reflectors that DO see this one — matched by IP / tag. Deduped
// by callsign so a directly-read node is never duplicated.
seenN := map[string]struct{}{}
for _, n := range nodes {
seenN[n.Callsign] = struct{}{}
}
for _, key := range []string{normalizeHost(s.IP), normalizeHost(s.Tag)} {
if key == "" {
continue
}
for _, rn := range routeByIP[key] {
if _, ok := seenN[rn.Callsign]; ok {
continue
}
seenN[rn.Callsign] = struct{}{}
nodes = append(nodes, rn)
}
}
sort.Slice(nodes, func(i, j int) bool { return nodes[i].Callsign < nodes[j].Callsign })
trunks := []netTrunkRef{}
for _, t := range s.Trunks {
trunks = append(trunks, netTrunkRef{Name: t.Name, Host: t.Host, Connected: t.Connected, Kind: t.Kind})
}
aliases := s.Aliases
if aliases == nil {
aliases = []string{}
}
_, isSelf := selfKeys[s.IP]
if !isSelf {
_, isSelf = selfKeys[normalizeHost(s.Tag)]
}
// Routing table: this ref's trunk IP maps to our own callsign → it's us.
if !isSelf && localName != "" {
if strings.EqualFold(routing[s.IP], localName) || strings.EqualFold(routing[normalizeHost(s.Tag)], localName) {
isSelf = true
}
}
_, isUplink := uplinks[normalizeHost(s.Tag)]
if !isUplink {
_, isUplink = uplinks[normalizeHost(hostFromURL(s.URL))]
}
hostname := hostFromURL(s.URL)
if hostname == "" && !isIPStr(s.Tag) {
hostname = s.Tag
}
// Online = HTTP-read OR a trunk to this reflector is connected. If it's up
// only via a connected trunk (HTTP unreachable), it's firewalled.
online := s.OK
firewalled := false
if !online {
for _, k := range append([]string{normalizeHost(s.Tag), normalizeHost(hostname), normalizeHost(s.IP)}, aliasesNorm(aliases)...) {
if k == "" {
continue
}
if _, ok := connectedHosts[k]; ok {
online = true
firewalled = true
break
}
}
}
doc.Reflectors = append(doc.Reflectors, netReflector{
Type: "reflector", Self: isSelf, Uplink: isUplink,
Name: s.Tag, Hostname: hostname, IP: s.IP, Port: s.HTTPPort,
Online: online, Firewalled: firewalled, Version: s.Version, Mode: s.Mode, Aliases: aliases,
Nodes: nodes, Trunks: trunks,
})
}
// Flat list of ALL links in the network — trunks, UDP peers AND satellites —
// built from the reflectors' own link lists (deduped by name), with owner +
// node count. This includes satellites, which the merged trunk map omits.
seenT := map[string]struct{}{}
for _, r := range doc.Reflectors {
for _, t := range r.Trunks {
if _, ok := seenT[t.Name]; ok {
continue
}
seenT[t.Name] = struct{}{}
tnodes := []netNode{}
if raw, ok := trmap[t.Name]; ok {
var m struct {
Nodes []json.RawMessage `json:"nodes"`
}
if json.Unmarshal(raw, &m) == nil {
for _, nr := range m.Nodes {
tnodes = append(tnodes, toNetNode("", nr))
}
}
}
// A UDP peer exposes no nodes[] inline; fill its members from the owning
// reflector's Routing_table (callsigns reachable via its host IP). Deduped.
seenTN := map[string]struct{}{}
for _, n := range tnodes {
seenTN[n.Callsign] = struct{}{}
}
for _, rn := range routeByIP[normalizeHost(t.Host)] {
if _, ok := seenTN[rn.Callsign]; ok {
continue
}
seenTN[rn.Callsign] = struct{}{}
tnodes = append(tnodes, rn)
}
sort.Slice(tnodes, func(i, j int) bool { return tnodes[i].Callsign < tnodes[j].Callsign })
doc.Trunks = append(doc.Trunks, netTrunk{
Name: t.Name, Owner: r.Name, Host: t.Host,
Connected: t.Connected, NodeCount: len(tnodes), Kind: t.Kind, Nodes: tnodes,
})
}
}
sort.Slice(doc.Trunks, func(i, j int) bool { return doc.Trunks[i].Name < doc.Trunks[j].Name })
return doc
}
// writeNetworkFile atomically writes b to networkJSONPath (temp + rename) when it
// changed, so external fetchers never see a half-written file.
func writeNetworkFile(b []byte) {
if networkJSONPath == "" {
return
}
dir := filepath.Dir(networkJSONPath)
tmp, err := os.CreateTemp(dir, ".network-*.json")
if err != nil {
return
}
tmpName := tmp.Name()
if _, err := tmp.Write(b); err != nil {
tmp.Close()
os.Remove(tmpName)
return
}
tmp.Close()
if err := os.Rename(tmpName, networkJSONPath); err != nil {
os.Remove(tmpName)
}
}
// injectExternal adds ext_sources and network_trunks (from externalState) as
// top-level fields to the served status JSON. If no external data is available
// yet, body is returned unchanged so local nodes/trunks still go out.
func injectExternal(body []byte) []byte {
// Build the complete network structure and publish it (served at /api/network
// and written to networkJSONPath for other systems to fetch).
doc := buildNetworkDoc(body)
nb, nerr := json.Marshal(doc)
if nerr == nil {
networkMu.Lock()
networkJSON = nb
networkMu.Unlock()
if h := hash(nb); h != lastNetworkHash {
lastNetworkHash = h
writeNetworkFile(nb)
}
}
var top map[string]json.RawMessage
if json.Unmarshal(body, &top) != nil {
return body
}
// The complete structure the frontend reads. The old flat ext_sources /
// network_trunks fields (unused by the frontend) are intentionally NOT emitted
// — they were ~66% of the served blob. Other systems fetch /api/network.
if nerr == nil {
top["network"] = nb
}
if bb, err := json.Marshal(top); err == nil {
return bb
}
return body
}
const (
heartbeatKey = "/registry/svxlink/nodelist/heartbeat"
)
type statusEnvelope struct {
Nodes map[string]json.RawMessage `json:"nodes"`
Trunks map[string]trunkStatus `json:"trunks"`
Satellites map[string]satelliteStatus `json:"satellites"`
}
// satelliteStatus mirrors a satellite trunk (chan_svx_sat / chan_pbx_svx). Its
// nodes[] carry a nodeClass so a PBX gateway talker can be shown as
// "<call> via <gateway>" rather than the raw gateway node name.
type satelliteStatus struct {
Nodes []struct {
Callsign string `json:"callsign"`
TG int `json:"tg"`
IsTalker bool `json:"isTalker"`
NodeClass string `json:"nodeClass"`
NodeLocation string `json:"nodeLocation"`
SW string `json:"sw"`
} `json:"nodes"`
}
// trunkStatus mirrors the per-trunk JSON emitted by the reflector
// (SatelliteLink/TrunkLink statusJson): active_talkers maps a TG to the real
// remote talker callsign, and nodes[] lists partner nodes with isTalker.
type trunkStatus struct {
ActiveTalkers map[string]string `json:"active_talkers"`
Nodes []struct {
Callsign string `json:"callsign"`
TG int `json:"tg"`
IsTalker bool `json:"isTalker"`
} `json:"nodes"`
}
type nodeInfo struct {
ToneToTalkgroup map[string]int `json:"toneToTalkgroup"`
QTH []struct {
Name string `json:"name"`
RX map[string]struct {
Freq float64 `json:"freq"`
Name string `json:"name"`
Active bool `json:"active"`
Siglev float64 `json:"siglev"`
} `json:"rx"`
TX map[string]struct {
Freq float64 `json:"freq"`
} `json:"tx"`
} `json:"qth"`
RX float64 `json:"rx"`
TX float64 `json:"tx"`
RxFreq float64 `json:"rxFreq"`
TxFreq float64 `json:"txFreq"`
IsTalker bool `json:"isTalker"`
TG int `json:"tg"`
NodeLocation string `json:"nodeLocation"`
Sysop string `json:"sysop"`
}
// live holds the real-callsign lookup maps maintained by the poller and read
// by the log tailer:
// - tgTalker: TG -> real remote talker callsign (from trunks[].active_talkers)
// - nodeSysop: node-name -> operator callsign (from nodes[].sysop)
// - tgMeta: TG -> talker meta published on MQTT (talker/<tg>/meta) by an
// external source such as astmeta (AllStar bridge talker id)
var live = struct {
mu sync.RWMutex
tgTalker map[int]string
tgTalkerAt map[int]int64 // TG -> unix ts tgTalker[tg] last had a value (sticky window)
nodeSysop map[string]string
tgMeta map[int]talkerMeta
asNodes map[string]allstarNode
viaNode map[string]bridgeInfo // UPPER(via callsign) -> bridge target, long-lived
tgLoc map[int]string // TG -> gateway nodeLocation (e.g. SA7AUX-PBX), for the Location column
tgMode map[int]string // TG -> Backend/mode of the gateway node (e.g. PBX), for the Backend column
}{tgTalker: map[int]string{}, tgTalkerAt: map[int]int64{}, nodeSysop: map[string]string{},
tgMeta: map[int]talkerMeta{}, asNodes: map[string]allstarNode{},
viaNode: map[string]bridgeInfo{}, tgLoc: map[int]string{}, tgMode: map[int]string{}}
// locForTG returns the gateway nodeLocation currently mapped to a TG (empty if none).
func locForTG(tg int) string {
// FROZEN DESIGN: the tglist is the SOLE source of the Location shown in
// Lastheard. No live gateway location, no astmeta, no nodes[].system — every
// talker on a TG shows exactly the tglist name for that TG (blank if the TG
// is not in the list), so the display is identical on every box.
return systemForTG(tg)
}
// modeForTG returns the gateway Backend/mode currently mapped to a TG (empty if none).
func modeForTG(tg int) string {
live.mu.RLock()
defer live.mu.RUnlock()
return live.tgMode[tg]
}
// bridgeInfo is what a bridge login (via) speaks through: the AllStar node it is
// connected to and that node's system name (e.g. 45192 / "East Coast Reflector").
// Long-lived + redis-persisted so the web can show "connected to <system>" and
// the connected-nodes button on the bridge row even when it is idle.
type bridgeInfo struct {
Node string `json:"node"`
System string `json:"system"`
}
// allstarNode is one station/system heard through the bridge via the
// talker-meta ingest. Kept ALLSTAR_NODES_TTL from the last keyup and
// published in reflector-status "nodes" format on /api/allstar-nodes.
type allstarNode struct {
Callsign string
Node string
System string
Via string
TG int
At time.Time
}
// talkerMeta is one talker-meta entry (fed by POST /api/talker-meta): who is
// really talking on the TG and from which node/system. Generic across
// sources: the AllStar bridge fills it via the svxlink TCL hook (node =
// AllStar node number), a DMR/NXDN bridge can fill the same fields (node =
// DMR id, resolved against the radioid tables in redis).
type talkerMeta struct {
Callsign string `json:"callsign"`
Node string `json:"node"`
System string `json:"system"`
Via string `json:"via"` // reflector callsign the bridge speaks through
Network string `json:"network"` // ingress mode: AllStar / DMR / DMR/YSF …
At time.Time
}
// talkerMetaFor returns the fresh talker meta for a TG, if any. logName is
// the talker name as it appears in the reflector log: when the meta carries
// a "via" callsign it only applies to that talker, so lingering meta can
// never mislabel an unrelated station on the same TG.
func talkerMetaFor(tg int, logName string) (talkerMeta, bool) {
live.mu.RLock()
defer live.mu.RUnlock()
m, ok := live.tgMeta[tg]
if !ok || m.Callsign == "" || time.Since(m.At) > talkerMetaTTL {
return talkerMeta{}, false
}
// The meta's via identifies the talker's bridge. Prefer the per-TG via
// configured in svxdr.json (nodes[].via) so the callsign/via is defined in
// ONE place and need NOT equal the reflector-log talker name (svx_sat.conf
// callsign). Only when no per-TG via is configured do we fall back to the
// old log-name match (backward compat for boxes without a nodes[] config).
if anyViaForTG(tg) {
if m.Via != "" && !viaConfiguredForTG(tg, m.Via) {
return talkerMeta{}, false
}
} else if m.Via != "" && m.Via != logName {
return talkerMeta{}, false
}
return m, true
}
// resolveTalker maps a node/trunk name (as it appears in the reflector log) to
// the real operator callsign, returning the trunk/node name separately when it
// differs so the UI can show both. For local nodes, fresh talker meta
// (e.g. the AllStar system behind a USRP bridge) wins over the sysop mapping.
func resolveTalker(nodeName string, tg int, isTrunk bool) (callsign, trunk string) {
callsign = nodeName
live.mu.RLock()
if isTrunk {
if c := live.tgTalker[tg]; c != "" {
callsign = c
}
} else if m, ok := live.tgMeta[tg]; ok && m.Callsign != "" &&
time.Since(m.At) <= talkerMetaTTL &&
(m.Via == "" || m.Via == nodeName) {
callsign = m.Callsign
} else if s := live.nodeSysop[nodeName]; s != "" {
callsign = s
}
live.mu.RUnlock()
if callsign != nodeName {
trunk = nodeName
}
return
}
func isDigits(s string) bool {
if s == "" {
return false
}
for _, r := range s {
if r < '0' || r > '9' {
return false
}
}
return true
}
// isLoopbackAddr reports whether a net/http RemoteAddr ("ip:port") is loopback
// (127.0.0.0/8 or ::1). Used to let same-box bridges POST talker-meta untokened.
func isLoopbackAddr(remote string) bool {
host, _, err := net.SplitHostPort(remote)
if err != nil {
host = remote
}
ip := net.ParseIP(host)
return ip != nil && ip.IsLoopback()
}
// injectTalkerMeta adds a top-level talker_meta object (tg -> fresh meta) to
// the served status JSON so the web can show the real talker behind a bridge
// node. The web applies the same via-gating as lastheard: the meta only
// labels the node whose callsign matches via.
func injectTalkerMeta(body []byte) []byte {
type metaOut struct {
Callsign string `json:"callsign"`
Node string `json:"node"`
System string `json:"system"`
Via string `json:"via"`
}
out := map[string]metaOut{}
bridge := map[string]bridgeInfo{} // via callsign -> {node, system} (for the
// web's per-bridge "connected to" + button)
// Network-wide bridges first (crawled from every reflector's own
// /api/bridge-nodes), so any box shows a bridge that lives on another
// server; the LOCAL map overrides on conflict (it is authoritative).
externalState.mu.RLock()
for via, bi := range externalState.bridgeNodes {
bridge[via] = bi
}
externalState.mu.RUnlock()
live.mu.RLock()
for tg, m := range live.tgMeta {
if m.Callsign == "" || time.Since(m.At) > talkerMetaTTL {
continue
}
out[strconv.Itoa(tg)] = metaOut{m.Callsign, m.Node, m.System, m.Via}
}
for via, bi := range live.viaNode {
bridge[via] = bi
}
live.mu.RUnlock()
if len(out) == 0 && len(bridge) == 0 {
return body
}
var top map[string]json.RawMessage
if json.Unmarshal(body, &top) != nil {
return body
}
if len(out) > 0 {
if b, err := json.Marshal(out); err == nil {
top["talker_meta"] = b
}
}
if len(bridge) > 0 {
if b, err := json.Marshal(bridge); err == nil {
top["bridge_nodes"] = b
}
}
bb, err := json.Marshal(top)
if err != nil {
return body
}
return bb
}
type talkerSession struct {
StartTs int64
TG int
Location string
RxName string
Siglev float64
}
type lastheardEvent struct {
Ts int64 `json:"ts"`
Callsign string `json:"callsign"`
Trunk string `json:"trunk,omitempty"`
TG int `json:"tg"`
Duration int64 `json:"duration_s"`
Location string `json:"location,omitempty"`
RxName string `json:"rx_name,omitempty"`
Siglev float64 `json:"siglev,omitempty"`
Source string `json:"source"`
// Source-network identity from talker meta (astmeta for AllStar, the
// DMR bridge later): node/ID number and human-readable system name.
// Empty for plain svxlink traffic — the UI shows them as blank columns.
Node string `json:"node,omitempty"`
System string `json:"system,omitempty"`
// Ingress mode: HOW the talker enters — "AllStar", "SVXLink", "trunk",
// and later "DMR", "DMR/YSF", "DMR/OB"… from the callmgr bridge. The web
// shows this as the Mode column (replacing the old System name). Always
// set: a row with no meta defaults to "trunk" or "SVXLink" by source.
Network string `json:"network,omitempty"`
}
type runtimeState struct {
mu sync.RWMutex
StartedAt time.Time
LastFetchOKAt time.Time
LastFetchURL string
LastFetchBytes int
LastFetchErr string
LastParsedNodes int
LastStatusHash string
LastRedisOKAt time.Time
LastRedisErr string
}
var st = &runtimeState{
StartedAt: time.Now(),
}
// ---- Fas 2: federation gap detector ------------------------------------------
// Compares the network-wide aggregate (FEDERATION_AGGREGATE_URL, sa2blv's
// reflector-network) against what our OWN recursive crawl + MQTT actually cover.
// Purely diagnostic: it names the reflectors/callsigns we are NOT seeing so they
// can be added to FEDERATION_SEEDS. Nothing here is merged into the served roster
// — our own fresh data stays authoritative.
var federationGap = struct {
mu sync.RWMutex
updatedAt time.Time
aggTotal int
haveTotal int
missingCS int
missingRefs map[string]int // reflector -> count of its nodes we do not cover
err string
}{missingRefs: map[string]int{}}
// ourCoveredCallsigns returns the set of callsigns we already see, from the
// recursive crawl (every reflector's /status nodes) and every MQTT source.
func ourCoveredCallsigns() map[string]struct{} {
have := map[string]struct{}{}
externalState.mu.RLock()
for _, s := range externalState.sources {
for cs := range s.Nodes {
have[cs] = struct{}{}
}
}
externalState.mu.RUnlock()
mqttState.mu.RLock()
for _, m := range mqttState.bySrc {
for cs := range m {
have[cs] = struct{}{}
}
}
mqttState.mu.RUnlock()
return have
}
func setFederationGapErr(msg string) {
federationGap.mu.Lock()
federationGap.err = msg
federationGap.updatedAt = time.Now()
federationGap.mu.Unlock()
if debug {
log.Printf("DEBUG federation gap fetch failed: %s", msg)
}
}
func federationGapOnce(client *http.Client) {
resp, err := client.Get(federationAggURL)
if err != nil {
setFederationGapErr(err.Error())
return
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 16<<20))
resp.Body.Close()
if resp.StatusCode != http.StatusOK {
setFederationGapErr(fmt.Sprintf("HTTP %d", resp.StatusCode))
return
}
// Aggregate is an object keyed by callsign; we only need each node's reflector.
var agg map[string]struct {
Reflector string `json:"reflector"`
}
if json.Unmarshal(body, &agg) != nil {
setFederationGapErr("aggregate is not a callsign->node object")
return
}
// Feed the aggregate into the All-Nodes flat layer so Monitor Nodes covers
// the whole network (real reflector names), not just our own crawl.
am := make(map[string]flatNode, len(agg))
for cs, n := range agg {
r := strings.TrimSpace(n.Reflector)
if r == "" {
continue
}
am[strings.ToUpper(cs)] = flatNode{Callsign: cs, Ref: r}
}
setAggNodes(am)
have := ourCoveredCallsigns()
missingRefs := map[string]int{}
missing := 0
for cs, n := range agg {
if _, ok := have[cs]; ok {
continue
}
missing++
ref := strings.TrimSpace(n.Reflector)
if ref == "" {
ref = "(unknown)"
}
missingRefs[ref]++
}
federationGap.mu.Lock()
federationGap.updatedAt = time.Now()
federationGap.aggTotal = len(agg)
federationGap.haveTotal = len(have)
federationGap.missingCS = missing
federationGap.missingRefs = missingRefs
federationGap.err = ""
federationGap.mu.Unlock()
if missing > 0 {
type kv struct {
ref string
n int
}
top := make([]kv, 0, len(missingRefs))
for r, n := range missingRefs {
top = append(top, kv{r, n})
}
sort.Slice(top, func(i, j int) bool { return top[i].n > top[j].n })
parts := make([]string, 0, len(top))
for i, t := range top {
if i >= 12 {
parts = append(parts, fmt.Sprintf("…+%d more", len(top)-i))
break
}
parts = append(parts, fmt.Sprintf("%s(%d)", t.ref, t.n))
}
log.Printf("federation gap: %d/%d aggregate callsigns not covered — missing reflectors: %s (add to FEDERATION_SEEDS)",
missing, len(agg), strings.Join(parts, ", "))
} else if debug {
log.Printf("federation gap: 0 — we cover all %d aggregate callsigns", len(agg))
}
}
func federationGapLoop() {
if federationAggURL == "" {
log.Printf("federation gap detector disabled (FEDERATION_AGGREGATE_URL empty)")
return
}
client := &http.Client{Timeout: 20 * time.Second}
time.Sleep(30 * time.Second) // let the crawl populate before the first diff
federationGapOnce(client)
t := time.NewTicker(federationAggInterval)
defer t.Stop()
for range t.C {
federationGapOnce(client)
}
}
// Run is the nodelist subsystem entrypoint for svxdr. It applies config from
// svxdr.json over the defaults, starts the crawl/lastheard/federation/xlink
// goroutines and the HTTP server, and returns when ctx is cancelled.
func Run(rctx context.Context, c *config.Config) error {
applyConfig(c)
rdb := redis.NewClient(&redis.Options{
Network: "unix",
Addr: redisSock,
Password: redisPass,
})
if err := rdb.Ping(ctx).Err(); err != nil {
return fmt.Errorf("nodelist redis ping failed: %v (sock=%s)", err, redisSock)
}
// Restore the persisted via->node map (bridge login -> AllStar node) so the
// web's per-bridge "connected nodes" button works immediately after a
// restart, before any fresh talker meta arrives.
if vn, err := rdb.HGetAll(ctx, "nodelist:vianode").Result(); err == nil && len(vn) > 0 {
live.mu.Lock()
for via, v := range vn {
node, system, _ := strings.Cut(v, "|") // stored as "node|system"
live.viaNode[strings.ToUpper(via)] = bridgeInfo{Node: node, System: system}
}
live.mu.Unlock()
log.Printf("restored %d via->node bridge mappings", len(vn))
}
// Location column render config now comes from svxdr.json (lastheard formats
// + reflector names derived from nodes[]), not a separate lastheard.json.
applyRenderFromConfig(c)
go poller(rdb)
go logTailer(rdb, reflectorLogPath)
xlinkStart(rdb)
if mqttBroker != "" && mqttPrefix != "" {
go startMqtt()
} else {
log.Printf("mqtt disabled (set MQTT_BROKER and MQTT_TOPIC_PREFIX to enable)")
}
// Topology: crawl the network from our uplink (derived from svxreflector.conf)
// so the graph shows every reflector AND the trunk links between them. Each
// reflector's OWN /status carries the real peer hosts that draw the edges — an
// aggregated hub status does not, so nothing else can reproduce the links.
// Runs on a gentle interval (EXTERNAL_PULL_INTERVAL, default 60s) with last-good
// retention, so it is spread out and never hammers any single server.
go externalPuller()
// Canonical TG->name list (svx-web /tglist.php). No-op until tglist.url is set
// in config; when set, it feeds systemForTG so Location renders the same name
// on every box that points at the same list.
go tglistPuller(rctx, c)
mux := http.NewServeMux()
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/plain; charset=utf-8")
w.WriteHeader(200)
_, _ = w.Write([]byte("ok\n"))
})
mux.HandleFunc("/api/status", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
b, err := rdb.Get(ctx, statusKey).Bytes()
if err != nil {
http.Error(w, `{"msg":"no status yet"}`, http.StatusServiceUnavailable)
return
}
writeMaybeGzip(w, r, b)
})
// Fas 2: federation gap report — which reflectors/callsigns the network-wide
// aggregate has that our own crawl does not cover yet. Diagnostic only.
mux.HandleFunc("/api/gaps", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
federationGap.mu.RLock()
out := map[string]any{
"updatedAt": federationGap.updatedAt,
"aggregateURL": federationAggURL,
"aggregateTotal": federationGap.aggTotal,
"weCover": federationGap.haveTotal,
"missingCallsigns": federationGap.missingCS,
"missingReflectors": federationGap.missingRefs,
"error": federationGap.err,
}
federationGap.mu.RUnlock()
b, _ := json.Marshal(out)
writeMaybeGzip(w, r, b)
})
// This box's OWN bridges (via -> {node, system}), for other reflectors to
// crawl and union into their network-wide bridge directory. Returns only
// the LOCAL map (never the crawled union) so crawling can't amplify/loop.
mux.HandleFunc("/api/bridge-nodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
out := map[string]bridgeInfo{}
live.mu.RLock()
for via, bi := range live.viaNode {
out[via] = bi
}
live.mu.RUnlock()
b, _ := json.Marshal(out)
writeMaybeGzip(w, r, b)
})
// The complete network structure on its own, for other systems to fetch.
mux.HandleFunc("/api/network", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
networkMu.RLock()
b := networkJSON
networkMu.RUnlock()
writeMaybeGzip(w, r, b)
})
mux.HandleFunc("/api/stream", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "SSE not supported", 500)
return
}
sub := rdb.Subscribe(ctx, pubsubChan)
defer sub.Close()
_, _ = io.WriteString(w, "event: update\ndata: 1\n\n")
flusher.Flush()
pingT := time.NewTicker(25 * time.Second)
defer pingT.Stop()
ch := sub.Channel()
for {
select {
case <-r.Context().Done():
return
case <-pingT.C:
_, _ = io.WriteString(w, "event: ping\ndata: 1\n\n")
flusher.Flush()
case <-ch:
_, _ = io.WriteString(w, "event: update\ndata: 1\n\n")
flusher.Flush()
}
}
})
mux.HandleFunc("/api/debug", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
resp := map[string]any{
"listen": listenAddr,
"reflectorURL": reflectorURL,
"pollInterval": pollInterval.String(),
"redisSock": redisSock,
"statusKey": statusKey,
"nodePrefix": nodeKeyPrefix,
"onlineTTL": effectiveOnlineTTL().String(),
"debug": debug,
}
// Current talker meta (MQTT or ingest fed), with age so the freshness
// gating can be judged without knowing the TTL.
type metaDump struct {
talkerMeta
AgeSec int64 `json:"age_s"`
}
metas := map[string]metaDump{}
live.mu.RLock()
for tg, m := range live.tgMeta {
metas[strconv.Itoa(tg)] = metaDump{m, int64(time.Since(m.At).Seconds())}
}
live.mu.RUnlock()
resp["talkerMeta"] = metas
st.mu.RLock()
resp["startedAt"] = st.StartedAt.Format(time.RFC3339)
resp["lastFetchOKAt"] = tOrEmpty(st.LastFetchOKAt)
resp["lastFetchURL"] = st.LastFetchURL
resp["lastFetchBytes"] = st.LastFetchBytes
resp["lastFetchErr"] = st.LastFetchErr
resp["lastParsedNodes"] = st.LastParsedNodes
resp["lastStatusHash"] = st.LastStatusHash
resp["lastRedisOKAt"] = tOrEmpty(st.LastRedisOKAt)
resp["lastRedisErr"] = st.LastRedisErr
st.mu.RUnlock()
externalState.mu.RLock()
resp["externalPullURLs"] = externalPullURLs() // derived from conf (or override)
resp["externalConf"] = svxreflectorConf
resp["externalSources"] = len(externalState.sources)
resp["externalGenerated"] = externalState.generated
resp["externalLastOKAt"] = tOrEmpty(externalState.lastOKAt)
resp["externalLastErr"] = externalState.lastErr
externalState.mu.RUnlock()
_ = json.NewEncoder(w).Encode(resp)
})
mux.HandleFunc("/api/lastheard", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
limit := 200
if s := r.URL.Query().Get("limit"); s != "" {
var n int
if _, err := fmt.Sscanf(s, "%d", &n); err == nil && n > 0 && n <= 1000 {
limit = n
}
}
now := time.Now().Unix()
minTs := now - lastheardWindowSec
vals, err := rdb.ZRevRangeByScore(ctx, lastheardKey, &redis.ZRangeBy{
Min: fmt.Sprintf("%d", minTs),
Max: "+inf",
Offset: 0,
Count: int64(limit),
}).Result()
if err != nil {
http.Error(w, `{"msg":"redis error"}`, 500)
return
}
out := make([]json.RawMessage, 0, len(vals))
for _, v := range vals {
out = append(out, json.RawMessage(v))
}
_ = json.NewEncoder(w).Encode(map[string]any{
"window_s": lastheardWindowSec,
"count": len(out),
"events": out,
})
})
mux.HandleFunc("/api/nodekeys", func(w http.ResponseWriter, r *http.Request) {
call := strings.TrimSpace(r.URL.Query().Get("call"))
if call == "" {
http.Error(w, `{"msg":"missing ?call="}`, 400)
return
}
keys := []string{
statusHashKey,
statusKey,
fmt.Sprintf("%s/%s/online", nodeKeyPrefix, call),
fmt.Sprintf("%s/%s/hash", nodeKeyPrefix, call),
fmt.Sprintf("%s/%s/json", nodeKeyPrefix, call),
fmt.Sprintf("%s/%s/rx", nodeKeyPrefix, call),
fmt.Sprintf("%s/%s/tx", nodeKeyPrefix, call),
fmt.Sprintf("%s/%s/toneToTalkgroup", nodeKeyPrefix, call),
heartbeatKey,
}
out := map[string]any{"call": call, "keys": map[string]any{}}
m := out["keys"].(map[string]any)
for _, k := range keys {
v, err := rdb.Get(ctx, k).Result()
if err != nil {
m[k] = nil
} else {
m[k] = v
}
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(out)
})
// Talker metadata pushed from the USRP bridge's svxlink TCL hook
// (usrp_stationdata_received) over HTTPS. Feeds the same live.tgMeta
// store as the MQTT talker/<tg>/meta leg, so via-gating and TTL apply
// unchanged. A bare numeric id is resolved to a callsign via the
// radioid tables callmgr2 keeps in redis (DMR first, then NXDN).
mux.HandleFunc("/api/talker-meta", func(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"msg":"POST only"}`, http.StatusMethodNotAllowed)
return
}
// Zero-config: en process på SAMMA burk (callmgr-satelliten, lokal astmeta)
// får POSTa utan token — så en DMR/SVX-brygga bara funkar utan konfig.
// Fjärranrop kräver X-Ingest-Token som förr (och att ett token är satt).
if !isLoopbackAddr(r.RemoteAddr) {
if ingestToken == "" {
http.Error(w, `{"msg":"ingest disabled"}`, http.StatusNotFound)
return
}
tok := r.Header.Get("X-Ingest-Token")
if subtle.ConstantTimeCompare([]byte(tok), []byte(ingestToken)) != 1 {
http.Error(w, `{"msg":"bad token"}`, http.StatusUnauthorized)
return
}
}
var in struct {
TG int `json:"tg"`
Event string `json:"event"`
Callsign string `json:"callsign"`
Node string `json:"node"`
System string `json:"system"`
Via string `json:"via"`
Network string `json:"network"` // ingress mode (AllStar / DMR / DMR/YSF …)
}
body, err := io.ReadAll(http.MaxBytesReader(w, r.Body, 4096))
if err != nil || json.Unmarshal(body, &in) != nil || in.TG <= 0 {
http.Error(w, `{"msg":"bad request"}`, http.StatusBadRequest)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
if in.Event == "stop" {
// Intentionally no clear: sub-second re-keys would race it
// (same design as astmeta). The TTL ages the entry out.
_, _ = w.Write([]byte(`{"ok":true}`))
return
}
cs := strings.TrimSpace(in.Callsign)
node := strings.TrimSpace(in.Node)
if (cs == "" || cs == "N0CALL" || isDigits(cs)) && isDigits(node) {
for _, k := range []string{
"/registry/ext/dmr/db/users/id/" + node,
"/registry/ext/nxdn/db/users/id/" + node,
} {
if v, err := rdb.HGet(ctx, k, "callsign").Result(); err == nil && v != "" {
cs = v
break
}
}
}
if cs == "" {
http.Error(w, `{"msg":"no callsign"}`, http.StatusBadRequest)
return
}
m := talkerMeta{Callsign: cs, Node: node,
System: strings.TrimSpace(in.System), Via: strings.TrimSpace(in.Via),
Network: strings.TrimSpace(in.Network), At: time.Now()}
live.mu.Lock()
live.tgMeta[in.TG] = m
// Track the station for /api/allstar-nodes and prune expired entries.
live.asNodes[cs] = allstarNode{Callsign: cs, Node: node,
System: m.System, Via: m.Via, TG: in.TG, At: m.At}
for k, v := range live.asNodes {
if time.Since(v.At) > allstarNodesTTL {
delete(live.asNodes, k)
}
}
// Long-lived via->node map: which AllStar node a bridge login speaks
// through, so the web can offer a "connected nodes" button on that
// bridge row even when idle (no fresh talker meta). Derived, never
// hard-coded, and persisted so it survives a restart.
if m.Via != "" && node != "" {
via := strings.ToUpper(m.Via)
bi := bridgeInfo{Node: node, System: m.System}
if live.viaNode[via] != bi {
live.viaNode[via] = bi
if rdb != nil {
rdb.HSet(ctx, "nodelist:vianode", via, node+"|"+m.System)
}
}
}
live.mu.Unlock()
if debug {
log.Printf("DEBUG ingest talker meta tg=%d callsign=%q node=%q via=%q",
in.TG, m.Callsign, m.Node, m.Via)
}
_, _ = w.Write([]byte(`{"ok":true}`))
})
// The bridge-heard stations as a reflector-status style node list:
// every station seen via the talker-meta ingest within
// ALLSTAR_NODES_TTL, keyed by callsign. Location comes from the
// radioid tables in redis when the station's id is known.
mux.HandleFunc("/api/allstar-nodes", func(w http.ResponseWriter, r *http.Request) {
type nodeOut struct {
Hidden bool `json:"hidden"`
IsTalker bool `json:"isTalker"`
MonitoredTGs []int `json:"monitoredTGs"`
NodeClass string `json:"nodeClass"`
NodeLocation string `json:"nodeLocation"`
RestrictedTG bool `json:"restrictedTG"`
SW string `json:"sw"`
Sysop string `json:"sysop"`
TG int `json:"tg"`
Node string `json:"node,omitempty"`
Via string `json:"via,omitempty"`
LastHeard int64 `json:"last_heard"`
}
out := map[string]nodeOut{}
now := time.Now()
live.mu.RLock()
for cs, n := range live.asNodes {
if now.Sub(n.At) > allstarNodesTTL {
continue
}
m, ok := live.tgMeta[n.TG]
isTalker := ok && m.Callsign == cs && now.Sub(m.At) <= talkerMetaTTL
loc := "via " + n.Via
if n.Via == "" {
loc = n.System
}
out[cs] = nodeOut{IsTalker: isTalker, MonitoredTGs: []int{n.TG},
NodeClass: "AllStar", NodeLocation: loc, SW: "AllStarLink",
Sysop: cs, TG: n.TG, Node: n.Node, Via: n.Via,
LastHeard: n.At.Unix()}
}
live.mu.RUnlock()
// Location from the radioid tables — outside the lock (redis I/O).
for cs, n := range out {
if !isDigits(n.Node) {
continue
}
for _, k := range []string{
"/registry/ext/dmr/db/users/id/" + n.Node,
"/registry/ext/nxdn/db/users/id/" + n.Node,
} {
vals, err := rdb.HMGet(ctx, k, "city", "state", "country").Result()
if err != nil {
continue
}
parts := []string{}
for _, v := range vals {
if s, ok := v.(string); ok && s != "" {
parts = append(parts, s)
}
}
if len(parts) > 0 {
n.NodeLocation = strings.Join(parts, ", ")
out[cs] = n
break
}
}
}
resp := map[string]any{
"mode": "bridge",
"ttl_s": int(allstarNodesTTL.Seconds()),
"nodes": out,
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(resp)
})
// /api/remote-nodes?node=<n> — ADDITIVE read-only endpoint: the nodes
// currently linked to an AllStar node, from stats.allstarlink.org, reshaped
// to {node, callsign, location, keyed}. Server-side fetch, short cache. Does
// not touch any existing response shape, so all current readers are safe.
mux.HandleFunc("/api/remote-nodes", func(w http.ResponseWriter, r *http.Request) {
node := strings.TrimSpace(r.URL.Query().Get("node"))
if !isDigits(node) {
http.Error(w, `{"msg":"node query param (digits) required"}`, http.StatusBadRequest)
return
}
resp, err := remoteNodes(node)
if err != nil {
http.Error(w, `{"msg":"upstream fetch failed"}`, http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
_ = json.NewEncoder(w).Encode(resp)
})
// All-Nodes flat layer: /api/nodes (full) + /api/nodes-stream (per-node deltas).
nodeFlatStart(mux, rdb)
// Monitor Nodes (single-endpoint): /api/monitor-nodes — Local + per-trunk.
monitorNodesStart(mux)
// Monitor Trunks: /api/local-trunks — ONLY the trunks this reflector is
// configured to connect to, read from the local svxreflector.conf.
localTrunksStart(mux)
srv := &http.Server{
Addr: listenAddr,
Handler: securityHeaders(mux),
ReadHeaderTimeout: 5 * time.Second,
}
log.Printf("nodelist listen=%s reflector=%s poll=%s redis=unix:%s statusKey=%s nodePrefix=%s onlineTTL=%s debug=%v",
listenAddr, reflectorURL, pollInterval, redisSock, statusKey, nodeKeyPrefix, effectiveOnlineTTL(), debug)
go func() {
<-rctx.Done()
sc, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(sc)
}()
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
func tOrEmpty(t time.Time) string {
if t.IsZero() {
return ""
}
return t.Format(time.RFC3339)
}
func effectiveOnlineTTL() time.Duration {
onlineTTL := nodeOnlineTTL
if onlineTTL <= 0 {
onlineTTL = nodeTTL
}
if onlineTTL <= 0 {
onlineTTL = maxDur(15*time.Second, 3*pollInterval)
}
return onlineTTL
}
// ---- /api/remote-nodes: AllStarLink stats proxy (additive, read-only) --------
var (
remoteHTTP = &http.Client{Timeout: 8 * time.Second}
remoteMu sync.Mutex
remoteCache = map[string]remoteEntry{}
)
const remoteTTL = 8 * time.Second
type remoteEntry struct {
at time.Time
resp *remoteResp
}
type remoteNode struct {
Node string `json:"node"`
Callsign string `json:"callsign"`
Location string `json:"location"`
Keyed bool `json:"keyed"`
}
type remoteResp struct {
Node string `json:"node"` // the AllStar node we queried
Count int `json:"count"`
Nodes []remoteNode `json:"nodes"`
FetchedAt int64 `json:"fetched_at"`
}
// asStats mirrors only the fields we consume from stats.allstarlink.org.
// The connected nodes are node-number strings under stats.data.links; this
// endpoint carries no callsign/location (resolved separately, TODO astdb).
type asStats struct {
Stats struct {
Data struct {
Links []string `json:"links"`
} `json:"data"`
} `json:"stats"`
}
// remoteNodes fetches (and briefly caches) the linked-node list for an AllStar
// node from the public stats API, reshaped for the UI.
func remoteNodes(node string) (*remoteResp, error) {
remoteMu.Lock()
if e, ok := remoteCache[node]; ok && time.Since(e.at) < remoteTTL {
remoteMu.Unlock()
return e.resp, nil
}
remoteMu.Unlock()
req, err := http.NewRequest(http.MethodGet, "https://stats.allstarlink.org/api/stats/"+node, nil)
if err != nil {
return nil, err
}
req.Header.Set("Accept", "application/json")
hr, err := remoteHTTP.Do(req)
if err != nil {
return nil, err
}
defer hr.Body.Close()
if hr.StatusCode >= 300 {
return nil, fmt.Errorf("stats HTTP %d", hr.StatusCode)
}
var s asStats
if err := json.NewDecoder(io.LimitReader(hr.Body, 8<<20)).Decode(&s); err != nil {
return nil, err
}
ensureAstdb() // background-loads the node->callsign db if stale (non-blocking)
resp := &remoteResp{Node: node, FetchedAt: time.Now().Unix()}
for _, ln := range s.Stats.Data.Links {
n := strings.TrimSpace(ln)
if n == "" {
continue
}
cs, loc := astdbLookup(n)
resp.Nodes = append(resp.Nodes, remoteNode{Node: n, Callsign: cs, Location: loc})
}
resp.Count = len(resp.Nodes)
remoteMu.Lock()
remoteCache[node] = remoteEntry{at: time.Now(), resp: resp}
remoteMu.Unlock()
return resp, nil
}
// ---- astdb: AllStar node -> callsign/location (background-loaded, cached) ----
var (
astdbURL = env("ASTDB_URL", "http://allmondb.allstarlink.org/") // node|call|desc|loc
astdbLine = regexp.MustCompile(`^([0-9]+)\|([^|]*)\|([^|]*)\|(.*)$`)
astdbMu sync.RWMutex
astdbMap = map[string][2]string{} // node -> {callsign, location}
astdbAt time.Time
astdbBusy bool
)
const astdbTTL = 6 * time.Hour
// ensureAstdb starts a background reload when the cache is stale. Non-blocking:
// the caller uses whatever is currently loaded (empty until the first load
// finishes), so a request never waits on the (large) allmondb download.
func ensureAstdb() {
astdbMu.Lock()
stale := time.Since(astdbAt) >= astdbTTL
if astdbBusy || !stale {
astdbMu.Unlock()
return
}
astdbBusy = true
astdbMu.Unlock()
go loadAstdb()
}
func loadAstdb() {
defer func() { astdbMu.Lock(); astdbBusy = false; astdbMu.Unlock() }()
if astdbURL == "" {
return
}
hr, err := remoteHTTP.Get(astdbURL)
if err != nil {
log.Printf("astdb: fetch failed: %v", err)
return
}
defer hr.Body.Close()
m := map[string][2]string{}
sc := bufio.NewScanner(io.LimitReader(hr.Body, 64<<20))
sc.Buffer(make([]byte, 1<<20), 1<<20)
for sc.Scan() {
if f := astdbLine.FindStringSubmatch(sc.Text()); f != nil {
m[f[1]] = [2]string{strings.TrimSpace(f[2]), strings.TrimSpace(f[4])}
}
}
if len(m) > 0 {
astdbMu.Lock()
astdbMap = m
astdbAt = time.Now()
astdbMu.Unlock()
log.Printf("astdb: loaded %d nodes", len(m))
}
}
func astdbLookup(node string) (callsign, location string) {
astdbMu.RLock()
defer astdbMu.RUnlock()
if v, ok := astdbMap[node]; ok {
return v[0], v[1]
}
return "", ""
}
func maxDur(a, b time.Duration) time.Duration {
if a > b {
return a
}
return b
}
func poller(rdb *redis.Client) {
defer func() {
if r := recover(); r != nil {
log.Printf("poller panic: %v (will NOT recover)", r)
os.Exit(2)
}
}()
t := time.NewTicker(pollInterval)
defer t.Stop()
onlineTTL := effectiveOnlineTTL()
nodeOnlineKey := func(cs string) string {
return fmt.Sprintf("%s/%s/online", nodeKeyPrefix, cs)
}
prevOnline := map[string]struct{}{}
if arr, err := rdb.SMembers(ctx, nodesOnlineKey).Result(); err == nil {
for _, cs := range arr {
prevOnline[cs] = struct{}{}
}
}
var lastFetchErrLog time.Time
for range t.C {
body, usedURL, err := fetchReflectorStatus(reflectorURL)
if err != nil {
st.mu.Lock()
st.LastFetchErr = err.Error()
st.mu.Unlock()
if debug {
log.Printf("DEBUG fetch failed: %v (url=%s)", err, reflectorURL)
}
if time.Since(lastFetchErrLog) > 30*time.Second {
log.Printf("reflector fetch failed: %v (url=%s)", err, reflectorURL)
lastFetchErrLog = time.Now()
}
continue
}
st.mu.Lock()
st.LastFetchOKAt = time.Now()
st.LastFetchURL = usedURL
st.LastFetchBytes = len(body)
st.LastFetchErr = ""
st.mu.Unlock()
var env statusEnvelope
if err := json.Unmarshal(body, &env); err != nil {
st.mu.Lock()
st.LastFetchErr = "parse: " + err.Error()
st.mu.Unlock()
log.Printf("status json parse failed: %v (url=%s)", err, usedURL)
continue
}
if env.Nodes == nil {
env.Nodes = map[string]json.RawMessage{}
}
st.mu.Lock()
st.LastParsedNodes = len(env.Nodes)
st.mu.Unlock()
// Refresh the real-callsign lookup maps for the log tailer.
newTgTalker := map[int]string{}
newTgTalkerAt := map[int]int64{}
newTgLoc := map[int]string{}
newTgMode := map[int]string{}
crawlNow := time.Now().Unix()
for _, tr := range env.Trunks {
for tgs, cs := range tr.ActiveTalkers {
if cs == "" {
continue
}
if tg, e := strconv.Atoi(tgs); e == nil && tg > 0 {
newTgTalker[tg] = cs
newTgTalkerAt[tg] = crawlNow
}
}
for _, n := range tr.Nodes {
if n.IsTalker && n.Callsign != "" && n.TG > 0 {
newTgTalker[n.TG] = n.Callsign
newTgTalkerAt[n.TG] = crawlNow
}
}
}
// Satellite trunks (chan_svx_sat / chan_pbx_svx) carry their talkers in a
// separate section, not env.Trunks. The node's callsign is the real caller
// (SA7AUX); its gateway label rides in nodeLocation (SA7AUX-PBX) -> the
// Location column, like an AllStar node's node_info.json.
for _, sat := range env.Satellites {
for _, n := range sat.Nodes {
if !n.IsTalker || n.Callsign == "" || n.TG <= 0 {
continue
}
newTgTalker[n.TG] = n.Callsign
newTgTalkerAt[n.TG] = crawlNow
if n.NodeLocation != "" {
newTgLoc[n.TG] = n.NodeLocation
}
if m := monModeOf(n.SW); m != "" {
newTgMode[n.TG] = m
}
}
}
newNodeSysop := map[string]string{}
for cs, raw := range env.Nodes {
if sy := extractSysop(raw); sy != "" {
newNodeSysop[cs] = sy
}
}
// Sticky: a talker-start from the log tail can race ahead of this crawl,
// so a TG the crawl momentarily lost (talker still active, roster lagging)
// would drop back to the raw trunk name. Carry a very-recent value forward
// (short 15s window) so resolveTalker keeps the real callsign across the gap.
const stickyWindow = int64(15)
live.mu.Lock()
for tg, cs := range live.tgTalker {
if _, ok := newTgTalker[tg]; ok {
continue
}
if at := live.tgTalkerAt[tg]; at > 0 && crawlNow-at <= stickyWindow {
newTgTalker[tg] = cs
newTgTalkerAt[tg] = at
}
}
live.tgTalker = newTgTalker
live.tgTalkerAt = newTgTalkerAt
live.tgLoc = newTgLoc
live.tgMode = newTgMode
live.nodeSysop = newNodeSysop
live.mu.Unlock()
nowOnline := map[string]struct{}{}
for cs := range env.Nodes {
nowOnline[cs] = struct{}{}
}
added, removed := diffSets(prevOnline, nowOnline)
pipe := rdb.Pipeline()
pipe.Set(ctx, heartbeatKey, fmt.Sprint(time.Now().Unix()), 5*time.Second)
// Merge MQTT-sourced trunk nodes into the served status so the
// dashboard's node views show trunk-side nodes + their isTalker state.
storeBody := body
if merged := mergeMqttNodes(env.Nodes); len(merged) > len(env.Nodes) {
var top map[string]json.RawMessage
if json.Unmarshal(body, &top) == nil {
if nb, err := json.Marshal(merged); err == nil {
top["nodes"] = nb
if bb, err := json.Marshal(top); err == nil {
storeBody = bb
}
}
}
}
// Inject the network-wide picture (ext_sources + network_trunks) so the
// Monitor views can show every reflector's nodes and all trunks.
storeBody = injectExternal(storeBody)
// Fresh bridge talker meta for the "Talking now" strip.
storeBody = injectTalkerMeta(storeBody)
h := hash(storeBody)
pipe.Set(ctx, statusKey, storeBody, 0)
pipe.Set(ctx, statusHashKey, h, 0)
pipe.Publish(ctx, pubsubChan, "1")
st.mu.Lock()
st.LastStatusHash = h
st.mu.Unlock()
if len(added) > 0 {
members := make([]interface{}, 0, len(added))
for _, cs := range added {
members = append(members, cs)
}
pipe.SAdd(ctx, nodesOnlineKey, members...)
}
if len(removed) > 0 {
members := make([]interface{}, 0, len(removed))
for _, cs := range removed {
members = append(members, cs)
}
pipe.SRem(ctx, nodesOnlineKey, members...)
}
for cs, raw := range env.Nodes {
pipe.Set(ctx, nodeOnlineKey(cs), "1", onlineTTL)
nh := hash(raw)
pipe.Set(ctx, fmt.Sprintf("%s/%s/hash", nodeKeyPrefix, cs), nh, 0)
// IMPORTANT: go-redis can't marshal json.RawMessage directly; cast to []byte.
pipe.Set(ctx, fmt.Sprintf("%s/%s/json", nodeKeyPrefix, cs), []byte(raw), 0)
rx, tx := extractRxTx(raw)
pipe.Set(ctx, fmt.Sprintf("%s/%s/rx", nodeKeyPrefix, cs), fmt.Sprintf("%.5f", rx), 0)
pipe.Set(ctx, fmt.Sprintf("%s/%s/tx", nodeKeyPrefix, cs), fmt.Sprintf("%.5f", tx), 0)
tones := extractToneToTG(raw)
if tones != nil && len(tones) > 0 {
b, _ := json.Marshal(tones)
pipe.Set(ctx, fmt.Sprintf("%s/%s/toneToTalkgroup", nodeKeyPrefix, cs), b, 0)
} else {
pipe.Del(ctx, fmt.Sprintf("%s/%s/toneToTalkgroup", nodeKeyPrefix, cs))
}
}
for _, cs := range added {
pipe.Publish(ctx, nodeConnectChan, cs)
pipe.Publish(ctx, nodeUpdateChan, "connect:"+cs)
}
for _, cs := range removed {
pipe.Publish(ctx, nodeDisconnectChan, cs)
pipe.Publish(ctx, nodeUpdateChan, "disconnect:"+cs)
pipe.Del(ctx, nodeOnlineKey(cs))
}
// lastheard events come from the reflector-log tailer (logTailer goroutine)
if _, execErr := pipe.Exec(ctx); execErr != nil {
st.mu.Lock()
st.LastRedisErr = execErr.Error()
st.mu.Unlock()
log.Printf("redis exec failed: %v", execErr)
continue
}
st.mu.Lock()
st.LastRedisOKAt = time.Now()
st.LastRedisErr = ""
st.mu.Unlock()
if debug {
log.Printf("DEBUG wrote: nodes=%d added=%d removed=%d", len(env.Nodes), len(added), len(removed))
}
prevOnline = nowOnline
}
}
func extractRxTx(raw json.RawMessage) (rx float64, tx float64) {
var ni nodeInfo
if err := json.Unmarshal(raw, &ni); err != nil {
return 0, 0
}
if ni.RxFreq > 0 {
rx = ni.RxFreq
} else if ni.RX > 0 {
rx = ni.RX
}
if ni.TxFreq > 0 {
tx = ni.TxFreq
} else if ni.TX > 0 {
tx = ni.TX
}
if (rx <= 0 || tx <= 0) && len(ni.QTH) > 0 {
q := ni.QTH[0]
if rx <= 0 {
for _, v := range q.RX {
if v.Freq > 0 {
rx = v.Freq
break
}
}
}
if tx <= 0 {
for _, v := range q.TX {
if v.Freq > 0 {
tx = v.Freq
break
}
}
}
}
if rx > 0 && tx <= 0 {
tx = rx
}
return
}
func extractToneToTG(raw json.RawMessage) map[string]int {
var ni nodeInfo
if err := json.Unmarshal(raw, &ni); err != nil {
return nil
}
return ni.ToneToTalkgroup
}
func extractSysop(raw json.RawMessage) string {
var ni nodeInfo
if err := json.Unmarshal(raw, &ni); err != nil {
return ""
}
return strings.TrimSpace(ni.Sysop)
}
var (
// Matches both a local/node talker ("... KT4ROY-A: Talker start on TG #N")
// and a trunk/satellite-delivered one ("... [trunk] SA7AUX: Trunk talker
// start on TG #N"). The optional "Trunk " group tags the source.
talkerLineRe = regexp.MustCompile(`^(.+?): (Trunk )?[Tt]alker (start|stop) on TG #(\d+)\s*$`)
logDateRe = regexp.MustCompile(`^(\w{3}\s+\d{1,2}\s+\w{3}\s+\d{4}\s+\d{2}:\d{2}:\d{2}\s+(?:AM|PM))\s+[A-Z]{2,5}$`)
)
type talkerLogEvent struct {
Ts int64
Callsign string
TG int
IsStop bool
IsTrunk bool
}
func parseTalkerLogLine(line string) (ev talkerLogEvent, ok bool) {
m := talkerLineRe.FindStringSubmatch(line)
if m == nil {
return
}
prefix := m[1]
idx := strings.LastIndex(prefix, ": ")
if idx < 0 {
return
}
datePart := prefix[:idx]
tail := prefix[idx+2:]
ev.Ts = parseReflectorDate(datePart)
if ev.Ts == 0 {
ev.Ts = time.Now().Unix()
}
ev.IsTrunk = m[2] != "" || strings.Contains(tail, "-> Trunk")
fields := strings.Fields(tail)
// Newer reflector builds prefix log lines with severity/component tags
// ("[info] [client] KT4ROY-AS: Talker start ..."); skip any such tags so
// the callsign is found in both the old and the new log format.
for len(fields) > 0 && strings.HasPrefix(fields[0], "[") && strings.HasSuffix(fields[0], "]") {
fields = fields[1:]
}
if len(fields) == 0 {
return
}
ev.Callsign = strings.TrimRight(fields[0], ":,")
ev.IsStop = m[3] == "stop"
tg, err := strconv.Atoi(m[4])
if err != nil {
return
}
ev.TG = tg
ok = true
return
}
func parseReflectorDate(s string) int64 {
s = strings.TrimSpace(s)
m := logDateRe.FindStringSubmatch(s)
if m == nil {
t, err := time.ParseInLocation("Mon 2 Jan 2006 03:04:05 PM MST", s, time.Local)
if err == nil {
return t.Unix()
}
return 0
}
t, err := time.ParseInLocation("Mon 2 Jan 2006 03:04:05 PM", m[1], time.Local)
if err != nil {
return 0
}
return t.Unix()
}
type liveTalker struct {
StartTs int64
TG int
IsTrunk bool
Callsign string // resolved real callsign, captured at talker-start
Trunk string // node/trunk name when it differs from the callsign
Node string // source-network node/id (AllStar node, DMR id), from meta
System string // source system name (e.g. "East Coast Reflector"), from meta
Via string // reflector callsign the bridge speaks through, from meta
Network string // ingress mode: AllStar / SVXLink / trunk / DMR / DMR/YSF …
LogName string // raw talker name from the reflector log (for via-match)
}
// lastheardRenderCfg controls how bridge/AllStar rows render in lastheard,
// editable at runtime via a JSON file (hot-reloaded) — NO recompile, nothing
// hardcoded. Path: LASTHEARD_RENDER_JSON env, default /etc/nodelist/lastheard.json.
// {system}/{node}/{talker} in the format strings are filled dynamically.
// reflector_names gives a friendly System name to reflectors astmeta doesn't
// name (e.g. "ASL-dodropin" -> "Do Drop In"), keyed by the bridge/log name.
type lastheardRenderCfg struct {
BridgeLocationFormat string `json:"bridge_location_format"`
BridgeLocationFormatNoSystem string `json:"bridge_location_format_no_system"`
ReflectorNames map[string]string `json:"reflector_names"`
}
var defaultRenderCfg = lastheardRenderCfg{
BridgeLocationFormat: "{system} via {node} / {talker}",
BridgeLocationFormatNoSystem: "via {node} / {talker}",
ReflectorNames: map[string]string{},
}
var (
renderCfgMu sync.RWMutex
renderCfg = defaultRenderCfg
)
func renderConfig() lastheardRenderCfg {
renderCfgMu.RLock()
defer renderCfgMu.RUnlock()
return renderCfg
}
// loadRenderConfig merges the JSON file over the built-in defaults, so the
// binary works with no file and a partial file only overrides what it sets.
func loadRenderConfig(path string) {
cfg := defaultRenderCfg
cfg.ReflectorNames = map[string]string{}
if b, err := os.ReadFile(path); err == nil {
var fc lastheardRenderCfg
if jerr := json.Unmarshal(b, &fc); jerr != nil {
log.Printf("lastheard render json %s: %v (keeping current)", path, jerr)
return
}
if strings.TrimSpace(fc.BridgeLocationFormat) != "" {
cfg.BridgeLocationFormat = fc.BridgeLocationFormat
}
if strings.TrimSpace(fc.BridgeLocationFormatNoSystem) != "" {
cfg.BridgeLocationFormatNoSystem = fc.BridgeLocationFormatNoSystem
}
for k, v := range fc.ReflectorNames {
cfg.ReflectorNames[k] = v
}
}
// nodes.json (shared bridge map) merged on top: it is the source of truth
// for reflector names + Location formats. Absent file = no-op.
applyNodesRenderConfig(&cfg, nodesJSONPath())
renderCfgMu.Lock()
renderCfg = cfg
renderCfgMu.Unlock()
}
// watchRenderConfig hot-reloads whenever lastheard.json OR nodes.json changes,
// so editing the shared bridge map updates the Location column with no restart.
func watchRenderConfig(path string) {
var last, lastNodes time.Time
for {
reload := false
if fi, err := os.Stat(path); err == nil && fi.ModTime() != last {
last = fi.ModTime()
reload = true
}
if fi, err := os.Stat(nodesJSONPath()); err == nil && fi.ModTime() != lastNodes {
lastNodes = fi.ModTime()
reload = true
}
if reload {
loadRenderConfig(path)
}
time.Sleep(3 * time.Second)
}
}
// bridgeIdentity splits a talker into the (Callsign, Location) shown in
// lastheard, uniform for PBX and AllStar (approved layout). PBX / plain svxlink:
// Callsign = lt.Callsign (sip-tg<TG> / real callsign), Location = locForTG
// (SA7AUX-PBX / blank). AllStar/bridge (meta System known): the BRIDGE
// (lt.Via, e.g. KT4ROY-AS) is the Callsign, and Location carries
// "<System> via <node#> / <talker>" (e.g. "East Coast Reflector via 45225 / W2ECR").
// The AllStar node number is dynamic from astmeta (lt.Node), never hardcoded.
func bridgeIdentity(lt liveTalker, tg int) (callsign, location string) {
callsign = lt.Callsign
location = locForTG(tg)
bridge := lt.Via
if bridge == "" {
bridge = lt.Trunk
}
// A bridge/AllStar talker has a bridge node (Via/Trunk) AND meta (Node or
// System) — the reflector name may be absent (e.g. ASL-dodropin). Plain
// svxlink / PBX carry no bridge node -> untouched.
if bridge == "" || (lt.Node == "" && lt.System == "") {
return callsign, location
}
// FROZEN DESIGN: Location is the tglist name ONLY (already set above via
// locForTG). We no longer build "<System> via <node> / <talker>" from live
// astmeta — the tglist is the sole source, so the row reads identically on
// every box. The bridge is still the Callsign (who spoke).
callsign = bridge
return callsign, location
}
func writeLastheardEvent(rdb *redis.Client, ev lastheardEvent) {
b, err := json.Marshal(ev)
if err != nil {
return
}
pipe := rdb.Pipeline()
pipe.ZAdd(ctx, lastheardKey, redis.Z{Score: float64(ev.Ts), Member: string(b)})
cutoff := time.Now().Unix() - lastheardWindowSec
pipe.ZRemRangeByScore(ctx, lastheardKey, "-inf", fmt.Sprintf("(%d", cutoff))
if _, err := pipe.Exec(ctx); err != nil {
log.Printf("lastheard write failed: %v", err)
}
}
func sourceLabel(isTrunk bool) string {
if isTrunk {
return "trunk"
}
return "local"
}
// defaultNetwork is the ingress mode when no talker meta enriches the row:
// a trunk-delivered talker enters over a reflector↔reflector trunk, everything
// else is a plain svxlink client. A bridge (AllStar via astmeta, later DMR via
// callmgr) overrides this with its own network string from the talker meta.
func defaultNetwork(isTrunk bool) string {
if isTrunk {
return "trunk"
}
return "SVXLink"
}
func logTailer(rdb *redis.Client, path string) {
defer func() {
if r := recover(); r != nil {
log.Printf("logTailer panic: %v", r)
}
}()
active := map[string]liveTalker{} // key: callsign|tg
keyOf := func(cs string, tg int) string { return fmt.Sprintf("%s|%d", cs, tg) }
mkLive := func(ev talkerLogEvent) liveTalker {
cs, tn := resolveTalker(ev.Callsign, ev.TG, ev.IsTrunk)
lt := liveTalker{StartTs: ev.Ts, TG: ev.TG, IsTrunk: ev.IsTrunk,
Callsign: cs, Trunk: tn, LogName: ev.Callsign,
Network: defaultNetwork(ev.IsTrunk)}
// Meta applies regardless of source (locally bridged traffic here,
// trunk-delivered on the partner reflector) — gated by via-match.
if m, ok := talkerMetaFor(ev.TG, ev.Callsign); ok {
lt.Callsign, lt.Node, lt.System, lt.Via = m.Callsign, m.Node, m.System, m.Via
if m.Network != "" {
lt.Network = m.Network
}
if lt.Trunk == "" && ev.Callsign != m.Callsign {
lt.Trunk = ev.Callsign
}
}
// Gateway-node Backend (PBX etc.) wins over the generic "trunk" ingress.
if md := modeForTG(ev.TG); md != "" {
lt.Network = md
}
return lt
}
emit := func(start liveTalker, tg int, stopTs int64) {
// The meta often arrives just after talker-start (astmeta polls AMI
// about once a second), so short keyups miss it in mkLive. Re-check
// at stop — the meta is still fresh at this point — so those rows
// get the real source identity instead of the sysop fallback.
if start.Node == "" {
if m, ok := talkerMetaFor(tg, start.LogName); ok {
if start.Trunk == "" && start.Callsign != m.Callsign {
start.Trunk = start.Callsign
}
start.Callsign, start.Node, start.System, start.Via = m.Callsign, m.Node, m.System, m.Via
if m.Network != "" {
start.Network = m.Network
}
}
}
dur := stopTs - start.StartTs
if dur < 1 {
dur = 1
}
// Re-apply the gateway Backend at stop: tgMode may not have been populated
// yet when mkLive ran at talker-start (crawl lag on short keyups).
if md := modeForTG(tg); md != "" {
start.Network = md
}
// Location column, symmetric for PBX and AllStar: the gateway/reflector
// descriptor lives here, the station stays in Callsign. PBX gets its
// gateway nodeLocation via locForTG (e.g. SA7AUX-PBX); a bridge/AllStar
// talker (no TG->location map) gets "<System> via <node>" (e.g.
// "East Coast Reflector via KT4ROY-AS") so it renders like the PBX rows.
// Callsign + Location split (uniform PBX/AllStar). See bridgeIdentity.
csOut, loc := bridgeIdentity(start, tg)
writeLastheardEvent(rdb, lastheardEvent{
Ts: start.StartTs,
Callsign: csOut,
Trunk: start.Trunk,
TG: tg,
Duration: dur,
Location: loc,
Source: sourceLabel(start.IsTrunk),
Node: start.Node,
System: start.System,
Network: start.Network,
})
}
// 1) Backfill: scan whole file, replay events into ZSET (last 24h only).
now0 := time.Now().Unix()
// Timestamps already persisted in the lastheard set, so a restart's backfill
// does not re-emit (duplicate) rows the previous run already wrote live — a
// live row may be enriched ("X via X-PBX") while the raw backfill cannot
// enrich (tgTalker is empty for historical events), producing a second row.
existing := map[int64]bool{}
if zs, zerr := rdb.ZRangeWithScores(ctx, lastheardKey, 0, -1).Result(); zerr == nil {
for _, z := range zs {
existing[int64(z.Score)] = true
}
}
if f, err := os.Open(path); err == nil {
sc := bufio.NewScanner(f)
sc.Buffer(make([]byte, 64*1024), 1024*1024)
for sc.Scan() {
ev, ok := parseTalkerLogLine(sc.Text())
if !ok {
continue
}
if ev.Ts < now0-lastheardWindowSec {
if !ev.IsStop {
continue
}
delete(active, keyOf(ev.Callsign, ev.TG))
continue
}
k := keyOf(ev.Callsign, ev.TG)
if !ev.IsStop {
active[k] = mkLive(ev)
} else if st, was := active[k]; was {
if !existing[st.StartTs] { // already persisted by a previous run → don't duplicate
emit(st, ev.TG, ev.Ts)
}
delete(active, k)
}
}
f.Close()
log.Printf("logTailer: backfill done, %d in-flight talkers", len(active))
} else {
log.Printf("logTailer: cannot open %s for backfill: %v", path, err)
}
// 2) Tail mode: follow new lines, handle rotation.
for {
f, err := os.Open(path)
if err != nil {
time.Sleep(2 * time.Second)
continue
}
if _, err := f.Seek(0, io.SeekEnd); err != nil {
f.Close()
time.Sleep(2 * time.Second)
continue
}
fi0, _ := f.Stat()
reader := bufio.NewReader(f)
for {
line, err := reader.ReadString('\n')
if err == nil {
line = strings.TrimRight(line, "\r\n")
if ev, ok := parseTalkerLogLine(line); ok {
k := keyOf(ev.Callsign, ev.TG)
if !ev.IsStop {
lt := mkLive(ev)
active[k] = lt
// Live only (not backfill): announce the SVX talker to the mesh.
xlinkPublishTalker(lt.Callsign, lt.System, lt.Trunk, lt.Node, ev.TG, lt.Network, "start", ev.Ts)
} else if st, was := active[k]; was {
xlinkPublishTalker(st.Callsign, st.System, st.Trunk, st.Node, ev.TG, st.Network, "stop", ev.Ts)
emit(st, ev.TG, ev.Ts)
delete(active, k)
} else {
lt := mkLive(ev)
cs3, loc3 := bridgeIdentity(lt, ev.TG)
writeLastheardEvent(rdb, lastheardEvent{
Ts: ev.Ts,
Callsign: cs3,
Trunk: lt.Trunk,
TG: ev.TG,
Duration: 1,
Location: loc3,
Source: sourceLabel(ev.IsTrunk),
Node: lt.Node,
System: lt.System,
Network: lt.Network,
})
}
}
continue
}
if err == io.EOF {
time.Sleep(300 * time.Millisecond)
fi1, sErr := os.Stat(path)
if sErr == nil && fi0 != nil {
if !os.SameFile(fi0, fi1) || fi1.Size() < fi0.Size() {
f.Close()
break
}
}
continue
}
log.Printf("logTailer read error: %v", err)
f.Close()
break
}
time.Sleep(500 * time.Millisecond)
}
}
func extractTalkerInfo(raw json.RawMessage) (talking bool, tg int, location, rxName string, siglev float64) {
var ni nodeInfo
if err := json.Unmarshal(raw, &ni); err != nil {
return
}
talking = ni.IsTalker
tg = ni.TG
location = ni.NodeLocation
if len(ni.QTH) > 0 {
for _, q := range ni.QTH {
if location == "" && q.Name != "" {
location = q.Name
}
for _, rx := range q.RX {
if rx.Active {
if rx.Name != "" {
rxName = rx.Name
if q.Name != "" {
rxName = rx.Name + " " + q.Name
}
}
siglev = rx.Siglev
return
}
}
}
}
return
}
func diffSets(prev, now map[string]struct{}) (added []string, removed []string) {
for k := range now {
if _, ok := prev[k]; !ok {
added = append(added, k)
}
}
for k := range prev {
if _, ok := now[k]; !ok {
removed = append(removed, k)
}
}
sort.Strings(added)
sort.Strings(removed)
return
}
func fetchReflectorStatus(url string) ([]byte, string, error) {
try := func(u string) ([]byte, string, error) {
b, err := fetch(u)
if err != nil {
return nil, u, err
}
if !json.Valid(b) {
return nil, u, fmt.Errorf("not json (http 200)")
}
var m map[string]json.RawMessage
if err := json.Unmarshal(b, &m); err != nil {
return nil, u, err
}
if _, ok := m["nodes"]; !ok {
return nil, u, fmt.Errorf("missing top-level 'nodes'")
}
return b, u, nil
}
body, used, err := try(url)
if err == nil {
return body, used, nil
}
alt := altStatusURL(url)
if alt == "" || alt == url {
return nil, used, err
}
return try(alt)
}
func altStatusURL(url string) string {
if strings.HasSuffix(url, "/status") {
return url + "/json"
}
if strings.HasSuffix(url, "/status/json") {
return strings.TrimSuffix(url, "/json")
}
if strings.HasSuffix(url, "/status/") {
return strings.TrimSuffix(url, "/") + "/json"
}
if strings.HasSuffix(url, "/status/json/") {
alt := strings.TrimSuffix(url, "/")
alt = strings.TrimSuffix(alt, "/json")
return alt
}
return ""
}
func fetch(url string) ([]byte, error) {
c := &http.Client{Timeout: 2 * time.Second}
resp, err := c.Get(url)
if err != nil {
return nil, err
}
defer resp.Body.Close()
if resp.StatusCode != 200 {
_, _ = io.Copy(io.Discard, resp.Body)
return nil, fmt.Errorf("http %d", resp.StatusCode)
}
return io.ReadAll(resp.Body)
}
func hash(b []byte) string {
sum := sha256.Sum256(b)
return hex.EncodeToString(sum[:])
}
func securityHeaders(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("X-Content-Type-Options", "nosniff")
w.Header().Set("X-Frame-Options", "DENY")
w.Header().Set("Referrer-Policy", "no-referrer")
next.ServeHTTP(w, r)
})
}
func env(k, def string) string {
v := os.Getenv(k)
if v == "" {
return def
}
return v
}
func envBool(k string, def bool) bool {
v := strings.TrimSpace(strings.ToLower(os.Getenv(k)))
if v == "" {
return def
}
switch v {
case "1", "true", "yes", "y", "on":
return true
case "0", "false", "no", "n", "off":
return false
default:
return def
}
}
func envDur(k string, def time.Duration) time.Duration {
v := os.Getenv(k)
if v == "" {
return def
}
d, err := time.ParseDuration(v)
if err != nil {
return def
}
return d
}
func envInt(k string, def int) int {
v := os.Getenv(k)
if v == "" {
return def
}
n, err := strconv.Atoi(strings.TrimSpace(v))
if err != nil {
return def
}
return n
}