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