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