Add svxdr: the live-data backend (nodelist + proxy; astmeta/xlink optional), config example, systemd unit, INSTALL section 9
svxdr sources from the internal repo with AGPL headers; install/svxdr.json.example is a plain-reflector layout (reflector 8099 -> proxy 8090 -> nodelist 8098) with CHANGEME secrets and no site values. Co-Authored-By: Claude Fable 5.1 <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,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}`))
|
||||
}
|
||||
Reference in New Issue
Block a user