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

413 lines
15 KiB
Go

// 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 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
}