Add svxdr: the live-data backend (nodelist + proxy; astmeta/xlink optional), config example, systemd unit, INSTALL section 9

svxdr sources from the internal repo with AGPL headers; install/svxdr.json.example is a plain-reflector
layout (reflector 8099 -> proxy 8090 -> nodelist 8098) with CHANGEME secrets and no site values.

Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
2026-09-27 00:48:43 +02:00
co-authored by Claude Fable 5.1
parent 3322a9f625
commit 4e43e69583
18 changed files with 7763 additions and 21 deletions
+634
View File
@@ -0,0 +1,634 @@
// svxdr — live-data daemon for SvxPortal (nodelist + proxy + astmeta + xlink)
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
// Package astmeta is the svxdr astmeta subsystem — AllStar talker metadata,
// folded in from the standalone svx-web/astmeta. It polls AMI (rpt xnode) for
// keyed remote nodes, resolves callsign/system, feeds svxlink UsrpLogic (USRP),
// optionally MQTT, and POSTs talker-meta to the nodelist/proxy (kept for the
// REMOTE case, e.g. astmeta on the allstar box; when co-located a later phase
// shares the store directly). Logic preserved exactly, including the fan-out
// allstar remap. Config now comes from svxdr.json (astmeta + nodes + redis).
package astmeta
import (
"bufio"
"bytes"
"context"
"encoding/binary"
"encoding/json"
"fmt"
"io"
"log"
"net"
"net/http"
"os"
"regexp"
"strconv"
"strings"
"sync"
"time"
mqtt "github.com/eclipse/paho.mqtt.golang"
"github.com/redis/go-redis/v9"
"svxportal/svxdr/config"
)
// settings holds the astmeta-relevant config, derived from svxdr.json.
type settings struct {
AmiHost, AmiPort, AmiUser, AmiSecret string
IgnoreNodes map[string]bool
PollInterval, HangTime time.Duration
Network string
Target, Format, SpaceReplace string
RefreshInterval time.Duration
LookupEnabled bool
LookupURL, LookupFile string
CacheTTL time.Duration
RedisCache bool
RedisPrefix string
Redis config.Redis
MqttEnabled bool
MqttBroker, MqttUser, MqttPass, MqttPrefix string
NodelistURL, NodelistToken string
NodelistPeers []metaTarget
Via string
// per-node, from cfg.Nodes
WatchNodes []string
NodeTg map[string]string
NodeVia map[string]string
NodeAllstar map[string]string
Names map[string]string
ownNodes map[string]bool
}
func (s *settings) tgFor(wn string) string { return s.NodeTg[wn] }
func (s *settings) viaFor(wn string) string {
if v := s.NodeVia[wn]; v != "" {
return v
}
return s.Via
}
func newSettings(c *config.Config) *settings {
a := c.Astmeta
s := &settings{
AmiHost: orDef(a.AmiHost, "127.0.0.1"), AmiPort: orDef(a.AmiPort, "5038"),
AmiUser: a.AmiUser, AmiSecret: a.AmiSecret,
IgnoreNodes: map[string]bool{},
PollInterval: orDur(a.PollInterval.D(), time.Second),
HangTime: a.HangTime.D(),
Network: orDef(a.Network, "AllStar"),
Target: a.UsrpTarget, // empty = USRP sink disabled (newSender returns nil)
Format: orDef(a.Format, "info"),
SpaceReplace: orDef(a.SpaceReplace, "-"),
RefreshInterval: a.RefreshInterval.D(),
LookupEnabled: a.LookupEnabled,
LookupURL: a.LookupURL, LookupFile: a.LookupFile,
CacheTTL: orDur(a.CacheTTL.D(), 24*time.Hour),
RedisCache: a.RedisCache,
RedisPrefix: orDef(a.RedisCachePrefix, "astmeta:node:"),
Redis: c.Redis,
MqttEnabled: a.MqttEnabled, MqttBroker: a.MqttBroker, MqttUser: a.MqttUser,
MqttPass: a.MqttPass, MqttPrefix: a.MqttPrefix,
NodelistURL: a.NodelistURL, NodelistToken: a.NodelistToken,
NodeTg: map[string]string{},
NodeVia: map[string]string{},
NodeAllstar: map[string]string{},
Names: map[string]string{},
ownNodes: map[string]bool{},
}
s.Via = "" // global via fallback is unused now that nodes carry their own via
for _, n := range a.IgnoreNodes {
if n = strings.TrimSpace(n); n != "" {
s.IgnoreNodes[n] = true
}
}
for _, p := range a.NodelistPeers {
if p.URL != "" {
s.NodelistPeers = append(s.NodelistPeers, metaTarget{URL: p.URL, Token: p.Token})
}
}
// per-node bindings from nodes[] (the source of truth)
for _, e := range c.Nodes {
if !e.IsEnabled() || e.SvxNode == 0 {
continue
}
wn := strconv.Itoa(e.SvxNode)
s.WatchNodes = append(s.WatchNodes, wn)
s.ownNodes[wn] = true
if e.TG != 0 {
s.NodeTg[wn] = strconv.Itoa(e.TG)
}
via := e.Via
if via == "" {
via = e.Callsign
}
if via != "" {
s.NodeVia[wn] = via
}
if e.Allstar != 0 {
s.NodeAllstar[wn] = strconv.Itoa(e.Allstar)
if e.System != "" {
s.Names[strconv.Itoa(e.Allstar)] = e.System
}
}
}
return s
}
func orDef(v, d string) string {
if v == "" {
return d
}
return v
}
func orDur(v, d time.Duration) time.Duration {
if v <= 0 {
return d
}
return v
}
func sleepCtx(ctx context.Context, d time.Duration) bool {
t := time.NewTimer(d)
defer t.Stop()
select {
case <-ctx.Done():
return false
case <-t.C:
return true
}
}
// ---------------------------------------------------------------- AMI --------
type ami struct {
s *settings
conn net.Conn
rd *bufio.Reader
}
func (a *ami) connect() error {
conn, err := net.DialTimeout("tcp", net.JoinHostPort(a.s.AmiHost, a.s.AmiPort), 5*time.Second)
if err != nil {
return err
}
a.conn = conn
a.rd = bufio.NewReader(conn)
a.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
if _, err := a.rd.ReadString('\n'); err != nil {
conn.Close()
return fmt.Errorf("ami banner: %w", err)
}
resp, err := a.request(fmt.Sprintf(
"Action: Login\r\nUsername: %s\r\nSecret: %s\r\nEvents: off\r\n\r\n",
a.s.AmiUser, a.s.AmiSecret))
if err != nil {
conn.Close()
return err
}
if !strings.Contains(resp, "Success") {
conn.Close()
return fmt.Errorf("ami login failed: %s", strings.TrimSpace(resp))
}
return nil
}
func (a *ami) request(action string) (string, error) {
a.conn.SetWriteDeadline(time.Now().Add(5 * time.Second))
if _, err := io.WriteString(a.conn, action); err != nil {
return "", err
}
var b strings.Builder
follows := false
for {
a.conn.SetReadDeadline(time.Now().Add(5 * time.Second))
line, err := a.rd.ReadString('\n')
if err != nil {
return "", err
}
b.WriteString(line)
t := strings.TrimRight(line, "\r\n")
if strings.HasPrefix(t, "Response: Follows") {
follows = true
}
if follows {
if strings.Contains(t, "--END COMMAND--") {
follows = false
}
continue
}
if t == "" {
return b.String(), nil
}
}
}
func (a *ami) alinks(node string) ([]string, error) {
resp, err := a.request(fmt.Sprintf("Action: Command\r\nCommand: rpt xnode %s\r\n\r\n", node))
if err != nil {
return nil, err
}
for _, line := range strings.Split(resp, "\n") {
line = strings.TrimPrefix(strings.TrimSpace(line), "Output: ")
if v, ok := strings.CutPrefix(line, "RPT_ALINKS="); ok {
parts := strings.Split(strings.TrimSpace(v), ",")
if len(parts) > 1 {
return parts[1:], nil
}
return nil, nil
}
}
return nil, fmt.Errorf("no RPT_ALINKS in xnode output for %s", node)
}
// ------------------------------------------------------------- resolver ------
var astdbLine = regexp.MustCompile(`^([0-9]+)\|([^|]*)\|([^|]*)\|(.*)$`)
type resolver struct {
s *settings
ctx context.Context
rdb *redis.Client
mu sync.RWMutex
astdb map[string][2]string
last time.Time
}
func newResolver(ctx context.Context, s *settings) *resolver {
r := &resolver{s: s, ctx: ctx, astdb: map[string][2]string{}}
if s.RedisCache {
opt := &redis.Options{Password: s.Redis.Password, DB: s.Redis.DB}
if s.Redis.Socket != "" {
opt.Network, opt.Addr = "unix", s.Redis.Socket
} else {
opt.Addr = orDef(s.Redis.Addr, "127.0.0.1:6379")
}
r.rdb = redis.NewClient(opt)
}
return r
}
func (r *resolver) refresh() {
if !r.s.LookupEnabled || time.Since(r.last) < r.s.CacheTTL {
return
}
var rd io.ReadCloser
var err error
switch {
case r.s.LookupFile != "":
rd, err = os.Open(r.s.LookupFile)
case r.s.LookupURL != "":
var resp *http.Response
cl := &http.Client{Timeout: 60 * time.Second}
resp, err = cl.Get(r.s.LookupURL)
if err == nil {
rd = resp.Body
}
default:
r.last = time.Now()
return
}
if err != nil {
log.Printf("astmeta lookup: refresh failed: %v", err)
r.last = time.Now().Add(-r.s.CacheTTL + 5*time.Minute)
return
}
defer rd.Close()
m := map[string][2]string{}
sc := bufio.NewScanner(rd)
sc.Buffer(make([]byte, 1024*1024), 1024*1024)
for sc.Scan() {
if f := astdbLine.FindStringSubmatch(sc.Text()); f != nil {
m[f[1]] = [2]string{f[2], f[3]}
}
}
if len(m) > 0 {
r.mu.Lock()
r.astdb = m
r.mu.Unlock()
log.Printf("astmeta lookup: astdb loaded, %d nodes", len(m))
}
r.last = time.Now()
}
func (r *resolver) Resolve(node string) (call, system string) {
system = r.s.Names[node]
call = node
if r.rdb != nil {
if v, err := r.rdb.Get(r.ctx, r.s.RedisPrefix+node).Result(); err == nil && v != "" {
return v, system
}
}
r.refresh()
r.mu.RLock()
e, ok := r.astdb[node]
r.mu.RUnlock()
if ok && e[0] != "" {
if r.rdb != nil {
r.rdb.Set(r.ctx, r.s.RedisPrefix+node, e[0], r.s.CacheTTL)
}
call = e[0]
}
return call, system
}
// ---------------------------------------------------------------- sender -----
type sender struct {
s *settings
conn net.Conn
seq uint32
}
func newSender(s *settings) (*sender, error) {
if s.Target == "" {
return nil, nil // USRP sink optional
}
conn, err := net.Dial("udp", s.Target)
if err != nil {
return nil, err
}
return &sender{s: s, conn: conn}, nil
}
func (s *sender) header(utype uint32) []byte {
h := make([]byte, 32)
copy(h[0:4], "USRP")
binary.LittleEndian.PutUint32(h[4:8], s.seq)
binary.LittleEndian.PutUint32(h[20:24], utype)
s.seq++
return h
}
func (s *sender) SendInfo(call, node, tg string) error {
payload := map[string]any{
"ab": map[string]string{"version": "astmeta"},
"digital": map[string]string{
"gw": node, "rpt": node, "tg": tg, "ts": "1", "cc": "1",
"call": call, "ambe_mode": "USRP",
},
"last_tune": tg,
}
j, err := json.Marshal(payload)
if err != nil {
return err
}
frame := append(s.header(2), []byte("INFO:")...)
frame = append(frame, j...)
_, err = s.conn.Write(frame)
return err
}
func (s *sender) SendTlv(call, node, tg string) error {
if len(call) > 8 {
call = call[:8]
}
tgn, _ := strconv.Atoi(tg)
nodeNum, _ := strconv.Atoi(node)
body := make([]byte, 2+3+4+3+1+1+306)
body[0] = 0x08
body[1] = byte(len(call) + 13)
binary.LittleEndian.PutUint32(body[5:9], uint32(nodeNum))
body[9] = byte(tgn >> 16)
body[10] = byte(tgn >> 8)
body[11] = byte(tgn)
body[12] = 1
body[13] = 1
copy(body[14:], call)
frame := append(s.header(2), body...)
_, err := s.conn.Write(frame)
return err
}
func (s *sender) Send(call, node, tg string) error {
call = strings.Join(strings.Fields(call), s.s.SpaceReplace)
if s.s.Format == "tlv" {
return s.SendTlv(call, node, tg)
}
return s.SendInfo(call, node, tg)
}
// ------------------------------------------------------------- publisher -----
type publisher struct {
s *settings
cl mqtt.Client
}
func newPublisher(s *settings) *publisher {
if !s.MqttEnabled {
return nil
}
opts := mqtt.NewClientOptions().
AddBroker(s.MqttBroker).SetClientID("svxdr-astmeta").
SetUsername(s.MqttUser).SetPassword(s.MqttPass).
SetAutoReconnect(true).SetConnectRetry(true).
SetConnectRetryInterval(5 * time.Second)
cl := mqtt.NewClient(opts)
if t := cl.Connect(); t.Wait() && t.Error() != nil {
log.Printf("astmeta mqtt: connect failed: %v (will keep retrying)", t.Error())
}
return &publisher{s: s, cl: cl}
}
func (p *publisher) PublishMeta(call, node, system, tg, via, network string) {
topic := strings.TrimRight(p.s.MqttPrefix, "/") + "/talker/" + tg + "/meta"
j, _ := json.Marshal(map[string]any{
"callsign": call, "node": node, "system": system, "via": via,
"network": network, "ts": time.Now().UnixMilli(),
})
p.cl.Publish(topic, 0, false, j)
}
// --------------------------------------------------------------- poster ------
type metaTarget struct{ URL, Token string }
type poster struct {
targets []metaTarget
cl *http.Client
}
func newPoster(s *settings) *poster {
var targets []metaTarget
if s.NodelistURL != "" && s.NodelistToken != "" {
targets = append(targets, metaTarget{URL: s.NodelistURL, Token: s.NodelistToken})
}
targets = append(targets, s.NodelistPeers...)
if len(targets) == 0 {
return nil
}
return &poster{targets: targets, cl: &http.Client{Timeout: 5 * time.Second}}
}
func (p *poster) PostMeta(call, node, system, event string, tg int, via, network string) {
if tg <= 0 {
return
}
body, _ := json.Marshal(map[string]any{
"tg": tg, "event": event, "callsign": call, "node": node,
"system": system, "via": via, "network": network,
})
for _, t := range p.targets {
req, err := http.NewRequest(http.MethodPost, t.URL, bytes.NewReader(body))
if err != nil {
continue
}
req.Header.Set("Content-Type", "application/json")
if t.Token != "" {
req.Header.Set("X-Ingest-Token", t.Token)
}
resp, err := p.cl.Do(req)
if err != nil {
log.Printf("astmeta nodelist: post to %s failed: %v", t.URL, err)
continue
}
resp.Body.Close()
if resp.StatusCode >= 300 {
log.Printf("astmeta nodelist: post %s to %s returned HTTP %d", event, t.URL, resp.StatusCode)
}
}
}
// ------------------------------------------------------------------ Run ------
var alinkEntry = regexp.MustCompile(`^([0-9]+)([A-Z]+)$`)
// Run polls AMI for every watched node and emits talker metadata until ctx ends.
func Run(ctx context.Context, c *config.Config) error {
s := newSettings(c)
if len(s.WatchNodes) == 0 {
return fmt.Errorf("astmeta: no nodes to watch (nodes[] empty)")
}
if s.AmiUser == "" || s.AmiSecret == "" {
return fmt.Errorf("astmeta: ami_user/ami_secret required")
}
resolver := newResolver(ctx, s)
snd, err := newSender(s)
if err != nil {
return fmt.Errorf("astmeta usrp: %w", err)
}
pub := newPublisher(s)
post := newPoster(s)
log.Printf("astmeta: watching %v via AMI %s:%s (usrp=%v mqtt=%v post=%v)",
s.WatchNodes, s.AmiHost, s.AmiPort, snd != nil, pub != nil, post != nil)
a := &ami{s: s}
keyed := map[string]bool{}
unkeyedAt := map[string]time.Time{}
lastSent := map[string]time.Time{}
for {
if ctx.Err() != nil {
return nil
}
if a.conn == nil {
if err := a.connect(); err != nil {
log.Printf("astmeta ami: %v — retrying in 10s", err)
if !sleepCtx(ctx, 10*time.Second) {
return nil
}
continue
}
log.Printf("astmeta ami: connected")
}
if !sleepCtx(ctx, s.PollInterval) {
return nil
}
seen := map[string]bool{}
fail := false
for _, wn := range s.WatchNodes {
tg := s.tgFor(wn)
via := s.viaFor(wn)
tgNum, _ := strconv.Atoi(tg)
entries, err := a.alinks(wn)
if err != nil {
log.Printf("astmeta ami: %v — reconnecting", err)
a.conn.Close()
a.conn = nil
fail = true
break
}
for _, e := range entries {
f := alinkEntry.FindStringSubmatch(e)
if f == nil {
continue
}
node, flags := f[1], f[2]
if s.IgnoreNodes[node] {
continue
}
reportNode := node
if static := s.NodeAllstar[wn]; static != "" && node != static && s.ownNodes[node] {
reportNode = static
}
k := wn + "|" + node
isKeyed := strings.HasSuffix(flags, "K")
seen[k] = true
refresh := s.RefreshInterval > 0 && time.Since(lastSent[k]) >= s.RefreshInterval
if isKeyed {
delete(unkeyedAt, k)
if !keyed[k] || refresh {
call, system := resolver.Resolve(reportNode)
display := system
if display == "" {
display = call
}
if snd != nil {
if err := snd.Send(display, reportNode, tg); err != nil {
log.Printf("astmeta usrp: send failed: %v", err)
}
}
if !keyed[k] {
log.Printf("astmeta talker start: node %s call %s system %q tg %s via %s",
reportNode, call, system, tg, via)
}
if pub != nil {
pub.PublishMeta(call, reportNode, system, tg, via, s.Network)
}
if post != nil {
post.PostMeta(call, reportNode, system, "start", tgNum, via, s.Network)
}
lastSent[k] = time.Now()
}
keyed[k] = true
} else if keyed[k] {
if _, pending := unkeyedAt[k]; !pending {
unkeyedAt[k] = time.Now()
}
if s.HangTime <= 0 || time.Since(unkeyedAt[k]) >= s.HangTime {
log.Printf("astmeta talker stop: node %s tg %s", reportNode, tg)
if post != nil {
post.PostMeta("", reportNode, "", "stop", tgNum, via, s.Network)
}
keyed[k] = false
delete(unkeyedAt, k)
}
}
}
}
if !fail {
for k := range keyed {
if !seen[k] {
delete(keyed, k)
delete(lastSent, k)
delete(unkeyedAt, k)
}
}
}
}
}
+412
View File
@@ -0,0 +1,412 @@
// svxdr — live-data daemon for SvxPortal (nodelist + proxy + astmeta + xlink)
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
// Package config is the ONE config for svxdr — the merged svx daemon
// (astmeta + nodelist + proxy + xlink). Lives at /etc/svxlink/svxdr.json.
//
// One file, sectioned per subsystem plus shared sections (redis, nodes, trunk,
// lastheard). Read at start and on reload (SIGHUP / file-watch) and held in
// memory. `modules` toggles which subsystems run on this box, so the same
// binary runs all of them on speed/aldmr and only astmeta on the allstar box.
//
// Secrets carry an @@PLACEHOLDER@@ that deploy-remote.sh --conf fills from the
// live file per box, so the structure is one file but secrets differ per host.
package config
import (
"bufio"
"encoding/json"
"os"
"strconv"
"strings"
"time"
)
// DefaultPath is where the merged config lives.
const DefaultPath = "/etc/svxlink/svxdr.json"
// Config is the whole svxdr.json.
type Config struct {
Modules Modules `json:"modules"` // per-box on/off
Redis Redis `json:"redis"` // shared
Nodes []Node `json:"nodes"` // shared bridge map
Trunk Trunk `json:"trunk"` // shared chan_svx_sat trunk
Lastheard Lastheard `json:"lastheard"` // shared render config
Astmeta Astmeta `json:"astmeta"`
Nodelist Nodelist `json:"nodelist"`
Proxy Proxy `json:"proxy"`
Xlink Xlink `json:"xlink"`
Tglist Tglist `json:"tglist"` // shared canonical TG->name list source
}
// Modules toggles which subsystems this box runs. Unset (all false) is treated
// as "run nothing" — a box MUST opt in, so a fresh/empty file never silently
// starts services.
type Modules struct {
Astmeta bool `json:"astmeta"`
Nodelist bool `json:"nodelist"`
Proxy bool `json:"proxy"`
Xlink bool `json:"xlink"`
}
// Redis — shared connection (as today).
type Redis struct {
Socket string `json:"socket"` // /run/redis/redis.sock
Addr string `json:"addr"` // host:port (if not socket)
Password string `json:"password"` // @@PLACEHOLDER@@ per box
DB int `json:"db"`
// key namespace (kept configurable, defaults mirror today's env)
StatusKey string `json:"status_key"`
StatusHashKey string `json:"status_hash_key"`
PubsubChan string `json:"pubsub_chan"`
NodesOnlineKey string `json:"nodes_online_key"`
NodeKeyPrefix string `json:"node_key_prefix"`
NodeConnectChan string `json:"node_connect_chan"`
NodeDisconnectChan string `json:"node_disconnect_chan"`
NodeUpdateChan string `json:"node_update_chan"`
LastheardKey string `json:"lastheard_key"`
}
// Node — one AllStar↔reflector bridge node (the nodes.json bridge map).
type Node struct {
SvxNode int `json:"svx_node"`
TG int `json:"tg"`
Allstar int `json:"allstar"`
System string `json:"system"`
Via string `json:"via"`
Callsign string `json:"callsign"`
Location string `json:"location,omitempty"`
// Group = the shared reflector login these per-TG nodes share in the /status
// data (e.g. "KT4ROY-ASL", "KT4ROY-DMR"). Config-driven: any group works the
// same, nothing about ASL/DMR/etc is hardcoded in the web. Nodes with a Group
// are rendered per-TG under that group's trunk.
Group string `json:"group,omitempty"`
// Mode = free-text type shown for this node (AllStar, DMR, YSF, SVX, or anything
// you write). Config wins; empty falls back to the data-derived type. No hardcode.
Mode string `json:"mode,omitempty"`
Enabled *bool `json:"enabled,omitempty"` // nil/true = enabled
}
func (n Node) IsEnabled() bool { return n.Enabled == nil || *n.Enabled }
// Trunk — the shared chan_svx_sat trunk (for the Asterisk generator).
type Trunk struct {
SatelliteID string `json:"satellite_id"`
Host string `json:"host"`
Port int `json:"port"`
// ConfPath = chan_svx_sat's svx_sat.conf. Last Heard reads the per-node
// reflector name (base callsign + suffix) straight from it, so the name has
// ONE source and is never duplicated here. Optional; defaults to
// /etc/asterisk/svx_sat.conf.
ConfPath string `json:"conf_path"`
}
// Lastheard — how bridge/AllStar rows render.
type Lastheard struct {
BridgeLocationFormat string `json:"bridge_location_format"`
BridgeLocationFormatNoSystem string `json:"bridge_location_format_no_system"`
// reflector_names is derived from nodes[] (via/callsign/allstar -> system)
// but an explicit map here can still add names nodes[] doesn't cover.
ReflectorNames map[string]string `json:"reflector_names,omitempty"`
}
// Astmeta — AllStar AMI poller settings (was /etc/astmeta.conf, minus the
// per-node bindings which now live in nodes[]/[names]).
type Astmeta struct {
AmiHost string `json:"ami_host"`
AmiPort string `json:"ami_port"`
AmiUser string `json:"ami_user"`
AmiSecret string `json:"ami_secret"` // @@PLACEHOLDER@@
IgnoreNodes []string `json:"ignore_nodes"`
PollInterval Duration `json:"poll_interval"`
HangTime Duration `json:"hang_time"`
Network string `json:"network"` // ingress mode label (default AllStar)
// USRP sink (svxlink UsrpLogic), MQTT publish, lookup — kept as today.
UsrpTarget string `json:"usrp_target"`
Format string `json:"format"`
SpaceReplace string `json:"space_replace"`
RefreshInterval Duration `json:"refresh_interval"`
LookupEnabled bool `json:"lookup_enabled"`
LookupURL string `json:"lookup_url"`
LookupFile string `json:"lookup_file"`
CacheTTL Duration `json:"cache_ttl"`
// resolver node->callsign cache in the shared redis (astmeta.conf [redis]).
RedisCache bool `json:"redis_cache"`
RedisCachePrefix string `json:"redis_cache_prefix"`
MqttEnabled bool `json:"mqtt_enabled"`
MqttBroker string `json:"mqtt_broker"`
MqttUser string `json:"mqtt_user"`
MqttPass string `json:"mqtt_pass"` // @@PLACEHOLDER@@
MqttPrefix string `json:"mqtt_prefix"`
// /api/talker-meta targets (local nodelist/proxy handled in-memory when
// co-located; these are for a REMOTE svxdr, e.g. astmeta on the allstar box).
NodelistURL string `json:"nodelist_url"`
NodelistToken string `json:"nodelist_token"` // @@PLACEHOLDER@@
NodelistPeers []NodeMetaURL `json:"nodelist_peers"`
}
type NodeMetaURL struct {
URL string `json:"url"`
Token string `json:"token"`
}
// Nodelist — reflector status crawl + lastheard + talker-meta API.
type Nodelist struct {
ListenAddr string `json:"listen_addr"`
ReflectorStatus string `json:"reflector_status_url"`
MonitorStatusURL string `json:"monitor_status_url"`
Debug bool `json:"debug"`
PollInterval Duration `json:"poll_interval"`
ReflectorLogPath string `json:"reflector_log_path"`
NetworkJSONPath string `json:"network_json_path"`
AstdbURL string `json:"astdb_url"`
TalkerMetaTTL Duration `json:"talker_meta_ttl"`
TalkerIngestToken string `json:"talker_ingest_token"` // @@PLACEHOLDER@@
AllstarNodesTTL Duration `json:"allstar_nodes_ttl"`
NodeOnlineTTL Duration `json:"node_online_ttl"`
NodeTTL Duration `json:"node_ttl"`
// nodelist's own MQTT publish of reflector status (MQTT_* env).
MqttBroker string `json:"mqtt_broker"`
MqttUser string `json:"mqtt_user"`
MqttPass string `json:"mqtt_pass"`
MqttPrefix string `json:"mqtt_prefix"`
// How often a REMOTE Monitor->Nodes read hits the master status. Kept gentle so
// a shared hub is never hammered (local reads stay fast).
MonitorInterval Duration `json:"monitor_interval"`
// external reflector federation (the "which other reflectors we crawl" list)
External External `json:"external"`
Federation Federation `json:"federation"`
}
type External struct {
PullURL string `json:"pull_url"`
PullScheme string `json:"pull_scheme"`
PullPort string `json:"pull_port"`
PullPath string `json:"pull_path"`
PullInterval Duration `json:"pull_interval"`
StatusPorts []string `json:"status_ports"`
StatusPath string `json:"status_path"`
ProxyPath string `json:"proxy_path"`
DetailPath string `json:"detail_path"`
MaxRefs int `json:"max_refs"`
StaleTTL Duration `json:"stale_ttl"`
}
type Federation struct {
Seeds []string `json:"seeds"`
AggregateURL string `json:"aggregate_url"`
AggregateEvery Duration `json:"aggregate_interval"`
}
// Proxy — enriched status proxy.
type Proxy struct {
Listen string `json:"listen"`
UpstreamURL string `json:"reflector_status_url"`
CacheTTL Duration `json:"cache_ttl"`
FetchTimeout Duration `json:"fetch_timeout"`
MetaTTL Duration `json:"meta_ttl"`
IngestToken string `json:"ingest_token"` // @@PLACEHOLDER@@
UpstreamPeer string `json:"upstream_status_url"`
ForwardTalker string `json:"forward_talker_url"`
SyncInterval Duration `json:"sync_interval"`
RemoteTTL Duration `json:"remote_ttl"`
}
// Xlink — mesh talker publish over MQTT (was the XLINK_* env in nodelist).
type Xlink struct {
Broker string `json:"broker"`
User string `json:"user"`
Pass string `json:"pass"` // @@PLACEHOLDER@@
Topic string `json:"topic"`
Origin string `json:"origin"`
}
// Tglist — the shared canonical TG -> name/system list, edited in svx-web and
// served at /tglist.php. Every program points its own URL here (per-box
// override, no hardcoding). Empty URL = feature off: fall back to the per-box
// nodes[].system. The list is cached to disk so a fetch failure keeps the last
// good copy (consumers stay independent of the list host being reachable).
type Tglist struct {
File string `json:"file"` // local JSON file (SVX source of truth, /etc/svxlink/tglist.json) — read directly, no HTTP
URL string `json:"url"` // alternative: fetch over HTTP (e.g. https://host/tglist.php). File wins if both set.
RefreshInterval Duration `json:"refresh_interval"` // default 15m
CacheFile string `json:"cache_file"` // disk fallback for the URL path (default /var/lib/svxdr/tglist.json)
}
// Duration is a time.Duration that (un)marshals as a Go duration string ("2s").
type Duration time.Duration
func (d Duration) D() time.Duration { return time.Duration(d) }
func (d *Duration) UnmarshalJSON(b []byte) error {
var s string
if err := json.Unmarshal(b, &s); err != nil {
// also accept a bare number = seconds
var n float64
if err2 := json.Unmarshal(b, &n); err2 != nil {
return err
}
*d = Duration(time.Duration(n * float64(time.Second)))
return nil
}
if s == "" {
*d = 0
return nil
}
v, err := time.ParseDuration(s)
if err != nil {
return err
}
*d = Duration(v)
return nil
}
func (d Duration) MarshalJSON() ([]byte, error) {
return json.Marshal(time.Duration(d).String())
}
// Load reads and parses the config file.
func Load(path string) (*Config, error) {
b, err := os.ReadFile(path)
if err != nil {
return nil, err
}
var c Config
if err := json.Unmarshal(b, &c); err != nil {
return nil, err
}
// AllStar/sat-node config lives in chan_svx_sat's svx_sat.conf (ONE file for
// everything AllStar). Parse it into Node entries and merge, so nodes[] in
// svxdr.json only needs the non-AllStar nodes (e.g. the DMR side). Every
// downstream subsystem (astmeta/nodelist/proxy/web) then reads one source.
c.Nodes = append(c.Nodes, loadSatNodes(c.Trunk.ConfPath)...)
return &c, nil
}
// loadSatNodes reads chan_svx_sat's svx_sat.conf and turns each node section into
// a Node: SvxNode = the section id, TG/Allstar/System from its keys, and the
// display name (Via = Callsign) computed exactly like chan_svx_sat's svx_eff_cs
// (bare trunk/node callsign, node designator stripped at the first '-', plus the
// per-node suffix). Group = the trunk's satellite_id. Missing file -> nil.
func loadSatNodes(confPath string) []Node {
if confPath == "" {
confPath = "/etc/asterisk/svx_sat.conf"
}
f, err := os.Open(confPath)
if err != nil {
return nil
}
defer f.Close()
trunkCS := map[string]string{} // trunk name -> callsign
trunkSat := map[string]string{} // trunk name -> satellite_id
type nc struct {
trunk, callsign, suffix, system string
tg, allstar int
}
nodes := map[string]*nc{}
order := []string{}
cur, curTrunk := "", ""
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, "]") {
sec := strings.TrimSpace(line[1 : len(line)-1])
if strings.HasPrefix(sec, "trunk:") {
curTrunk, cur = strings.TrimPrefix(sec, "trunk:"), ""
} else {
curTrunk, cur = "", sec
if nodes[cur] == nil {
nodes[cur] = &nc{}
order = append(order, cur)
}
}
continue
}
eq := strings.IndexByte(line, '=')
if eq < 0 {
continue
}
k := strings.TrimSpace(line[:eq])
v := strings.TrimSpace(line[eq+1:])
switch {
case curTrunk != "":
switch k {
case "callsign":
trunkCS[curTrunk] = v
case "satellite_id":
trunkSat[curTrunk] = v
}
case cur != "":
switch k {
case "trunk":
nodes[cur].trunk = v
case "callsign":
nodes[cur].callsign = v
case "suffix":
nodes[cur].suffix = v
case "system":
nodes[cur].system = v
case "tg":
nodes[cur].tg, _ = strconv.Atoi(v)
case "allstar":
nodes[cur].allstar, _ = strconv.Atoi(v)
}
}
}
var out []Node
for _, sec := range order {
n := nodes[sec]
if n.tg == 0 {
continue
}
cs := n.callsign
if cs == "" {
cs = trunkCS[n.trunk]
}
if cs == "" {
continue
}
name := satEffCS(cs, n.suffix)
svxNode, _ := strconv.Atoi(sec)
group := trunkSat[n.trunk]
if group == "" {
group = trunkCS[n.trunk]
}
out = append(out, Node{
SvxNode: svxNode, TG: n.tg, Allstar: n.allstar,
System: n.system, Via: name, Callsign: name,
Group: group, Mode: "AllStar",
})
}
return out
}
// satEffCS mirrors chan_svx_sat's svx_eff_cs: bare callsign (strip node
// designator at the first '-') + suffix; an alnum-leading suffix gets an auto "-".
// Empty suffix -> the callsign unchanged.
func satEffCS(callsign, suffix string) string {
if suffix == "" {
return callsign
}
base := callsign
if i := strings.IndexByte(base, '-'); i >= 0 {
base = base[:i]
}
c := suffix[0]
if (c >= '0' && c <= '9') || (c >= 'a' && c <= 'z') || (c >= 'A' && c <= 'Z') {
return base + "-" + suffix
}
return base + suffix
}
+16
View File
@@ -0,0 +1,16 @@
module svxportal/svxdr
go 1.24.4
require (
github.com/eclipse/paho.mqtt.golang v1.5.1
github.com/redis/go-redis/v9 v9.17.2
)
require (
github.com/cespare/xxhash/v2 v2.3.0 // indirect
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f // indirect
github.com/gorilla/websocket v1.5.3 // indirect
golang.org/x/net v0.44.0 // indirect
golang.org/x/sync v0.17.0 // indirect
)
+18
View File
@@ -0,0 +1,18 @@
github.com/bsm/ginkgo/v2 v2.12.0 h1:Ny8MWAHyOepLGlLKYmXG4IEkioBysk6GpaRTLC8zwWs=
github.com/bsm/ginkgo/v2 v2.12.0/go.mod h1:SwYbGRRDovPVboqFv0tPTcG1sN61LM1Z4ARdbAV9g4c=
github.com/bsm/gomega v1.27.10 h1:yeMWxP2pV2fG3FgAODIY8EiRE3dy0aeFYt4l7wh6yKA=
github.com/bsm/gomega v1.27.10/go.mod h1:JyEr/xRbxbtgWNi8tIEVPUYZ5Dzef52k01W3YH0H+O0=
github.com/cespare/xxhash/v2 v2.3.0 h1:UL815xU9SqsFlibzuggzjXhog7bL6oX9BbNZnL2UFvs=
github.com/cespare/xxhash/v2 v2.3.0/go.mod h1:VGX0DQ3Q6kWi7AoAeZDth3/j3BFtOZR5XLFGgcrjCOs=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f h1:lO4WD4F/rVNCu3HqELle0jiPLLBs70cWOduZpkS1E78=
github.com/dgryski/go-rendezvous v0.0.0-20200823014737-9f7001d12a5f/go.mod h1:cuUVRXasLTGF7a8hSLbxyZXjz+1KgoB3wDUb6vlszIc=
github.com/eclipse/paho.mqtt.golang v1.5.1 h1:/VSOv3oDLlpqR2Epjn1Q7b2bSTplJIeV2ISgCl2W7nE=
github.com/eclipse/paho.mqtt.golang v1.5.1/go.mod h1:1/yJCneuyOoCOzKSsOTUc0AJfpsItBGWvYpBLimhArU=
github.com/gorilla/websocket v1.5.3 h1:saDtZ6Pbx/0u+bgYQ3q96pZgCzfhKXGPqt7kZ72aNNg=
github.com/gorilla/websocket v1.5.3/go.mod h1:YR8l580nyteQvAITg2hZ9XVh4b55+EU/adAjf1fMHhE=
github.com/redis/go-redis/v9 v9.17.2 h1:P2EGsA4qVIM3Pp+aPocCJ7DguDHhqrXNhVcEp4ViluI=
github.com/redis/go-redis/v9 v9.17.2/go.mod h1:u410H11HMLoB+TP67dz8rL9s6QW2j76l0//kSOd3370=
golang.org/x/net v0.44.0 h1:evd8IRDyfNBMBTTY5XRF1vaZlD+EmWx6x8PkhR04H/I=
golang.org/x/net v0.44.0/go.mod h1:ECOoLqd5U3Lhyeyo/QDCEVQ4sNgYsqvCZ722XogGieY=
golang.org/x/sync v0.17.0 h1:l60nONMj9l5drqw6jlhIELNv9I0A4OFgRsG9k2oT9Ug=
golang.org/x/sync v0.17.0/go.mod h1:9KTHXmSnoGruLpwFjVSX0lNNA75CykiMECbovNTZqGI=
+132
View File
@@ -0,0 +1,132 @@
// svxdr — live-data daemon for SvxPortal (nodelist + proxy + astmeta + xlink)
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
// svxdr — the merged svx daemon: astmeta + nodelist + proxy + xlink in ONE
// process, each a subsystem goroutine, toggled per box by /etc/svxlink/svxdr.json.
//
// One binary, one config. Runs all subsystems on speed/aldmr; only astmeta on
// the allstar box. Co-located subsystems share memory (no localhost HTTP hop);
// the HTTP talker-meta ingest stays for a REMOTE astmeta feeding this box.
//
// Phase 1 scaffold: config + module toggles + lifecycle (start / SIGHUP reload /
// graceful stop). Subsystems are stubs here and are folded in one at a time
// (proxy, then astmeta, then nodelist+xlink) so the existing standalone
// binaries keep running until cutover.
package main
import (
"context"
"flag"
"log"
"os"
"os/signal"
"sync"
"syscall"
"svxportal/svxdr/astmeta"
"svxportal/svxdr/config"
"svxportal/svxdr/nodelist"
"svxportal/svxdr/proxy"
)
// module is one subsystem. Run blocks until ctx is cancelled; it should return
// nil on clean shutdown. reload is called (best-effort) when the config file
// changes so a running module can pick up new settings without a restart.
type module struct {
name string
enabled func(*config.Config) bool
run func(ctx context.Context, cfg *config.Config) error
}
var modules = []module{
{"proxy", func(c *config.Config) bool { return c.Modules.Proxy }, proxy.Run},
{"astmeta", func(c *config.Config) bool { return c.Modules.Astmeta }, astmeta.Run},
{"nodelist", func(c *config.Config) bool { return c.Modules.Nodelist }, nodelist.Run},
// xlink is not a separate process — it runs inside nodelist, gated by
// modules.xlink in svxdr.json (see nodelist/svxdrcfg.go).
}
func main() {
log.SetFlags(log.LstdFlags)
path := flag.String("config", "", "path to svxdr.json (default "+config.DefaultPath+" or $SVXDR_CONFIG)")
fAstmeta := flag.Bool("astmeta", false, "run astmeta (overrides modules in config)")
fNodelist := flag.Bool("nodelist", false, "run nodelist (overrides modules in config)")
fProxy := flag.Bool("proxy", false, "run proxy (overrides modules in config)")
fXlink := flag.Bool("xlink", false, "run xlink inside nodelist (overrides modules in config)")
flag.Parse()
cfgPath := *path
if cfgPath == "" {
if p := os.Getenv("SVXDR_CONFIG"); p != "" {
cfgPath = p
} else {
cfgPath = config.DefaultPath
}
}
cfg, err := config.Load(cfgPath)
if err != nil {
log.Fatalf("svxdr: config %s: %v", cfgPath, err)
}
// If ANY module flag is passed, the flags fully define the set to run on this
// box (config "modules" is overridden) — handy for per-box units and testing.
if *fAstmeta || *fNodelist || *fProxy || *fXlink {
cfg.Modules = config.Modules{Astmeta: *fAstmeta, Nodelist: *fNodelist, Proxy: *fProxy, Xlink: *fXlink}
log.Printf("svxdr: module set overridden by flags: astmeta=%v nodelist=%v proxy=%v xlink=%v",
*fAstmeta, *fNodelist, *fProxy, *fXlink)
}
ctx, stop := signal.NotifyContext(context.Background(), syscall.SIGINT, syscall.SIGTERM)
defer stop()
// SIGHUP -> reload config from disk and hand the new config to modules.
hup := make(chan os.Signal, 1)
signal.Notify(hup, syscall.SIGHUP)
var wg sync.WaitGroup
started := 0
for _, m := range modules {
if !m.enabled(cfg) {
continue
}
started++
wg.Add(1)
go func(m module) {
defer wg.Done()
log.Printf("svxdr: starting module %q", m.name)
if err := m.run(ctx, cfg); err != nil {
log.Printf("svxdr: module %q exited: %v", m.name, err)
}
}(m)
}
if started == 0 {
log.Printf("svxdr: no modules enabled in %s (set \"modules\": {...}) — idle", cfgPath)
} else {
log.Printf("svxdr: %d module(s) running from %s", started, cfgPath)
}
// lifecycle loop: reload on SIGHUP, exit on signal/context.
for {
select {
case <-ctx.Done():
log.Printf("svxdr: shutting down")
wg.Wait()
return
case <-hup:
nc, err := config.Load(cfgPath)
if err != nil {
log.Printf("svxdr: reload failed, keeping current config: %v", err)
continue
}
cfg = nc
log.Printf("svxdr: config reloaded from %s (modules pick up changes on next cycle)", cfgPath)
// NOTE: per-module live reload is wired as each subsystem is folded in.
}
}
}
+299
View File
@@ -0,0 +1,299 @@
// svxdr — live-data daemon for SvxPortal (nodelist + proxy + astmeta + xlink)
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
package nodelist
// Monitor → Trunks lists ONLY the trunks THIS reflector is configured to connect
// to — read straight from the local svxreflector.conf. Two config dialects exist
// in the wild and both are supported:
//
// 1) newer GEU fork:
// [ReflectorTrunk]
// Peers=SE-AUX
// [TrunkPeer#SE-AUX]
// Host=reflector.reflector-network.se
//
// 2) older fork:
// [TRUNK_US_SE]
// HOST=reflector.reflector-network.se
// PORT=5302
// REMOTE_PREFIX=240
//
// The configured trunk NAME is matched against the LOCAL reflector's /status to
// fill online/offline + node count. The host is used only for that match and is
// never sent to the client (host/IP must never be exposed).
import (
"bufio"
"encoding/json"
"net/http"
"os"
"sort"
"strings"
"sync"
"time"
)
// svxreflectorConf is declared in main.go (default /etc/svxlink/svxreflector.conf).
// satTG is one talkgroup carried by a satellite trunk (per-TG label + tg number).
type satTG struct {
Name string `json:"name"`
TG string `json:"tg"`
}
type localTrunk struct {
Name string `json:"name"`
Connected bool `json:"connected"`
Nodes int `json:"nodes"`
TGs []satTG `json:"tgs,omitempty"` // satellite trunks: the TGs it carries
}
type confTrunk struct {
name string
hosts []string // normalized, for status matching only — never exposed
}
// normTrunkHost strips scheme, path and port and lowercases a host.
func normTrunkHost(h string) string {
h = strings.ToLower(strings.TrimSpace(h))
if i := strings.Index(h, "://"); i >= 0 {
h = h[i+3:]
}
if i := strings.IndexByte(h, '/'); i >= 0 {
h = h[:i]
}
if i := strings.LastIndexByte(h, ':'); i >= 0 {
h = h[:i]
}
return strings.Trim(h, "[]")
}
// parseConfTrunks reads svxreflector.conf and returns the configured trunks
// (both dialects). Order is preserved; duplicates by name are dropped.
func parseConfTrunks(path string) []confTrunk {
f, err := os.Open(path)
if err != nil {
return nil
}
defer f.Close()
type section struct {
name string
kv map[string]string
}
var order []*section
byName := map[string]*section{}
var cur *section
sc := bufio.NewScanner(f)
for sc.Scan() {
line := strings.TrimSpace(sc.Text())
if line == "" || strings.HasPrefix(line, "#") || strings.HasPrefix(line, ";") {
continue
}
if strings.HasPrefix(line, "[") && strings.HasSuffix(line, "]") {
cur = &section{name: line[1 : len(line)-1], kv: map[string]string{}}
order = append(order, cur)
byName[cur.name] = cur
continue
}
if cur == nil {
continue
}
if i := strings.IndexByte(line, '='); i > 0 {
cur.kv[strings.ToUpper(strings.TrimSpace(line[:i]))] = strings.TrimSpace(line[i+1:])
}
}
var out []confTrunk
seen := map[string]bool{}
add := func(name, hostCSV string) {
name = strings.TrimSpace(name)
if name == "" || seen[name] {
return
}
seen[name] = true
var hosts []string
for _, h := range strings.Split(hostCSV, ",") {
if hh := normTrunkHost(h); hh != "" {
hosts = append(hosts, hh)
}
}
out = append(out, confTrunk{name: name, hosts: hosts})
}
// Dialect 1: [ReflectorTrunk] Peers=A,B -> [TrunkPeer#A] Host=...
if rt := byName["ReflectorTrunk"]; rt != nil {
for _, p := range strings.Split(rt.kv["PEERS"], ",") {
p = strings.TrimSpace(p)
if p == "" {
continue
}
host := ""
if peer := byName["TrunkPeer#"+p]; peer != nil {
host = peer.kv["HOST"]
}
add(p, host)
}
}
// Dialect 2: [TRUNK_xxx] HOST=...
for _, s := range order {
if strings.HasPrefix(strings.ToUpper(s.name), "TRUNK_") {
add(s.name, s.kv["HOST"])
}
}
return out
}
var localTrunkState = struct {
mu sync.RWMutex
data []localTrunk
src string
}{data: []localTrunk{}}
// buildLocalTrunks parses the conf and matches each trunk against the LOCAL
// reflector's /status (by name, then host) for online + node count.
func buildLocalTrunks(client *http.Client) {
conf := parseConfTrunks(svxreflectorConf)
type liveT struct {
connected bool
nodes int
host string
}
live := map[string]liveT{}
// Satellite trunks (chan_svx_sat) live status, keyed by reflector sat id.
type liveSat struct {
connected bool
nodes int
}
liveSats := map[string]liveSat{}
if resp, err := client.Get(reflectorURL); err == nil {
func() {
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return
}
var raw struct {
Trunks map[string]struct {
Host string `json:"host"`
Connected bool `json:"connected"`
Nodes []json.RawMessage `json:"nodes"`
} `json:"trunks"`
TrunksUDP []struct {
Name string `json:"name"`
Status bool `json:"status"`
Host string `json:"host"`
} `json:"trunks_UDP"`
Satellites map[string]struct {
Authenticated bool `json:"authenticated"`
Nodes []json.RawMessage `json:"nodes"`
} `json:"satellites"`
}
if json.NewDecoder(resp.Body).Decode(&raw) == nil {
for n, t := range raw.Trunks {
live[n] = liveT{connected: t.Connected, nodes: len(t.Nodes), host: normTrunkHost(t.Host)}
}
for id, s := range raw.Satellites {
liveSats[id] = liveSat{connected: s.Authenticated, nodes: len(s.Nodes)}
}
// trunks_UDP peers are named like "TrunkPeer#SE-AUX"; index them
// both raw and with the prefix stripped so a conf name matches.
for _, t := range raw.TrunksUDP {
v := liveT{connected: t.Status, host: normTrunkHost(t.Host)}
live[t.Name] = v
if s := strings.TrimPrefix(t.Name, "TrunkPeer#"); s != t.Name {
live[s] = v
}
}
}
}()
}
out := make([]localTrunk, 0, len(conf))
for _, c := range conf {
lt := localTrunk{Name: c.name}
if v, ok := live[c.name]; ok { // exact name match — most reliable
lt.Connected, lt.Nodes = v.connected, v.nodes
} else {
for _, v := range live { // fall back to host match
if v.host == "" {
continue
}
for _, h := range c.hosts {
if v.host == h || strings.Contains(v.host, h) || strings.Contains(h, v.host) {
lt.Connected, lt.Nodes = v.connected, v.nodes
}
}
}
}
out = append(out, lt)
}
// Satellite trunks (chan_svx_sat): ONE trunk per satellite, named by the
// satellite id (e.g. KT4ROY-ASL). All chan_svx_sat entries that share the same
// satellite_id are the SAME trunk carrying N talkgroups — they are grouped, not
// listed as one-trunk-per-TG. The per-TG labels (callsign + tg) are attached as
// TGs so the UI can tell the talkgroups apart under the single trunk name.
// Fully dynamic: the satellite set is the union of the configured [SATELLITE]
// ids in svxreflector.conf and any live-authenticated sat in the reflector
// /status, so new satellites (Chicago, …) appear automatically and offline ones
// still show. Secrets from [SATELLITE] are never read/exposed — only the id.
satSet := map[string]bool{}
for id := range liveSats {
satSet[id] = true
}
for _, id := range satelliteIDsFromReflectorConf(svxreflectorConf) {
satSet[id] = true
}
for _, id := range satTrunkIDs() { // configured satellites always show, even offline
satSet[id] = true
}
if len(satSet) > 0 {
ids := make([]string, 0, len(satSet))
for id := range satSet {
ids = append(ids, id)
}
sort.Strings(ids)
for _, id := range ids {
s := liveSats[id] // zero value (offline, 0 nodes) if not live
out = append(out, localTrunk{
Name: id, // trunk name = satellite id (KT4ROY-ASL)
Connected: s.connected,
Nodes: s.nodes,
TGs: satTGsForTrunk(id), // TGs + per-TG names straight from svxdr.json
})
}
}
localTrunkState.mu.Lock()
localTrunkState.data = out
localTrunkState.src = svxreflectorConf
localTrunkState.mu.Unlock()
}
func localTrunksStart(mux *http.ServeMux) {
client := &http.Client{Timeout: 8 * time.Second}
buildLocalTrunks(client)
go func() {
t := time.NewTicker(10 * time.Second)
defer t.Stop()
for range t.C {
buildLocalTrunks(client)
}
}()
mux.HandleFunc("/api/local-trunks", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
localTrunkState.mu.RLock()
b, _ := json.Marshal(map[string]interface{}{"trunks": localTrunkState.data, "source": localTrunkState.src})
localTrunkState.mu.RUnlock()
writeMaybeGzip(w, r, b)
})
}
+445
View File
@@ -0,0 +1,445 @@
// svxdr — live-data daemon for SvxPortal (nodelist + proxy + astmeta + xlink)
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
package nodelist
// Monitor Nodes, the simple way: read ONE reflector /status endpoint that
// already carries the whole picture (local nodes + every trunk with its nodes)
// and reshape it into {local, trunks:[{name,host,nodes}]}. No recursive crawl.
//
// Which URL is read is per-box config MONITOR_STATUS_URL (falls back to
// REFLECTOR_STATUS_URL):
// speed → https://reflector.sm2ampr.net/geu/status/ (the master's full view)
// aldmr → http://127.0.0.1:8080/status (its own reflector)
import (
"encoding/json"
"io"
"net/http"
"regexp"
"sort"
"strconv"
"strings"
"sync"
"time"
"svxportal/svxdr/statusnorm"
)
var monitorStatusURL = env("MONITOR_STATUS_URL", reflectorURL)
// monitorInterval is how often REMOTE status reads (a Monitor->Nodes URL or a
// trunk_status topology URL that points at another server) run. Gentle by default
// so a shared hub is never hammered; override via svxdr.json nodelist.monitor_interval.
// A LOCAL read (127.0.0.1) is free and refreshes fast — see localReadInterval.
var monitorInterval = 60 * time.Second
// localReadInterval: reading our OWN reflector costs nothing, so do it often.
var localReadInterval = 5 * time.Second
// isLocalURL reports whether a status URL points at this host (loopback). Local
// reads may be frequent; remote reads must be throttled to monitorInterval.
func isLocalURL(u string) bool {
return strings.Contains(u, "127.0.0.1") || strings.Contains(u, "localhost") || strings.Contains(u, "[::1]")
}
// readInterval picks the fast (local) or gentle (remote) cadence for a URL.
func readInterval(u string) time.Duration {
if isLocalURL(u) {
return localReadInterval
}
return monitorInterval
}
type monNode struct {
Callsign string `json:"callsign"`
TG string `json:"tg,omitempty"`
NodeClass string `json:"nodeClass,omitempty"`
Mode string `json:"mode,omitempty"`
Talker bool `json:"talker,omitempty"`
}
// uniquifyNames makes every node name in the view unique WITHOUT ever touching the
// Local (top-level) nodes — those are the reflector's own map keys, exactly what the
// Reflector clients view shows, so renaming them would make the two views disagree.
// Only trunk/satellite array nodes (which have no key and never appear in Reflector
// clients) are numbered, and only enough to stay clear of every name already taken
// (Local keys + earlier array nodes): SA7AUX -> SA7AUX-1, SA7AUX-2, …
func uniquifyNames(out *monStatus) {
taken := map[string]bool{}
for _, n := range out.Local { // canonical, never renamed
taken[n.Callsign] = true
}
next := map[string]int{}
for i := range out.Trunks {
for j := range out.Trunks[i].Nodes {
base := out.Trunks[i].Nodes[j].Callsign
if !taken[base] {
taken[base] = true
continue
}
for {
next[base]++
cand := base + "-" + strconv.Itoa(next[base])
if !taken[cand] {
taken[cand] = true
out.Trunks[i].Nodes[j].Callsign = cand
break
}
}
}
}
}
type monTrunk struct {
Name string `json:"name"`
Host string `json:"host,omitempty"`
Connected bool `json:"connected"`
Nodes []monNode `json:"nodes"`
}
type monStatus struct {
Local []monNode `json:"local"`
Trunks []monTrunk `json:"trunks"`
Source string `json:"source"`
UpdatedAt string `json:"updated_at"`
}
var monState = struct {
mu sync.RWMutex
data monStatus
}{}
// rawNode is one node as it appears either as a value in the top-level "nodes"
// object (keyed by callsign) or as an item in a trunk's "nodes" array.
type rawNode struct {
Callsign string `json:"callsign"`
SW string `json:"sw"`
TG json.Number `json:"tg"`
NodeClass string `json:"nodeClass"`
RestrictedTG bool `json:"restrictedTG"`
IsTalker bool `json:"isTalker"`
System string `json:"system"` // per-node link/type from the data (e.g. "DMR", "DMR/YSF", "DMR/Hytera")
Reflector string `json:"Reflector"` // which reflector this node is on (master status carries it)
MonitoredTGs []json.Number `json:"monitoredTGs"`
Links []struct {
LocalTg json.Number `json:"localTg"`
} `json:"links"`
}
// masterReflectorName picks a label for the monitored master's own nodes purely
// from the DATA (the Reflector field the nodes report) — no dependency on the
// local conf format, so every box derives it the same way.
func masterReflectorName(nodes map[string]json.RawMessage) string {
count := map[string]int{}
for _, raw := range nodes {
var r struct {
Reflector string `json:"Reflector"`
}
if json.Unmarshal(raw, &r) == nil && strings.TrimSpace(r.Reflector) != "" {
count[r.Reflector]++
}
}
best, n := "", 0
for k, c := range count {
if c > n {
best, n = k, c
}
}
return best
}
// monModeOf: real type from the software string (svx nodes carry no "mode").
func monModeOf(sw string) string {
s := strings.ToLower(sw)
switch {
case strings.Contains(s, "chan_pbx"):
return "PBX"
case strings.Contains(s, "chan_svx_sat"):
return "AllStar"
case strings.Contains(s, "chan_svx"):
return "AllStar"
case strings.Contains(s, "svxlink"):
return "SVX"
case strings.Contains(s, "mmdvm"), strings.Contains(s, "brandmeister"), strings.Contains(s, "dmr"):
return "DMR"
case strings.Contains(s, "ysf"):
return "YSF"
case strings.Contains(s, "nxdn"):
return "NXDN"
case strings.Contains(s, "m17"):
return "M17"
case sw != "":
return sw
default:
return ""
}
}
// A bridge node just links systems together (chan_svx asterisk, a DMR/trunk
// bridge, or an -AC/-DMR/-T suffix) — not a real user/radio client, so we never
// show it. chan_svx_sat IS a real AllStar node over the satellite trunk and is
// kept. Mirrors the TG Monitor's isBridge so both views show only real users.
var bridgeSuffix = regexp.MustCompile(`(?i)-(AC|DMR|T)$`)
func isBridgeNode(callsign, sw, nodeClass string) bool {
s := strings.ToLower(sw)
// AllStar radio-network peers ARE shown — the links to svx/dmr/ysf/nxdn/m17
// are exactly what the user wants visible under their node/trunk. chan_svx and
// chan_svx_sat both count. Only pure non-radio infrastructure stays hidden.
if strings.Contains(s, "chan_svx") || strings.Contains(s, "chan_pbx") { // chan_svx + chan_svx_sat + chan_pbx_svx (PBX)
return false
}
nc := strings.ToLower(nodeClass)
switch nc {
case "dmr", "ysf", "nxdn", "m17":
return false // radio-network peer — show
case "xlx", "mumble", "stream", "echolink", "sip", "zello":
return true // non-radio / D-Star infra — hide
}
// sw-based radio bridges (DMR_/YSF_/NXDN_/M17_Bridge) → show; the remaining
// "*bridge" (XLX_/Mumble_/Janus Bridge) is non-radio infra → hide.
if strings.Contains(s, "dmr") || strings.Contains(s, "ysf") ||
strings.Contains(s, "nxdn") || strings.Contains(s, "m17") {
return false
}
if strings.Contains(s, "bridge") {
return true
}
// pure infrastructure gateway callsigns are never real peers.
u := strings.ToUpper(callsign)
if strings.HasPrefix(u, "SIP") || strings.HasPrefix(u, "DCS") ||
strings.HasPrefix(u, "XLX") || strings.Contains(u, "MUMBLE") {
return true
}
return false // default: show (real users + radio peers)
}
func toMonNode(callsign string, raw json.RawMessage) (monNode, bool) {
var r rawNode
_ = json.Unmarshal(raw, &r)
// Display name = the node's KEY (its registered identity — exactly what the
// Reflector clients view shows via the /status map key), so the two views can
// never disagree on a name. Trunk/satellite array nodes have no key -> use the
// callsign field (which statusnorm may have set to the per-TG group name).
cs := callsign
if cs == "" {
cs = r.Callsign
}
// A configured node (in nodes[]) is an intentional own node — always show it,
// even if its nodeClass would otherwise read as infra (e.g. a DMR gateway as
// "Stream"). Everything else keeps the bridge/infra filter.
if !isConfiguredName(cs) && isBridgeNode(cs, r.SW, r.NodeClass) {
return monNode{}, false // bridge, not a real user — hide it
}
// TG: the node's own tg, else its first monitored/linked TG.
tg := ""
if s := r.TG.String(); s != "" && s != "0" {
tg = s
} else if len(r.MonitoredTGs) > 0 {
tg = r.MonitoredTGs[0].String()
} else if len(r.Links) > 0 {
tg = r.Links[0].LocalTg.String()
}
if r.RestrictedTG && tg != "" {
tg = "*" + tg
}
// Per-TG name: several sat nodes share ONE reflector login (KT4ROY-ASL), so
// under a trunk they'd all show that same callsign. If svxdr.json configures a
// per-TG via for this node's TG, show THAT (KT4ROY-ASL1..4) — the trunk's own
// name is unaffected. One place (svxdr.json) drives it.
// Type/mode, in priority order (no hardcoding of any mode):
// 1) config nodes[].mode (explicit override, keyed by display name)
// 2) the node's own "system" field from the data (DMR, DMR/YSF, DMR/Hytera…)
// 3) type derived from the software string
mode := modeForName(cs)
if mode == "" {
mode = r.System
}
if mode == "" {
mode = monModeOf(r.SW)
}
return monNode{Callsign: cs, TG: tg, NodeClass: r.NodeClass, Mode: mode, Talker: r.IsTalker}, true
}
// ownLocalNodes reads OUR OWN reflector (reflectorURL) and returns its top-level
// nodes — the ones genuinely local to THIS server. Used to keep Local accurate
// when MONITOR_STATUS_URL points at a foreign master.
func ownLocalNodes(client *http.Client) ([]monNode, bool) {
resp, err := client.Get(reflectorURL)
if err != nil {
return nil, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return nil, false
}
var raw struct {
Nodes map[string]json.RawMessage `json:"nodes"`
}
if json.NewDecoder(resp.Body).Decode(&raw) != nil {
return nil, false
}
var out []monNode
for cs, rn := range raw.Nodes {
if mn, ok := toMonNode(cs, rn); ok {
out = append(out, mn)
}
}
return out, true
}
func buildMonitor(client *http.Client, generated string) (monStatus, bool) {
resp, err := client.Get(monitorStatusURL)
if err != nil {
return monStatus{}, false
}
defer resp.Body.Close()
if resp.StatusCode != http.StatusOK {
return monStatus{}, false
}
body, err := io.ReadAll(resp.Body)
if err != nil {
return monStatus{}, false
}
// Normalize at ingest: sat-login node callsigns -> per-TG name. Covers the
// case where monitorStatusURL is a foreign master (e.g. speed reads the SE hub
// where aldmr's sat nodes federate in). A local read via the proxy is already
// normalized; re-normalizing is a harmless no-op.
body = statusnorm.Normalize(body, renameSatNode)
var raw struct {
Nodes map[string]json.RawMessage `json:"nodes"`
Trunks map[string]struct {
Host string `json:"host"`
Connected bool `json:"connected"`
Nodes []json.RawMessage `json:"nodes"`
} `json:"trunks"`
// Satellite trunks (chan_svx_sat): each id (e.g. KT4ROY-ASL) carries N
// AllStar nodes. They live here, NOT in top-level nodes, so Monitor→Nodes
// must read them too or the sat nodes are invisible.
Satellites map[string]struct {
Authenticated bool `json:"authenticated"`
Nodes []json.RawMessage `json:"nodes"`
} `json:"satellites"`
}
if json.Unmarshal(body, &raw) != nil {
return monStatus{}, false
}
out := monStatus{Source: monitorStatusURL, UpdatedAt: generated}
// Local = only OUR reflector's own nodes. Whenever MONITOR_STATUS_URL is a
// REMOTE master (not our own reflector), ALWAYS split: read our OWN reflector for
// the local set and put the master's own top-level nodes under a section instead.
// This must behave the SAME on every box regardless of its svxreflector.conf
// dialect — so the split is decided purely by "is the master remote", and the
// section label is derived from the DATA (masterReflectorName) when the conf
// doesn't name the uplink trunk. No per-box special cases.
doSplit := reflectorURL != monitorStatusURL
masterLabel := ""
if doSplit {
if masterLabel = masterReflectorName(raw.Nodes); masterLabel == "" {
masterLabel = "uplink"
}
}
selfCalls := map[string]bool{}
if doSplit {
if selfNodes, ok := ownLocalNodes(client); ok {
for _, mn := range selfNodes {
out.Local = append(out.Local, mn)
selfCalls[strings.ToUpper(mn.Callsign)] = true
}
}
}
var foreignTop []monNode
for cs, rn := range raw.Nodes {
mn, ok := toMonNode(cs, rn)
if !ok {
continue
}
if !doSplit {
out.Local = append(out.Local, mn)
} else if !selfCalls[strings.ToUpper(mn.Callsign)] {
foreignTop = append(foreignTop, mn) // the master's own node → its trunk section
}
}
sort.Slice(out.Local, func(i, j int) bool { return out.Local[i].Callsign < out.Local[j].Callsign })
// The master's own nodes become one trunk section (we reach it over a trunk).
if len(foreignTop) > 0 {
sort.Slice(foreignTop, func(i, j int) bool { return foreignTop[i].Callsign < foreignTop[j].Callsign })
out.Trunks = append(out.Trunks, monTrunk{Name: masterLabel, Connected: true, Nodes: foreignTop})
}
for name, t := range raw.Trunks {
mt := monTrunk{Name: name, Host: t.Host, Connected: t.Connected}
for _, rn := range t.Nodes {
if mn, ok := toMonNode("", rn); ok {
mt.Nodes = append(mt.Nodes, mn)
}
}
sort.Slice(mt.Nodes, func(i, j int) bool { return mt.Nodes[i].Callsign < mt.Nodes[j].Callsign })
out.Trunks = append(out.Trunks, mt)
}
// Satellite trunks: one section per satellite id (KT4ROY-ASL), its AllStar
// nodes (KT4ROY-ASL1..4 via toMonNode's per-TG rename) shown under it.
for id, s := range raw.Satellites {
mt := monTrunk{Name: id, Connected: s.Authenticated}
for _, rn := range s.Nodes {
if mn, ok := toMonNode("", rn); ok {
mt.Nodes = append(mt.Nodes, mn)
}
}
sort.Slice(mt.Nodes, func(i, j int) bool { return mt.Nodes[i].Callsign < mt.Nodes[j].Callsign })
out.Trunks = append(out.Trunks, mt)
}
// Connected trunks first, then by name — stable, tidy sections.
sort.Slice(out.Trunks, func(i, j int) bool {
if out.Trunks[i].Connected != out.Trunks[j].Connected {
return out.Trunks[i].Connected
}
return out.Trunks[i].Name < out.Trunks[j].Name
})
// Make every node name unique across the whole view (backend, before the web).
uniquifyNames(&out)
return out, true
}
func monitorNodesStart(mux *http.ServeMux) {
client := &http.Client{Timeout: 12 * time.Second}
refresh := func() {
// stamp with a wall-clock time computed off the request (Date.now is
// unavailable in workflow scripts, but this is normal server code).
if next, ok := buildMonitor(client, time.Now().UTC().Format(time.RFC3339)); ok {
monState.mu.Lock()
monState.data = next
monState.mu.Unlock()
}
}
refresh()
go func() {
// Local reflector reads are free (fast); a remote master URL is throttled.
t := time.NewTicker(readInterval(monitorStatusURL))
defer t.Stop()
for range t.C {
refresh()
}
}()
mux.HandleFunc("/api/monitor-nodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
monState.mu.RLock()
b, _ := json.Marshal(monState.data)
monState.mu.RUnlock()
writeMaybeGzip(w, r, b)
})
}
File diff suppressed because it is too large Load Diff
+83
View File
@@ -0,0 +1,83 @@
// svxdr — live-data daemon for SvxPortal (nodelist + proxy + astmeta + xlink)
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
package nodelist
// nodes.json — shared bridge map (see svx-web/nodes.README.md). nodelist takes
// the friendly reflector names (System) and the Location format strings from it,
// so the Location column ("{system} via {node} / {talker}") is driven by the
// same one file that configures astmeta and Asterisk. Merged ON TOP of the
// existing lastheard.json, so a box with only lastheard.json is unchanged.
import (
"encoding/json"
"os"
"strconv"
"strings"
)
type nodesFileNL struct {
Lastheard struct {
BridgeLocationFormat string `json:"bridge_location_format"`
BridgeLocationFormatNoSystem string `json:"bridge_location_format_no_system"`
} `json:"lastheard"`
Nodes []struct {
Allstar int `json:"allstar"`
System string `json:"system"`
Via string `json:"via"`
Callsign string `json:"callsign"`
Enabled *bool `json:"enabled"`
} `json:"nodes"`
}
// applyNodesRenderConfig merges nodes.json's reflector names + Location formats
// over cfg. A missing/invalid file is a no-op (cfg keeps the lastheard.json /
// default values). ReflectorNames is keyed the way bridgeIdentity looks it up:
// by the bridge label (via/callsign) and, as a fallback, the far AllStar node.
func applyNodesRenderConfig(cfg *lastheardRenderCfg, path string) {
b, err := os.ReadFile(path)
if err != nil {
return
}
var nf nodesFileNL
if json.Unmarshal(b, &nf) != nil {
return
}
if s := strings.TrimSpace(nf.Lastheard.BridgeLocationFormat); s != "" {
cfg.BridgeLocationFormat = s
}
if s := strings.TrimSpace(nf.Lastheard.BridgeLocationFormatNoSystem); s != "" {
cfg.BridgeLocationFormatNoSystem = s
}
if cfg.ReflectorNames == nil {
cfg.ReflectorNames = map[string]string{}
}
for _, e := range nf.Nodes {
if (e.Enabled != nil && !*e.Enabled) || e.System == "" {
continue
}
if e.Via != "" {
cfg.ReflectorNames[e.Via] = e.System
}
if e.Callsign != "" {
cfg.ReflectorNames[e.Callsign] = e.System
}
if e.Allstar != 0 {
cfg.ReflectorNames[strconv.Itoa(e.Allstar)] = e.System
}
}
}
// nodesJSONPath returns the nodes.json path (NODES_JSON env, default /etc/nodes.json).
func nodesJSONPath() string {
if p := os.Getenv("NODES_JSON"); p != "" {
return p
}
return "/etc/nodes.json"
}
+289
View File
@@ -0,0 +1,289 @@
// svxdr — live-data daemon for SvxPortal (nodelist + proxy + astmeta + xlink)
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
package nodelist
// All-Nodes flat layer: one total, deduped node list across the whole crawled
// network (federation via externalState.sources). Each node is attributed to
// the REAL reflector it is connected to (source tag; this box's own host is
// shown as the reflector's real name, never an IP). Mesh dupes collapse to one
// owning ref. Instead of re-shipping the whole list, this holds state
// server-side and streams only per-node deltas (added/changed/removed) over SSE.
import (
"encoding/json"
"fmt"
"net"
"net/http"
"sort"
"strings"
"sync"
"time"
"github.com/redis/go-redis/v9"
)
// Federation aggregate (reflector-network.com/api/nodes): callsign -> node with
// the REAL reflector name. Populated by the gap loop; used here to (a) cover the
// whole network, not just what our own crawl reaches, and (b) give real ref
// names instead of a bare IP. UPPER(callsign) keyed.
var aggNodes = struct {
mu sync.RWMutex
m map[string]flatNode
}{m: map[string]flatNode{}}
func setAggNodes(m map[string]flatNode) {
aggNodes.mu.Lock()
aggNodes.m = m
aggNodes.mu.Unlock()
}
type flatNode struct {
Callsign string `json:"callsign"`
Ref string `json:"ref"`
TG string `json:"tg"`
}
var flatState = struct {
mu sync.RWMutex
nodes map[string]flatNode // keyed by UPPER(callsign)
ver uint64
}{nodes: map[string]flatNode{}}
// --- SSE subscriber hub ----------------------------------------------------
type flatSub struct{ ch chan []byte }
var flatHub = struct {
mu sync.Mutex
subs map[*flatSub]struct{}
}{subs: map[*flatSub]struct{}{}}
func flatSubscribe() *flatSub {
s := &flatSub{ch: make(chan []byte, 8)}
flatHub.mu.Lock()
flatHub.subs[s] = struct{}{}
flatHub.mu.Unlock()
return s
}
func flatUnsubscribe(s *flatSub) {
flatHub.mu.Lock()
delete(flatHub.subs, s)
flatHub.mu.Unlock()
}
func flatBroadcast(msg []byte) {
flatHub.mu.Lock()
for s := range flatHub.subs {
select {
case s.ch <- msg:
default: // slow client — drop; it will re-sync on next full snapshot/reconnect
}
}
flatHub.mu.Unlock()
}
// tgOf extracts the "tg" (with "*" prefix for restrictedTG) from a node's JSON.
func tgOf(raw json.RawMessage) string {
var n struct {
TG json.Number `json:"tg"`
RestrictedTG bool `json:"restrictedTG"`
}
if json.Unmarshal(raw, &n) != nil {
return ""
}
s := n.TG.String()
if s == "" {
return ""
}
if n.RestrictedTG {
return "*" + s
}
return s
}
var localRefName string // real name of THIS reflector (from svxreflector.conf), never an IP
// buildFlat assembles the deduped map straight from the federation crawl. Each
// node is attributed to the REAL reflector it is connected to (source.Tag).
// First ref by tag wins (sources are tag-sorted → deterministic). A source
// whose tag is this box's own host is renamed to the reflector's real name so
// the ref column is never "127.0.0.1"/an IP. No local-status overlay — that mix
// carried trunk-side (remote) nodes and mis-tagged them as local.
func buildFlat() map[string]flatNode {
out := make(map[string]flatNode, 256)
selfHost := hostFromURL(reflectorURL)
externalState.mu.RLock()
for _, s := range externalState.sources {
ref := s.Tag
// Never expose a raw http(s)://host:port/status URL as the ref — those
// are FEDERATION_SEEDS whose tag is the seed URL. Show only the host.
if strings.HasPrefix(ref, "http://") || strings.HasPrefix(ref, "https://") {
ref = hostFromURL(ref)
}
if (hostFromURL(s.URL) == selfHost || hostFromURL(s.Tag) == selfHost) && localRefName != "" {
ref = localRefName
}
// Bare IP → prefer a hostname alias (a name resolving to the same IP).
if net.ParseIP(ref) != nil {
for _, a := range s.Aliases {
if a != "" && net.ParseIP(a) == nil {
ref = a
break
}
}
}
for cs, raw := range s.Nodes {
key := strings.ToUpper(cs)
if _, seen := out[key]; seen {
continue
}
out[key] = flatNode{Callsign: cs, Ref: ref, TG: tgOf(raw)}
}
}
externalState.mu.RUnlock()
// Cover the WHOLE network (not just what our own crawl reaches): fold in the
// federation aggregate. Crawl wins for nodes we see live (fresh TG); the
// aggregate adds everyone else AND supplies a real reflector name where the
// crawl only had a bare IP.
aggNodes.mu.RLock()
for k, an := range aggNodes.m {
if ex, ok := out[k]; ok {
if an.Ref != "" && net.ParseIP(ex.Ref) != nil {
ex.Ref = an.Ref
out[k] = ex
}
continue
}
out[k] = flatNode{Callsign: an.Callsign, Ref: an.Ref}
}
aggNodes.mu.RUnlock()
return out
}
// rebuildFlat rebuilds, diffs against the current state and — only when the set
// actually changed — bumps the version and streams the delta to SSE clients.
func rebuildFlat() {
next := buildFlat()
flatState.mu.Lock()
prev := flatState.nodes
var added, changed []flatNode
var removed []string
for k, nv := range next {
if pv, ok := prev[k]; !ok {
added = append(added, nv)
} else if pv.Ref != nv.Ref || pv.TG != nv.TG {
changed = append(changed, nv)
}
}
for k, pv := range prev {
if _, ok := next[k]; !ok {
removed = append(removed, pv.Callsign)
}
}
if len(added) == 0 && len(changed) == 0 && len(removed) == 0 {
flatState.mu.Unlock()
return
}
flatState.nodes = next
flatState.ver++
ver := flatState.ver
flatState.mu.Unlock()
payload, _ := json.Marshal(struct {
Added []flatNode `json:"added"`
Changed []flatNode `json:"changed"`
Removed []string `json:"removed"`
Ver uint64 `json:"v"`
}{added, changed, removed, ver})
flatBroadcast([]byte("event: nodes-delta\ndata: " + string(payload) + "\n\n"))
}
// flatSnapshotJSON returns the full current list (for /api/nodes and the SSE
// initial frame), sorted by callsign.
func flatSnapshotJSON() ([]byte, uint64) {
flatState.mu.RLock()
rows := make([]flatNode, 0, len(flatState.nodes))
for _, v := range flatState.nodes {
rows = append(rows, v)
}
ver := flatState.ver
flatState.mu.RUnlock()
sort.Slice(rows, func(i, j int) bool { return rows[i].Callsign < rows[j].Callsign })
b, _ := json.Marshal(struct {
Nodes []flatNode `json:"nodes"`
Ver uint64 `json:"v"`
}{rows, ver})
return b, ver
}
// nodeFlatStart wires the endpoints and the update driver. Called from main()
// once rdb exists. The driver rebuilds on every pubsubChan tick (instant local
// changes) and on a slow safety ticker (catches federation crawl updates).
func nodeFlatStart(mux *http.ServeMux, rdb *redis.Client) {
localRefName, _ = localReflectorMeta() // real reflector name for the "local" tag
rebuildFlat() // prime
mux.HandleFunc("/api/nodes", func(w http.ResponseWriter, r *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
b, _ := flatSnapshotJSON()
writeMaybeGzip(w, r, b)
})
mux.HandleFunc("/api/nodes-stream", func(w http.ResponseWriter, r *http.Request) {
flusher, ok := w.(http.Flusher)
if !ok {
http.Error(w, "SSE not supported", 500)
return
}
w.Header().Set("Content-Type", "text/event-stream; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Connection", "keep-alive")
// initial full snapshot
snap, _ := flatSnapshotJSON()
fmt.Fprintf(w, "event: nodes-full\ndata: %s\n\n", snap)
flusher.Flush()
sub := flatSubscribe()
defer flatUnsubscribe(sub)
ping := time.NewTicker(25 * time.Second)
defer ping.Stop()
for {
select {
case <-r.Context().Done():
return
case <-ping.C:
fmt.Fprint(w, "event: ping\ndata: 1\n\n")
flusher.Flush()
case msg := <-sub.ch:
w.Write(msg)
flusher.Flush()
}
}
})
// Driver: instant on pubsub, plus a safety ticker for crawl-side changes.
go func() {
sub := rdb.Subscribe(ctx, pubsubChan)
defer sub.Close()
ch := sub.Channel()
tick := time.NewTicker(3 * time.Second)
defer tick.Stop()
for {
select {
case <-ch:
rebuildFlat()
case <-tick.C:
rebuildFlat()
}
}
}()
}
+366
View File
@@ -0,0 +1,366 @@
// svxdr — live-data daemon for SvxPortal (nodelist + proxy + astmeta + xlink)
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
package nodelist
// svxdr config wiring. applyConfig overrides the package's env-derived globals
// with values from svxdr.json (a set field wins; an unset field keeps the
// built-in default). applyRenderFromConfig builds the lastheard render config
// (Location formats + reflector names) from svxdr.json instead of a separate
// lastheard.json / nodes.json. Nothing else in nodelist changed.
import (
"strconv"
"strings"
"sync"
"time"
"svxportal/svxdr/config"
)
// Per-TG expected via (from svxdr.json nodes[].via). talkerMetaFor matches the
// incoming meta against THIS instead of the reflector-log talker name, so the
// callsign/via is defined in ONE place (svxdr.json) and svx_sat.conf's callsign
// no longer has to equal it.
var (
tgViaMu sync.RWMutex
groupTgVia = map[string]map[int]string{} // group login -> tg -> via (per group, so groups may share a TG)
tgViaAny = map[int]map[string]bool{} // tg -> set of every configured via (for talkerMetaFor)
)
func setTgVia(gv map[string]map[int]string, any map[int]map[string]bool) {
tgViaMu.Lock()
groupTgVia, tgViaAny = gv, any
tgViaMu.Unlock()
}
func viaFor(login string, tg int) string {
tgViaMu.RLock()
defer tgViaMu.RUnlock()
if m := groupTgVia[login]; m != nil {
return m[tg]
}
return ""
}
func anyViaForTG(tg int) bool {
tgViaMu.RLock()
defer tgViaMu.RUnlock()
return len(tgViaAny[tg]) > 0
}
func viaConfiguredForTG(tg int, via string) bool {
tgViaMu.RLock()
defer tgViaMu.RUnlock()
return tgViaAny[tg][via]
}
// Per-node mode/type (from nodes[].mode), keyed by the node's display name (via or
// callsign). Config wins over the data-derived type; empty = fall back to data.
var (
modeByNameMu sync.RWMutex
modeByNameMap = map[string]string{}
)
func setModeByName(m map[string]string) { modeByNameMu.Lock(); modeByNameMap = m; modeByNameMu.Unlock() }
func modeForName(name string) string {
modeByNameMu.RLock()
defer modeByNameMu.RUnlock()
return modeByNameMap[name]
}
// Per-TG reflector/system name (from nodes[].system), so ANY talker on that TG —
// AllStar, DMR or a bare reflector client — falls back to the same Location label
// (e.g. TG 314093 -> "WHITELIGHTNING"). Used by locForTG when there is no live
// gateway location for the TG.
var (
tgSystemMu sync.RWMutex
tgSystemMap = map[int]string{}
)
func setTgSystem(m map[int]string) { tgSystemMu.Lock(); tgSystemMap = m; tgSystemMu.Unlock() }
// Canonical TG->system name fetched from the shared tglist (svx-web /tglist.php).
// FROZEN DESIGN: the tglist is the SOLE source of TG names/Location shown in
// Lastheard — there is NO fallback (not nodes[].system, not live gateway/astmeta
// data). A TG absent from the tglist has no name, by design; the list is edited
// on the master and every box syncs it, so the display is identical everywhere.
var (
tglistSystemMu sync.RWMutex
tglistSystemMap = map[int]string{}
)
func setTglistSystem(m map[int]string) { tglistSystemMu.Lock(); tglistSystemMap = m; tglistSystemMu.Unlock() }
func systemForTG(tg int) string {
tglistSystemMu.RLock()
defer tglistSystemMu.RUnlock()
return tglistSystemMap[tg]
}
// Configured node display names (every nodes[] via/callsign). A configured node is
// an intentional own node and is never hidden by the bridge/infra filter.
var (
confNamesMu sync.RWMutex
confNamesSet = map[string]bool{}
)
func setConfiguredNames(m map[string]bool) { confNamesMu.Lock(); confNamesSet = m; confNamesMu.Unlock() }
func isConfiguredName(name string) bool {
confNamesMu.RLock()
defer confNamesMu.RUnlock()
return confNamesSet[name]
}
// satLogins = the shared reflector login(s) our sat nodes use (= trunk
// satellite_id, e.g. KT4ROY-ASL). The per-TG rename applies ONLY to a node
// whose callsign IS such a login — so a FOREIGN node that merely monitors the
// same TG (e.g. SA7AUX on 3149050) is never mislabeled KT4ROY-ASL2.
var (
satLoginsMu sync.RWMutex
satLoginsSet = map[string]bool{}
)
func setSatLogins(m map[string]bool) { satLoginsMu.Lock(); satLoginsSet = m; satLoginsMu.Unlock() }
func isSatLogin(cs string) bool {
satLoginsMu.RLock()
defer satLoginsMu.RUnlock()
return satLoginsSet[cs]
}
// renameSatNode is THE rule for statusnorm.Normalize: a node whose login (its
// callsign for trunk/sat nodes, its key for top-level nodes) is a configured group
// -> that group's per-TG name. Keyed by (group, tg), so two different nodes (e.g. an
// ASL one and a DMR one) can share a TG and each keeps its own group's name.
func renameSatNode(login string, tg int) string {
// DISABLED (2026-08-25): the sat nodes now announce network-unique names at
// the source (callsign + per-TG suffix, e.g. KT4ROY-ASL-A93 / -050), so the
// bare shared login no longer appears and this per-TG rewrite is redundant —
// and we must NOT re-rewrite the already-unique source names. Always keep the
// source name; duplicate-safety still lives in uniquifyNames (view-only).
_ = login
_ = tg
return ""
}
// satTrunkTGs = the satellite trunk's talkgroups straight from svxdr.json nodes[]
// (grouped under the trunk satellite_id), names = the per-TG via. ONE source; used
// by Monitor->Trunks so it needs neither svx_sat.conf nor an override.
var (
satTrunkTGsMu sync.RWMutex
satTrunkTGs = map[string][]satTG{}
)
func setSatTrunkTGs(m map[string][]satTG) { satTrunkTGsMu.Lock(); satTrunkTGs = m; satTrunkTGsMu.Unlock() }
func satTGsForTrunk(id string) []satTG {
satTrunkTGsMu.RLock()
defer satTrunkTGsMu.RUnlock()
return satTrunkTGs[id]
}
func satTrunkIDs() []string {
satTrunkTGsMu.RLock()
defer satTrunkTGsMu.RUnlock()
ids := make([]string, 0, len(satTrunkTGs))
for id := range satTrunkTGs {
ids = append(ids, id)
}
return ids
}
func setStr(dst *string, v string) {
if v != "" {
*dst = v
}
}
func setDur(dst *time.Duration, v config.Duration) {
if v.D() > 0 {
*dst = v.D()
}
}
func applyConfig(c *config.Config) {
n := c.Nodelist
setStr(&listenAddr, n.ListenAddr)
setStr(&reflectorURL, n.ReflectorStatus)
// monitor status defaults to the (now-resolved) reflector URL unless set.
monitorStatusURL = reflectorURL
setStr(&monitorStatusURL, n.MonitorStatusURL)
debug = n.Debug
setDur(&pollInterval, n.PollInterval)
setStr(&reflectorLogPath, n.ReflectorLogPath)
setStr(&networkJSONPath, n.NetworkJSONPath)
setStr(&astdbURL, n.AstdbURL)
setDur(&talkerMetaTTL, n.TalkerMetaTTL)
setStr(&ingestToken, n.TalkerIngestToken)
setDur(&allstarNodesTTL, n.AllstarNodesTTL)
setDur(&nodeOnlineTTL, n.NodeOnlineTTL)
setDur(&nodeTTL, n.NodeTTL)
setStr(&mqttBroker, n.MqttBroker)
setStr(&mqttUser, n.MqttUser)
setStr(&mqttPass, n.MqttPass)
setStr(&mqttPrefix, n.MqttPrefix)
e := n.External
setStr(&externalPullURL, e.PullURL)
setStr(&externalPullScheme, e.PullScheme)
setStr(&externalPullPort, e.PullPort)
setStr(&externalPullPath, e.PullPath)
setDur(&externalPullInterval, e.PullInterval)
if len(e.StatusPorts) > 0 {
externalStatusPorts = strings.Join(e.StatusPorts, ",")
}
setStr(&externalStatusPath, e.StatusPath)
setStr(&externalProxyPath, e.ProxyPath)
setStr(&externalDetailPath, e.DetailPath)
if e.MaxRefs > 0 {
externalMaxRefs = e.MaxRefs
}
setDur(&externalStaleTTL, e.StaleTTL)
setDur(&monitorInterval, n.MonitorInterval)
f := n.Federation
if len(f.Seeds) > 0 {
federationSeeds = strings.Join(f.Seeds, ",")
}
setStr(&federationAggURL, f.AggregateURL)
setDur(&federationAggInterval, f.AggregateEvery)
r := c.Redis
setStr(&redisSock, r.Socket)
setStr(&redisPass, r.Password)
setStr(&statusKey, r.StatusKey)
setStr(&statusHashKey, r.StatusHashKey)
setStr(&pubsubChan, r.PubsubChan)
setStr(&nodesOnlineKey, r.NodesOnlineKey)
setStr(&nodeKeyPrefix, r.NodeKeyPrefix)
setStr(&nodeConnectChan, r.NodeConnectChan)
setStr(&nodeDisconnectChan, r.NodeDisconnectChan)
setStr(&nodeUpdateChan, r.NodeUpdateChan)
setStr(&lastheardKey, r.LastheardKey)
// xlink only when this box has the module enabled.
xlinkEnabled = c.Modules.Xlink
if c.Modules.Xlink {
x := c.Xlink
setStr(&xlinkBroker, x.Broker)
setStr(&xlinkUser, x.User)
setStr(&xlinkPass, x.Pass)
setStr(&xlinkTopic, x.Topic)
setStr(&xlinkOrigin, x.Origin)
}
}
// applyRenderFromConfig sets the lastheard render config (Location formats +
// reflector names) from svxdr.json. reflector_names is keyed the way
// bridgeIdentity looks it up: bridge label (via/callsign) and far AllStar node.
func applyRenderFromConfig(c *config.Config) {
rc := defaultRenderCfg
rc.ReflectorNames = map[string]string{}
if s := strings.TrimSpace(c.Lastheard.BridgeLocationFormat); s != "" {
rc.BridgeLocationFormat = s
}
if s := strings.TrimSpace(c.Lastheard.BridgeLocationFormatNoSystem); s != "" {
rc.BridgeLocationFormatNoSystem = s
}
for k, v := range c.Lastheard.ReflectorNames {
if v != "" {
rc.ReflectorNames[k] = v
}
}
// Skip via/callsign labels that several nodes share with DIFFERENT systems
// (e.g. a uniform trunk callsign KT4ROY-ASL across East Coast + WHITELIGHTNING):
// keying reflector_names by them collapses and then overrides astmeta's
// correct per-TG System. Only add unambiguous labels.
const conflict = "\x00CONFLICT"
label := map[string]string{}
mark := func(k, sys string) {
if k == "" || sys == "" {
return
}
if prev, ok := label[k]; ok && prev != sys {
label[k] = conflict
} else if !ok {
label[k] = sys
}
}
for _, e := range c.Nodes {
if !e.IsEnabled() {
continue
}
mark(e.Via, e.System)
mark(e.Callsign, e.System)
}
for _, e := range c.Nodes {
if !e.IsEnabled() || e.System == "" {
continue
}
if e.Via != "" && label[e.Via] != conflict {
rc.ReflectorNames[e.Via] = e.System
}
if e.Callsign != "" && label[e.Callsign] != conflict {
rc.ReflectorNames[e.Callsign] = e.System
}
if e.Allstar != 0 {
rc.ReflectorNames[strconv.Itoa(e.Allstar)] = e.System
}
}
// ONE pass over nodes[] builds everything, purely config-driven, group-aware:
// gv[group][tg]=via per-(group,TG) rename — two nodes (ASL + DMR) may share a TG
// any[tg]={via,...} every configured via for a TG — talkerMetaFor match
// logins/groupTGs the group trunks (satLogins + per-TG names)
// modeMap[name]=mode free-text type per node
// NOTHING about ASL/DMR/etc is hardcoded — add a group/mode in config and it
// renders the same as any other.
gv := map[string]map[int]string{}
any := map[int]map[string]bool{}
logins := map[string]bool{}
groupTGs := map[string][]satTG{}
modeMap := map[string]string{}
confNames := map[string]bool{}
tgSys := map[int]string{}
for _, e := range c.Nodes {
if !e.IsEnabled() || e.TG == 0 {
continue
}
if e.System != "" {
tgSys[e.TG] = e.System // TG -> reflector name, Location fallback for any talker on this TG
}
name := e.Via
if name == "" {
name = e.Callsign
}
if name == "" {
continue
}
confNames[name] = true
if any[e.TG] == nil {
any[e.TG] = map[string]bool{}
}
any[e.TG][name] = true
if e.Mode != "" {
modeMap[name] = e.Mode
}
if e.Group != "" {
logins[e.Group] = true
if gv[e.Group] == nil {
gv[e.Group] = map[int]string{}
}
gv[e.Group][e.TG] = name
groupTGs[e.Group] = append(groupTGs[e.Group], satTG{Name: name, TG: strconv.Itoa(e.TG)})
}
}
setTgVia(gv, any)
setSatLogins(logins)
setSatTrunkTGs(groupTGs)
setModeByName(modeMap)
setConfiguredNames(confNames)
setTgSystem(tgSys)
renderCfgMu.Lock()
renderCfg = rc
renderCfgMu.Unlock()
}
+416
View File
@@ -0,0 +1,416 @@
// svxdr — live-data daemon for SvxPortal (nodelist + proxy + astmeta + xlink)
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
// Package proxy is the svxdr proxy subsystem — the enriched SvxReflector status
// proxy, folded in from the standalone svx-web/proxy. Behaviour is preserved
// exactly (byte-identical status enrichment, same /status, /api/talker-meta,
// /healthz, same meta-sync). Config now comes from svxdr.json's "proxy" section
// instead of env, and Run is ctx-aware for graceful shutdown.
package proxy
import (
"bytes"
"context"
"encoding/json"
"io"
"log"
"net/http"
"strconv"
"strings"
"sync"
"time"
"svxportal/svxdr/config"
"svxportal/svxdr/statusnorm"
)
type talkerMeta struct {
Callsign string `json:"callsign,omitempty"`
Node string `json:"node,omitempty"`
System string `json:"system,omitempty"`
Via string `json:"via,omitempty"`
Network string `json:"network,omitempty"`
at time.Time
}
// Server holds the proxy's config-derived settings and in-memory state. Instance
// -scoped (no package globals) so a future in-memory hand-off from the astmeta
// module can share one store, and so reload can rebuild cleanly.
type Server struct {
listen string
upstreamURL string
cacheTTL time.Duration
metaTTL time.Duration
ingestToken string
upstreamPeer string
forwardTalker string
syncInterval time.Duration
remoteTTL time.Duration
httpClient *http.Client
tgVia map[int]string // TG -> per-TG display callsign (svxdr.json nodes[])
satLogins map[string]bool // shared sat logins (trunk satellite_id)
upstream struct {
mu sync.RWMutex
data []byte
at time.Time
}
meta struct {
mu sync.RWMutex
byTG map[int]talkerMeta
}
remote struct {
mu sync.RWMutex
sats json.RawMessage
at time.Time
}
}
// Run starts the proxy HTTP server and meta-sync loop, blocking until ctx is
// cancelled, then shutting the server down gracefully.
func Run(ctx context.Context, c *config.Config) error {
p := c.Proxy
s := &Server{
listen: orDefault(p.Listen, ":8099"),
upstreamURL: orDefault(p.UpstreamURL, "http://127.0.0.1:8080/status"),
cacheTTL: orDur(p.CacheTTL.D(), 200*time.Millisecond),
metaTTL: orDur(p.MetaTTL.D(), 30*time.Second),
ingestToken: p.IngestToken,
upstreamPeer: p.UpstreamPeer,
forwardTalker: p.ForwardTalker,
syncInterval: orDur(p.SyncInterval.D(), 2*time.Second),
remoteTTL: orDur(p.RemoteTTL.D(), 60*time.Second),
}
s.meta.byTG = map[int]talkerMeta{}
s.httpClient = &http.Client{Timeout: orDur(p.FetchTimeout.D(), 5*time.Second)}
// Per-TG display callsign: sat nodes share one reflector login (KT4ROY-ASL);
// override the callsign shown in /status with the per-TG name (KT4ROY-ASL1..4)
// from svxdr.json. The Asterisk login is NOT touched — this is display only.
s.tgVia = map[int]string{}
s.satLogins = map[string]bool{}
if c.Trunk.SatelliteID != "" {
s.satLogins[c.Trunk.SatelliteID] = true
}
for _, n := range c.Nodes {
if !n.IsEnabled() || n.TG == 0 {
continue
}
via := n.Via
if via == "" {
via = n.Callsign
}
if via != "" {
s.tgVia[n.TG] = via
}
}
mux := http.NewServeMux()
mux.HandleFunc("/status", s.handleStatus)
mux.HandleFunc("/api/talker-meta", s.handleTalkerMeta)
mux.HandleFunc("/healthz", func(w http.ResponseWriter, r *http.Request) { _, _ = w.Write([]byte("ok\n")) })
go s.metaSyncLoop(ctx)
srv := &http.Server{Addr: s.listen, Handler: mux, ReadHeaderTimeout: 5 * time.Second}
go func() {
<-ctx.Done()
sc, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
_ = srv.Shutdown(sc)
}()
log.Printf("proxy: listen=%s upstream=%s cache=%s metaTTL=%s", s.listen, s.upstreamURL, s.cacheTTL, s.metaTTL)
if err := srv.ListenAndServe(); err != nil && err != http.ErrServerClosed {
return err
}
return nil
}
func orDefault(v, def string) string {
if v == "" {
return def
}
return v
}
func orDur(v, def time.Duration) time.Duration {
if v <= 0 {
return def
}
return v
}
// ---- upstream status cache -------------------------------------------------
func (s *Server) fetchStatus() ([]byte, error) {
resp, err := s.httpClient.Get(s.upstreamURL)
if err != nil {
return nil, err
}
defer resp.Body.Close()
return io.ReadAll(io.LimitReader(resp.Body, 8<<20))
}
func (s *Server) cachedStatus() ([]byte, error) {
s.upstream.mu.RLock()
if s.upstream.data != nil && time.Since(s.upstream.at) < s.cacheTTL {
d := s.upstream.data
s.upstream.mu.RUnlock()
return d, nil
}
s.upstream.mu.RUnlock()
d, err := s.fetchStatus()
if err != nil {
s.upstream.mu.RLock()
stale := s.upstream.data
s.upstream.mu.RUnlock()
if stale != nil {
return stale, nil
}
return nil, err
}
// ONE normalization, at ingest (cache fill — at most once per cacheTTL, not
// per request): sat-login node callsigns become their per-TG name. Everything
// that reads this proxy (its own /status AND nodelist over it) is then correct
// with no per-view special-casing.
d = statusnorm.Normalize(d, s.renameNode)
s.upstream.mu.Lock()
s.upstream.data = d
s.upstream.at = time.Now()
s.upstream.mu.Unlock()
return d, nil
}
// renameNode is the ONE rule: a node whose callsign IS a shared sat login and
// whose TG has a per-TG name gets that name; everything else is left alone.
func (s *Server) renameNode(callsign string, tg int) string {
// DISABLED (2026-08-25): sat nodes now advertise network-unique names at the
// source (callsign + per-TG suffix, e.g. KT4ROY-ASL-A93), so the bare shared
// login no longer appears and this per-TG rewrite is redundant. Never rewrite
// the already-unique source name; dupe-safety stays in the nodelist view.
_ = callsign
_ = tg
return ""
}
// ---- meta store ------------------------------------------------------------
func (s *Server) setMeta(tg int, m talkerMeta) {
m.at = time.Now()
s.meta.mu.Lock()
s.meta.byTG[tg] = m
s.meta.mu.Unlock()
}
func (s *Server) freshMeta() map[string]talkerMeta {
out := map[string]talkerMeta{}
s.meta.mu.RLock()
for tg, m := range s.meta.byTG {
if time.Since(m.at) <= s.metaTTL {
out[strconv.Itoa(tg)] = m
}
}
s.meta.mu.RUnlock()
return out
}
func (s *Server) setRemoteSatellites(raw json.RawMessage) {
s.remote.mu.Lock()
s.remote.sats = raw
s.remote.at = time.Now()
s.remote.mu.Unlock()
}
func (s *Server) freshRemoteSatellites() json.RawMessage {
s.remote.mu.RLock()
defer s.remote.mu.RUnlock()
if s.remote.sats != nil && time.Since(s.remote.at) <= s.remoteTTL {
return s.remote.sats
}
return nil
}
// ---- meta sync -------------------------------------------------------------
type metaEntry struct {
Callsign string `json:"callsign"`
Node string `json:"node"`
System string `json:"system"`
Via string `json:"via"`
Network string `json:"network"`
}
func (s *Server) metaSyncLoop(ctx context.Context) {
if s.upstreamPeer == "" {
return
}
log.Printf("proxy meta-sync: upstream=%s forward=%s interval=%s", s.upstreamPeer, s.forwardTalker, s.syncInterval)
t := time.NewTicker(s.syncInterval)
defer t.Stop()
for {
select {
case <-ctx.Done():
return
case <-t.C:
}
resp, err := s.httpClient.Get(s.upstreamPeer)
if err != nil {
continue
}
body, _ := io.ReadAll(io.LimitReader(resp.Body, 8<<20))
resp.Body.Close()
var top struct {
TalkerMeta map[string]metaEntry `json:"talker_meta"`
Satellites json.RawMessage `json:"satellites"`
}
if json.Unmarshal(body, &top) != nil {
continue
}
if len(top.Satellites) > 0 && !bytes.Equal(bytes.TrimSpace(top.Satellites), []byte("null")) {
s.setRemoteSatellites(top.Satellites)
}
for tgStr, m := range top.TalkerMeta {
tg, err := strconv.Atoi(tgStr)
if err != nil || tg <= 0 {
continue
}
s.setMeta(tg, talkerMeta{Callsign: m.Callsign, Node: m.Node, System: m.System, Via: m.Via, Network: m.Network})
if s.forwardTalker != "" {
s.forwardMeta(tg, m)
}
}
}
}
func (s *Server) forwardMeta(tg int, m metaEntry) {
body, _ := json.Marshal(map[string]any{
"tg": tg, "event": "start",
"callsign": m.Callsign, "node": m.Node, "system": m.System,
"via": m.Via, "network": "",
})
req, err := http.NewRequest(http.MethodPost, s.forwardTalker, bytes.NewReader(body))
if err != nil {
return
}
req.Header.Set("Content-Type", "application/json")
resp, err := s.httpClient.Do(req)
if err != nil {
return
}
resp.Body.Close()
}
// ---- enrichment (byte-exact, preserved from svx-web/proxy) ------------------
func (s *Server) enrich(raw []byte) []byte {
extras := s.buildExtras(raw)
if len(extras) == 0 {
return raw
}
i := 0
for i < len(raw) && raw[i] != '{' {
i++
}
if i >= len(raw) {
return raw
}
j := i + 1
for j < len(raw) && (raw[j] == ' ' || raw[j] == '\t' || raw[j] == '\n' || raw[j] == '\r') {
j++
}
empty := j < len(raw) && raw[j] == '}'
out := make([]byte, 0, len(raw)+len(extras)+1)
out = append(out, raw[:i+1]...)
out = append(out, extras...)
if !empty {
out = append(out, ',')
}
out = append(out, raw[i+1:]...)
return out
}
func (s *Server) buildExtras(raw []byte) []byte {
var frags [][]byte
if fm := s.freshMeta(); len(fm) > 0 {
if b, err := json.Marshal(fm); err == nil {
frags = append(frags, append([]byte(`"talker_meta":`), b...))
}
}
if sats := s.freshRemoteSatellites(); sats != nil && !bytes.Contains(raw, []byte(`"satellites"`)) {
frags = append(frags, append([]byte(`"satellites":`), sats...))
}
if len(frags) == 0 {
return nil
}
return bytes.Join(frags, []byte(","))
}
// ---- HTTP ------------------------------------------------------------------
func isLoopback(addr string) bool {
host := addr
if i := strings.LastIndex(addr, ":"); i > 0 {
host = addr[:i]
}
host = strings.Trim(host, "[]")
return host == "127.0.0.1" || host == "::1" || host == "localhost"
}
func (s *Server) handleStatus(w http.ResponseWriter, r *http.Request) {
raw, err := s.cachedStatus()
if err != nil {
http.Error(w, `{"error":"upstream unavailable"}`, http.StatusBadGateway)
return
}
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.Header().Set("Cache-Control", "no-cache")
w.Header().Set("Access-Control-Allow-Origin", "*")
_, _ = w.Write(s.enrich(raw))
}
func (s *Server) handleTalkerMeta(w http.ResponseWriter, r *http.Request) {
if r.Method != http.MethodPost {
http.Error(w, `{"msg":"POST only"}`, http.StatusMethodNotAllowed)
return
}
if !isLoopback(r.RemoteAddr) {
if s.ingestToken == "" || r.Header.Get("X-Ingest-Token") != s.ingestToken {
http.Error(w, `{"msg":"unauthorized"}`, 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"`
}
body, err := io.ReadAll(io.LimitReader(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" {
_, _ = w.Write([]byte(`{"ok":true}`))
return
}
s.setMeta(in.TG, talkerMeta{
Callsign: strings.TrimSpace(in.Callsign),
Node: strings.TrimSpace(in.Node),
System: strings.TrimSpace(in.System),
Via: strings.TrimSpace(in.Via),
Network: strings.TrimSpace(in.Network),
})
_, _ = w.Write([]byte(`{"ok":true}`))
}
+142
View File
@@ -0,0 +1,142 @@
// svxdr — live-data daemon for SvxPortal (nodelist + proxy + astmeta + xlink)
// Copyright (C) 2026 SA7AUX (Björn)
// SPDX-License-Identifier: AGPL-3.0-or-later
//
// This program is free software: you can redistribute it and/or modify it under
// the terms of the GNU Affero General Public License as published by the Free
// Software Foundation, either version 3 of the License, or (at your option) any
// later version. See the LICENSE file for details.
// Package statusnorm holds the ONE rule that turns a reflector's shared sat-login
// (e.g. KT4ROY-ASL, used by all its TG nodes) into the per-TG display callsign
// (KT4ROY-ASL1..4). It is applied ONCE where a status is ingested (the proxy's
// cache fill, and the federation crawl), so every consumer downstream reads
// already-correct data — no per-view special-casing anywhere.
package statusnorm
import "encoding/json"
// Normalize rewrites the callsign of every node in trunks[].nodes and
// satellites[].nodes: rename(callsign, tg) returns the replacement, or "" to keep
// it. All other data is preserved. Parse failure or no change -> raw unchanged.
func Normalize(raw []byte, rename func(callsign string, tg int) string) []byte {
var top map[string]json.RawMessage
if json.Unmarshal(raw, &top) != nil {
return raw
}
changed := false
// Top-level nodes[]: a group can present as a plain node (e.g. a DMR gateway),
// not only under a trunk/satellite. Keyed by the node's login/identity, so rename
// by that KEY (its callsign field may be the operator, not the login).
if blk, ok := top["nodes"]; ok {
var nm map[string]json.RawMessage
if json.Unmarshal(blk, &nm) == nil {
blkChanged := false
for id, nRaw := range nm {
var n map[string]json.RawMessage
if json.Unmarshal(nRaw, &n) != nil {
continue
}
cs, tg := callsignTG(n)
repl := rename(id, tg) // match on the node's KEY (its login)
if repl == "" || repl == cs {
continue
}
b, _ := json.Marshal(repl)
n["callsign"] = b
if enc, err := json.Marshal(n); err == nil {
nm[id] = enc
blkChanged = true
}
}
if blkChanged {
if enc, err := json.Marshal(nm); err == nil {
top["nodes"] = enc
changed = true
}
}
}
}
for _, key := range []string{"trunks", "satellites"} {
blk, ok := top[key]
if !ok {
continue
}
var group map[string]json.RawMessage
if json.Unmarshal(blk, &group) != nil {
continue
}
blkChanged := false
for id, objRaw := range group {
var obj map[string]json.RawMessage
if json.Unmarshal(objRaw, &obj) != nil {
continue
}
nodesRaw, ok := obj["nodes"]
if !ok {
continue
}
var nodes []json.RawMessage
if json.Unmarshal(nodesRaw, &nodes) != nil {
continue
}
nodesChanged := false
for i, nRaw := range nodes {
var n map[string]json.RawMessage
if json.Unmarshal(nRaw, &n) != nil {
continue
}
cs, tg := callsignTG(n)
if cs == "" {
continue
}
repl := rename(cs, tg)
if repl == "" || repl == cs {
continue
}
b, _ := json.Marshal(repl)
n["callsign"] = b
if enc, err := json.Marshal(n); err == nil {
nodes[i] = enc
nodesChanged = true
}
}
if nodesChanged {
if enc, err := json.Marshal(nodes); err == nil {
obj["nodes"] = enc
if oenc, err := json.Marshal(obj); err == nil {
group[id] = oenc
blkChanged = true
}
}
}
}
if blkChanged {
if enc, err := json.Marshal(group); err == nil {
top[key] = enc
changed = true
}
}
}
if !changed {
return raw
}
if enc, err := json.Marshal(top); err == nil {
return enc
}
return raw
}
func callsignTG(n map[string]json.RawMessage) (string, int) {
var cs string
if v, ok := n["callsign"]; ok {
_ = json.Unmarshal(v, &cs)
}
var tg int
if v, ok := n["tg"]; ok {
_ = json.Unmarshal(v, &tg)
}
return cs, tg
}