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