413 lines
15 KiB
Go
413 lines
15 KiB
Go
// svxdr — live-data daemon for Reflector Dashboard (nodelist + proxy + astmeta + xlink)
|
|
// Copyright (C) 2026 SA7AUX (Björn)
|
|
// SPDX-License-Identifier: AGPL-3.0-or-later
|
|
//
|
|
// This program is free software: you can redistribute it and/or modify it under
|
|
// the terms of the GNU Affero General Public License as published by the Free
|
|
// Software Foundation, either version 3 of the License, or (at your option) any
|
|
// later version. See the LICENSE file for details.
|
|
|
|
// Package 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 a reflector box and only astmeta on an AllStar bridge 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
|
|
}
|