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

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}`))
}