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:
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
@@ -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
@@ -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.
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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 = §ion{name: line[1 : len(line)-1], kv: map[string]string{}}
|
||||
order = append(order, cur)
|
||||
byName[cur.name] = cur
|
||||
continue
|
||||
}
|
||||
if cur == nil {
|
||||
continue
|
||||
}
|
||||
if i := strings.IndexByte(line, '='); i > 0 {
|
||||
cur.kv[strings.ToUpper(strings.TrimSpace(line[:i]))] = strings.TrimSpace(line[i+1:])
|
||||
}
|
||||
}
|
||||
|
||||
var out []confTrunk
|
||||
seen := map[string]bool{}
|
||||
add := func(name, hostCSV string) {
|
||||
name = strings.TrimSpace(name)
|
||||
if name == "" || seen[name] {
|
||||
return
|
||||
}
|
||||
seen[name] = true
|
||||
var hosts []string
|
||||
for _, h := range strings.Split(hostCSV, ",") {
|
||||
if hh := normTrunkHost(h); hh != "" {
|
||||
hosts = append(hosts, hh)
|
||||
}
|
||||
}
|
||||
out = append(out, confTrunk{name: name, hosts: hosts})
|
||||
}
|
||||
|
||||
// Dialect 1: [ReflectorTrunk] Peers=A,B -> [TrunkPeer#A] Host=...
|
||||
if rt := byName["ReflectorTrunk"]; rt != nil {
|
||||
for _, p := range strings.Split(rt.kv["PEERS"], ",") {
|
||||
p = strings.TrimSpace(p)
|
||||
if p == "" {
|
||||
continue
|
||||
}
|
||||
host := ""
|
||||
if peer := byName["TrunkPeer#"+p]; peer != nil {
|
||||
host = peer.kv["HOST"]
|
||||
}
|
||||
add(p, host)
|
||||
}
|
||||
}
|
||||
// Dialect 2: [TRUNK_xxx] HOST=...
|
||||
for _, s := range order {
|
||||
if strings.HasPrefix(strings.ToUpper(s.name), "TRUNK_") {
|
||||
add(s.name, s.kv["HOST"])
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
var localTrunkState = struct {
|
||||
mu sync.RWMutex
|
||||
data []localTrunk
|
||||
src string
|
||||
}{data: []localTrunk{}}
|
||||
|
||||
// buildLocalTrunks parses the conf and matches each trunk against the LOCAL
|
||||
// reflector's /status (by name, then host) for online + node count.
|
||||
func buildLocalTrunks(client *http.Client) {
|
||||
conf := parseConfTrunks(svxreflectorConf)
|
||||
|
||||
type liveT struct {
|
||||
connected bool
|
||||
nodes int
|
||||
host string
|
||||
}
|
||||
live := map[string]liveT{}
|
||||
// Satellite trunks (chan_svx_sat) live status, keyed by reflector sat id.
|
||||
type liveSat struct {
|
||||
connected bool
|
||||
nodes int
|
||||
}
|
||||
liveSats := map[string]liveSat{}
|
||||
if resp, err := client.Get(reflectorURL); err == nil {
|
||||
func() {
|
||||
defer resp.Body.Close()
|
||||
if resp.StatusCode != http.StatusOK {
|
||||
return
|
||||
}
|
||||
var raw struct {
|
||||
Trunks map[string]struct {
|
||||
Host string `json:"host"`
|
||||
Connected bool `json:"connected"`
|
||||
Nodes []json.RawMessage `json:"nodes"`
|
||||
} `json:"trunks"`
|
||||
TrunksUDP []struct {
|
||||
Name string `json:"name"`
|
||||
Status bool `json:"status"`
|
||||
Host string `json:"host"`
|
||||
} `json:"trunks_UDP"`
|
||||
Satellites map[string]struct {
|
||||
Authenticated bool `json:"authenticated"`
|
||||
Nodes []json.RawMessage `json:"nodes"`
|
||||
} `json:"satellites"`
|
||||
}
|
||||
if json.NewDecoder(resp.Body).Decode(&raw) == nil {
|
||||
for n, t := range raw.Trunks {
|
||||
live[n] = liveT{connected: t.Connected, nodes: len(t.Nodes), host: normTrunkHost(t.Host)}
|
||||
}
|
||||
for id, s := range raw.Satellites {
|
||||
liveSats[id] = liveSat{connected: s.Authenticated, nodes: len(s.Nodes)}
|
||||
}
|
||||
// trunks_UDP peers are named like "TrunkPeer#SE-AUX"; index them
|
||||
// both raw and with the prefix stripped so a conf name matches.
|
||||
for _, t := range raw.TrunksUDP {
|
||||
v := liveT{connected: t.Status, host: normTrunkHost(t.Host)}
|
||||
live[t.Name] = v
|
||||
if s := strings.TrimPrefix(t.Name, "TrunkPeer#"); s != t.Name {
|
||||
live[s] = v
|
||||
}
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
|
||||
out := make([]localTrunk, 0, len(conf))
|
||||
for _, c := range conf {
|
||||
lt := localTrunk{Name: c.name}
|
||||
if v, ok := live[c.name]; ok { // exact name match — most reliable
|
||||
lt.Connected, lt.Nodes = v.connected, v.nodes
|
||||
} else {
|
||||
for _, v := range live { // fall back to host match
|
||||
if v.host == "" {
|
||||
continue
|
||||
}
|
||||
for _, h := range c.hosts {
|
||||
if v.host == h || strings.Contains(v.host, h) || strings.Contains(h, v.host) {
|
||||
lt.Connected, lt.Nodes = v.connected, v.nodes
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
out = append(out, lt)
|
||||
}
|
||||
|
||||
// Satellite trunks (chan_svx_sat): ONE trunk per satellite, named by the
|
||||
// satellite id (e.g. KT4ROY-ASL). All chan_svx_sat entries that share the same
|
||||
// satellite_id are the SAME trunk carrying N talkgroups — they are grouped, not
|
||||
// listed as one-trunk-per-TG. The per-TG labels (callsign + tg) are attached as
|
||||
// TGs so the UI can tell the talkgroups apart under the single trunk name.
|
||||
// Fully dynamic: the satellite set is the union of the configured [SATELLITE]
|
||||
// ids in svxreflector.conf and any live-authenticated sat in the reflector
|
||||
// /status, so new satellites (Chicago, …) appear automatically and offline ones
|
||||
// still show. Secrets from [SATELLITE] are never read/exposed — only the id.
|
||||
satSet := map[string]bool{}
|
||||
for id := range liveSats {
|
||||
satSet[id] = true
|
||||
}
|
||||
for _, id := range satelliteIDsFromReflectorConf(svxreflectorConf) {
|
||||
satSet[id] = true
|
||||
}
|
||||
for _, id := range satTrunkIDs() { // configured satellites always show, even offline
|
||||
satSet[id] = true
|
||||
}
|
||||
if len(satSet) > 0 {
|
||||
ids := make([]string, 0, len(satSet))
|
||||
for id := range satSet {
|
||||
ids = append(ids, id)
|
||||
}
|
||||
sort.Strings(ids)
|
||||
for _, id := range ids {
|
||||
s := liveSats[id] // zero value (offline, 0 nodes) if not live
|
||||
out = append(out, localTrunk{
|
||||
Name: id, // trunk name = satellite id (KT4ROY-ASL)
|
||||
Connected: s.connected,
|
||||
Nodes: s.nodes,
|
||||
TGs: satTGsForTrunk(id), // TGs + per-TG names straight from svxdr.json
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
localTrunkState.mu.Lock()
|
||||
localTrunkState.data = out
|
||||
localTrunkState.src = svxreflectorConf
|
||||
localTrunkState.mu.Unlock()
|
||||
}
|
||||
|
||||
func localTrunksStart(mux *http.ServeMux) {
|
||||
client := &http.Client{Timeout: 8 * time.Second}
|
||||
buildLocalTrunks(client)
|
||||
go func() {
|
||||
t := time.NewTicker(10 * time.Second)
|
||||
defer t.Stop()
|
||||
for range t.C {
|
||||
buildLocalTrunks(client)
|
||||
}
|
||||
}()
|
||||
|
||||
mux.HandleFunc("/api/local-trunks", func(w http.ResponseWriter, r *http.Request) {
|
||||
w.Header().Set("Content-Type", "application/json; charset=utf-8")
|
||||
localTrunkState.mu.RLock()
|
||||
b, _ := json.Marshal(map[string]interface{}{"trunks": localTrunkState.data, "source": localTrunkState.src})
|
||||
localTrunkState.mu.RUnlock()
|
||||
writeMaybeGzip(w, r, b)
|
||||
})
|
||||
}
|
||||
@@ -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
@@ -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"
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
}
|
||||
}()
|
||||
}
|
||||
@@ -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()
|
||||
}
|
||||
@@ -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}`))
|
||||
}
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user