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