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:
+62
-14
@@ -230,32 +230,80 @@ Answer the prompts (callsign, e-mail, password, admin level). Levels:
|
|||||||
|
|
||||||
Log in on the web page with that callsign. The **Admin** menu appears.
|
Log in on the web page with that callsign. The **Admin** menu appears.
|
||||||
|
|
||||||
## 9. Live data API — what this repository does NOT contain
|
## 9. Live data backend: svxdr
|
||||||
|
|
||||||
The dashboard (node list, map, monitor, last-heard, trunks) fetches JSON and
|
The dashboard (node list, map, monitor, last-heard, trunks) fetches JSON and
|
||||||
Server-Sent Events from `/svxportal/api/...`, which nginx proxies to
|
Server-Sent Events from `/svxportal/api/...`, which nginx proxies to
|
||||||
`127.0.0.1:8098`. That backend service is **not included** here. Without it
|
`127.0.0.1:8098`. That backend is **svxdr**, included in `svxdr/`: one Go
|
||||||
the account/admin/talkgroup pages work, but the front page's node list, map,
|
binary with four modules switched on or off per box in `/etc/svxlink/svxdr.json`.
|
||||||
monitor, last-heard and trunks views simply stay **empty**. There is no error
|
|
||||||
banner: the API helper returns null on a failed fetch and the page keeps its
|
|
||||||
empty tables. Check the browser console (404 on `/svxportal/api/status`) if
|
|
||||||
you are unsure.
|
|
||||||
|
|
||||||
Endpoints the UI expects:
|
| Module | What it does | A plain reflector needs it? |
|
||||||
|
|--------|--------------|-----------------------------|
|
||||||
|
| `proxy` | reads the reflector's own `/status`, caches it, serves it enriched on 8090 | yes |
|
||||||
|
| `nodelist` | builds the node/lastheard/monitor/trunk data from the proxy + Redis, serves `/api/*` on 8098 | yes |
|
||||||
|
| `astmeta` | AllStar/Asterisk AMI talker metadata for USRP bridges | no, leave `false` |
|
||||||
|
| `xlink` | relays talker events to an MQTT broker | no, leave `false` |
|
||||||
|
|
||||||
|
Without svxdr the account/admin/talkgroup pages work, but the front page's
|
||||||
|
live views stay **empty** with no error banner (404 on `/svxportal/api/status`
|
||||||
|
in the browser console).
|
||||||
|
|
||||||
|
### Build
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo apt install golang-go # Go 1.24 or newer
|
||||||
|
cd svxdr && CGO_ENABLED=0 go build -trimpath -o svxdr . && cd ..
|
||||||
|
sudo install -m0755 svxdr/svxdr /usr/local/bin/svxdr
|
||||||
|
```
|
||||||
|
|
||||||
|
### Configure
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp install/svxdr.json.example /etc/svxlink/svxdr.json
|
||||||
|
sudo chmod 640 /etc/svxlink/svxdr.json
|
||||||
|
sudoedit /etc/svxlink/svxdr.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Set at least:
|
||||||
|
|
||||||
|
- `redis.socket` and `redis.password` -> the same Redis as `auth/config.php` (step 3).
|
||||||
|
- `proxy.reflector_status_url` -> your reflector's `HTTP_SRV_PORT` status URL.
|
||||||
|
With `HTTP_SRV_PORT=8099` in `svxreflector.conf` (as in
|
||||||
|
`install/svxreflector.conf.example`) the default `http://127.0.0.1:8099/status` is right.
|
||||||
|
- `nodelist.reflector_status_url` stays on the proxy (`http://127.0.0.1:8090/status`).
|
||||||
|
- All `CHANGEME` tokens: any random string; the ingest tokens only matter if a
|
||||||
|
remote box posts talker metadata to you.
|
||||||
|
- `nodelist.federation.seeds`: `/status` URLs of other reflectors you want in the
|
||||||
|
map that your own trunks do not reach. Empty = only what the crawl finds.
|
||||||
|
`aggregate_url` empty disables the gap detector.
|
||||||
|
|
||||||
|
The port layout must match the nginx config: reflector 8099 -> proxy 8090 ->
|
||||||
|
nodelist 8098 -> nginx `/svxportal/api/`.
|
||||||
|
|
||||||
|
### Run
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo cp install/systemd/svxdr.service /etc/systemd/system/svxdr.service
|
||||||
|
sudo systemctl daemon-reload
|
||||||
|
sudo systemctl enable --now svxdr
|
||||||
|
systemctl status svxdr --no-pager
|
||||||
|
curl -s http://127.0.0.1:8098/healthz # -> ok
|
||||||
|
curl -s http://127.0.0.1:8098/api/status | head -c 300
|
||||||
|
```
|
||||||
|
|
||||||
|
Config changes are picked up with `sudo systemctl reload svxdr` (SIGHUP), no
|
||||||
|
restart needed. `journalctl -u svxdr -f` shows what it polls and serves.
|
||||||
|
|
||||||
|
Endpoints the UI uses:
|
||||||
|
|
||||||
| Path | Used by |
|
| Path | Used by |
|
||||||
|------|---------|
|
|------|---------|
|
||||||
| `/svxportal/api/status` | node table, topology |
|
| `/svxportal/api/status` | node table, topology, CTCSS |
|
||||||
| `/svxportal/api/stream` | live updates (SSE) |
|
| `/svxportal/api/stream` | live updates (SSE) |
|
||||||
| `/svxportal/api/lastheard` | last-heard list |
|
| `/svxportal/api/lastheard` | last-heard list |
|
||||||
| `/svxportal/api/monitor-nodes` | monitor view |
|
| `/svxportal/api/monitor-nodes` | monitor view |
|
||||||
| `/svxportal/api/local-trunks` | trunks view |
|
| `/svxportal/api/local-trunks` | trunks view |
|
||||||
|
|
||||||
You need to provide something that serves these, for example a small service
|
|
||||||
that reads your reflector's `HTTP_SRV_PORT` status endpoint
|
|
||||||
([GeuReflector](https://github.com/audric/GeuReflector) serves `/status` there)
|
|
||||||
and republishes it. The static reflector status page in `help.html` describes the fields.
|
|
||||||
|
|
||||||
## 10. Reflector admin features (optional, need sudo helpers)
|
## 10. Reflector admin features (optional, need sudo helpers)
|
||||||
|
|
||||||
The pages **Admin → Reflector**, **Certificates**, **Node control** and **MQTT**
|
The pages **Admin → Reflector**, **Certificates**, **Node control** and **MQTT**
|
||||||
|
|||||||
@@ -6,7 +6,9 @@ verification, and admin pages for accounts, node certificates, node control and
|
|||||||
talkgroup requests.
|
talkgroup requests.
|
||||||
|
|
||||||
Static HTML/JS (Bootstrap from jsdelivr) plus a small PHP backend that keeps
|
Static HTML/JS (Bootstrap from jsdelivr) plus a small PHP backend that keeps
|
||||||
its data in Redis. No build step.
|
its data in Redis, and **svxdr**, a Go daemon that turns the reflector's
|
||||||
|
`/status` into the live node, lastheard, monitor and trunk data the dashboard
|
||||||
|
shows (`svxdr/`, one `go build`).
|
||||||
|
|
||||||
- **Reflector:** this portal is used with [GeuReflector](https://github.com/audric/GeuReflector)
|
- **Reflector:** this portal is used with [GeuReflector](https://github.com/audric/GeuReflector)
|
||||||
(SvxReflector with reflector-to-reflector trunking). Its status endpoint
|
(SvxReflector with reflector-to-reflector trunking). Its status endpoint
|
||||||
@@ -15,10 +17,10 @@ its data in Redis. No build step.
|
|||||||
- **Config templates:** [`install/`](install/)
|
- **Config templates:** [`install/`](install/)
|
||||||
- **User help:** `help.html` (served at `/help.html`)
|
- **User help:** `help.html` (served at `/help.html`)
|
||||||
|
|
||||||
This repository contains the web application only. The live-data API it
|
Not included: the sudo helper scripts the reflector admin pages call
|
||||||
consumes (`/svxportal/api/...`) and the sudo helper scripts used by the
|
(`svxreflector-pty`, `svxreflector-users`, `svxreflector-systemctl`,
|
||||||
reflector admin pages are site-specific and not included; INSTALL.md
|
`svx-mqtt-provision`). They are specific to how each reflector box is set up;
|
||||||
sections 9 and 10 describe what they must provide.
|
INSTALL.md section 10 describes what they must do.
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
@@ -85,8 +85,8 @@ server {
|
|||||||
location / { try_files $uri $uri/ =404; }
|
location / { try_files $uri $uri/ =404; }
|
||||||
|
|
||||||
# Live data API (/svxportal/api/status, /stream, /lastheard, /monitor-nodes,
|
# Live data API (/svxportal/api/status, /stream, /lastheard, /monitor-nodes,
|
||||||
# /local-trunks). The web UI expects a JSON/SSE backend here on 127.0.0.1:8098.
|
# /local-trunks), served by svxdr's nodelist module on 127.0.0.1:8098.
|
||||||
# That backend is NOT part of this repository — see INSTALL.md, section 8.
|
# See INSTALL.md, section 9.
|
||||||
location ^~ /svxportal/api/ {
|
location ^~ /svxportal/api/ {
|
||||||
proxy_pass http://127.0.0.1:8098/api/;
|
proxy_pass http://127.0.0.1:8098/api/;
|
||||||
proxy_http_version 1.1;
|
proxy_http_version 1.1;
|
||||||
|
|||||||
@@ -0,0 +1,101 @@
|
|||||||
|
{
|
||||||
|
"modules": {
|
||||||
|
"astmeta": false,
|
||||||
|
"nodelist": true,
|
||||||
|
"proxy": true,
|
||||||
|
"xlink": false
|
||||||
|
},
|
||||||
|
"redis": {
|
||||||
|
"socket": "/run/redis/redis.sock",
|
||||||
|
"password": "CHANGEME",
|
||||||
|
"db": 0,
|
||||||
|
"status_key": "/registry/svxlink/status/json",
|
||||||
|
"status_hash_key": "/registry/svxlink/status/hash",
|
||||||
|
"pubsub_chan": "/registry/svxlink/status/update",
|
||||||
|
"nodes_online_key": "/registry/svxlink/nodes/online",
|
||||||
|
"node_key_prefix": "/registry/svxlink/node",
|
||||||
|
"node_connect_chan": "/registry/svxlink/node/connect",
|
||||||
|
"node_disconnect_chan": "/registry/svxlink/node/disconnect",
|
||||||
|
"node_update_chan": "/registry/svxlink/node/update",
|
||||||
|
"lastheard_key": "/registry/svxlink/lastheard"
|
||||||
|
},
|
||||||
|
"trunk": {
|
||||||
|
"satellite_id": "",
|
||||||
|
"host": "127.0.0.1",
|
||||||
|
"port": 5403
|
||||||
|
},
|
||||||
|
"lastheard": {
|
||||||
|
"bridge_location_format": "{system} via {node} / {talker}",
|
||||||
|
"bridge_location_format_no_system": "via {node} / {talker}"
|
||||||
|
},
|
||||||
|
"nodes": [],
|
||||||
|
"astmeta": {
|
||||||
|
"ami_host": "127.0.0.1",
|
||||||
|
"ami_port": "5038",
|
||||||
|
"ami_user": "astmeta",
|
||||||
|
"ami_secret": "CHANGEME",
|
||||||
|
"ignore_nodes": [
|
||||||
|
"1999",
|
||||||
|
"1998"
|
||||||
|
],
|
||||||
|
"poll_interval": "1s",
|
||||||
|
"hang_time": "2s",
|
||||||
|
"network": "AllStar",
|
||||||
|
"usrp_target": "127.0.0.1:4001",
|
||||||
|
"format": "info",
|
||||||
|
"refresh_interval": "10s",
|
||||||
|
"lookup_enabled": true,
|
||||||
|
"lookup_url": "http://allmondb.allstarlink.org/",
|
||||||
|
"cache_ttl": "24h",
|
||||||
|
"mqtt_enabled": false,
|
||||||
|
"nodelist_url": "",
|
||||||
|
"nodelist_token": "CHANGEME",
|
||||||
|
"nodelist_peers": []
|
||||||
|
},
|
||||||
|
"nodelist": {
|
||||||
|
"listen_addr": "127.0.0.1:8098",
|
||||||
|
"reflector_status_url": "http://127.0.0.1:8090/status",
|
||||||
|
"poll_interval": "1s",
|
||||||
|
"reflector_log_path": "/var/log/svxreflector",
|
||||||
|
"network_json_path": "/var/www/html/svxportal/network.json",
|
||||||
|
"astdb_url": "http://allmondb.allstarlink.org/",
|
||||||
|
"talker_meta_ttl": "30s",
|
||||||
|
"talker_ingest_token": "CHANGEME",
|
||||||
|
"allstar_nodes_ttl": "30m",
|
||||||
|
"external": {
|
||||||
|
"pull_scheme": "https",
|
||||||
|
"pull_path": "/svx_externalpull.php",
|
||||||
|
"pull_interval": "30s",
|
||||||
|
"status_ports": [],
|
||||||
|
"status_path": "/status",
|
||||||
|
"proxy_path": "/proxy_status.php",
|
||||||
|
"detail_path": "/geu/status/",
|
||||||
|
"max_refs": 100,
|
||||||
|
"stale_ttl": "5m"
|
||||||
|
},
|
||||||
|
"federation": {
|
||||||
|
"seeds": [],
|
||||||
|
"aggregate_url": "",
|
||||||
|
"aggregate_interval": "5m"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"proxy": {
|
||||||
|
"listen": "127.0.0.1:8090",
|
||||||
|
"reflector_status_url": "http://127.0.0.1:8099/status",
|
||||||
|
"cache_ttl": "200ms",
|
||||||
|
"fetch_timeout": "5s",
|
||||||
|
"meta_ttl": "30s",
|
||||||
|
"ingest_token": "CHANGEME",
|
||||||
|
"upstream_status_url": "",
|
||||||
|
"forward_talker_url": "",
|
||||||
|
"sync_interval": "2s",
|
||||||
|
"remote_ttl": "60s"
|
||||||
|
},
|
||||||
|
"xlink": {
|
||||||
|
"broker": "",
|
||||||
|
"user": "",
|
||||||
|
"pass": "CHANGEME",
|
||||||
|
"topic": "callmgr/xlink/talker",
|
||||||
|
"origin": ""
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
# svxdr — live-data daemon for SvxPortal. Install:
|
||||||
|
# sudo cp install/systemd/svxdr.service /etc/systemd/system/ && sudo systemctl daemon-reload && sudo systemctl enable --now svxdr
|
||||||
|
# Build and configure per INSTALL.md section 9.
|
||||||
|
[Unit]
|
||||||
|
Description=svxdr — merged svx daemon (astmeta + nodelist + proxy + xlink)
|
||||||
|
After=network-online.target redis-server.service
|
||||||
|
Wants=network-online.target
|
||||||
|
|
||||||
|
[Service]
|
||||||
|
Type=simple
|
||||||
|
# One binary, one config; which subsystems run is decided by "modules" in the
|
||||||
|
# JSON (or override with flags, e.g. ExecStart=... --nodelist --proxy).
|
||||||
|
ExecStart=/usr/local/bin/svxdr --config /etc/svxlink/svxdr.json
|
||||||
|
Restart=on-failure
|
||||||
|
RestartSec=3
|
||||||
|
# Reload config in place (SIGHUP) without dropping the process.
|
||||||
|
ExecReload=/bin/kill -HUP $MAINPID
|
||||||
|
# Match the file/socket permissions the old services used (redis socket, logs,
|
||||||
|
# /var/www network.json). Adjust User/Group to whatever nodelist ran as.
|
||||||
|
#User=root
|
||||||
|
LimitNOFILE=65536
|
||||||
|
|
||||||
|
[Install]
|
||||||
|
WantedBy=multi-user.target
|
||||||
@@ -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