// 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 Nodes, the simple way: read ONE reflector /status endpoint that // already carries the whole picture (local nodes + every trunk with its nodes) // and reshape it into {local, trunks:[{name,host,nodes}]}. No recursive crawl. // // Which URL is read is per-box config MONITOR_STATUS_URL (falls back to // REFLECTOR_STATUS_URL): // hub box → https:///geu/status/ (the master's full view) // bridge box → http://127.0.0.1:8080/status (its own reflector) import ( "encoding/json" "io" "net/http" "regexp" "sort" "strconv" "strings" "sync" "time" "svxportal/svxdr/statusnorm" ) var monitorStatusURL = env("MONITOR_STATUS_URL", reflectorURL) // monitorInterval is how often REMOTE status reads (a Monitor->Nodes URL or a // trunk_status topology URL that points at another server) run. Gentle by default // so a shared hub is never hammered; override via svxdr.json nodelist.monitor_interval. // A LOCAL read (127.0.0.1) is free and refreshes fast — see localReadInterval. var monitorInterval = 60 * time.Second // localReadInterval: reading our OWN reflector costs nothing, so do it often. var localReadInterval = 5 * time.Second // isLocalURL reports whether a status URL points at this host (loopback). Local // reads may be frequent; remote reads must be throttled to monitorInterval. func isLocalURL(u string) bool { return strings.Contains(u, "127.0.0.1") || strings.Contains(u, "localhost") || strings.Contains(u, "[::1]") } // readInterval picks the fast (local) or gentle (remote) cadence for a URL. func readInterval(u string) time.Duration { if isLocalURL(u) { return localReadInterval } return monitorInterval } type monNode struct { Callsign string `json:"callsign"` TG string `json:"tg,omitempty"` NodeClass string `json:"nodeClass,omitempty"` Mode string `json:"mode,omitempty"` Talker bool `json:"talker,omitempty"` } // uniquifyNames makes every node name in the view unique WITHOUT ever touching the // Local (top-level) nodes — those are the reflector's own map keys, exactly what the // Reflector clients view shows, so renaming them would make the two views disagree. // Only trunk/satellite array nodes (which have no key and never appear in Reflector // clients) are numbered, and only enough to stay clear of every name already taken // (Local keys + earlier array nodes): SA7AUX -> SA7AUX-1, SA7AUX-2, … func uniquifyNames(out *monStatus) { taken := map[string]bool{} for _, n := range out.Local { // canonical, never renamed taken[n.Callsign] = true } next := map[string]int{} for i := range out.Trunks { for j := range out.Trunks[i].Nodes { base := out.Trunks[i].Nodes[j].Callsign if !taken[base] { taken[base] = true continue } for { next[base]++ cand := base + "-" + strconv.Itoa(next[base]) if !taken[cand] { taken[cand] = true out.Trunks[i].Nodes[j].Callsign = cand break } } } } } type monTrunk struct { Name string `json:"name"` Host string `json:"host,omitempty"` Connected bool `json:"connected"` Nodes []monNode `json:"nodes"` } type monStatus struct { Local []monNode `json:"local"` Trunks []monTrunk `json:"trunks"` Source string `json:"source"` UpdatedAt string `json:"updated_at"` } var monState = struct { mu sync.RWMutex data monStatus }{} // rawNode is one node as it appears either as a value in the top-level "nodes" // object (keyed by callsign) or as an item in a trunk's "nodes" array. type rawNode struct { Callsign string `json:"callsign"` SW string `json:"sw"` TG json.Number `json:"tg"` NodeClass string `json:"nodeClass"` RestrictedTG bool `json:"restrictedTG"` IsTalker bool `json:"isTalker"` System string `json:"system"` // per-node link/type from the data (e.g. "DMR", "DMR/YSF", "DMR/Hytera") Reflector string `json:"Reflector"` // which reflector this node is on (master status carries it) MonitoredTGs []json.Number `json:"monitoredTGs"` Links []struct { LocalTg json.Number `json:"localTg"` } `json:"links"` } // masterReflectorName picks a label for the monitored master's own nodes purely // from the DATA (the Reflector field the nodes report) — no dependency on the // local conf format, so every box derives it the same way. func masterReflectorName(nodes map[string]json.RawMessage) string { count := map[string]int{} for _, raw := range nodes { var r struct { Reflector string `json:"Reflector"` } if json.Unmarshal(raw, &r) == nil && strings.TrimSpace(r.Reflector) != "" { count[r.Reflector]++ } } best, n := "", 0 for k, c := range count { if c > n { best, n = k, c } } return best } // monModeOf: real type from the software string (svx nodes carry no "mode"). func monModeOf(sw string) string { s := strings.ToLower(sw) switch { case strings.Contains(s, "chan_pbx"): return "PBX" case strings.Contains(s, "chan_svx_sat"): return "AllStar" case strings.Contains(s, "chan_svx"): return "AllStar" case strings.Contains(s, "svxlink"): return "SVX" case strings.Contains(s, "mmdvm"), strings.Contains(s, "brandmeister"), strings.Contains(s, "dmr"): return "DMR" case strings.Contains(s, "ysf"): return "YSF" case strings.Contains(s, "nxdn"): return "NXDN" case strings.Contains(s, "m17"): return "M17" case sw != "": return sw default: return "" } } // A bridge node just links systems together (chan_svx asterisk, a DMR/trunk // bridge, or an -AC/-DMR/-T suffix) — not a real user/radio client, so we never // show it. chan_svx_sat IS a real AllStar node over the satellite trunk and is // kept. Mirrors the TG Monitor's isBridge so both views show only real users. var bridgeSuffix = regexp.MustCompile(`(?i)-(AC|DMR|T)$`) func isBridgeNode(callsign, sw, nodeClass string) bool { s := strings.ToLower(sw) // AllStar radio-network peers ARE shown — the links to svx/dmr/ysf/nxdn/m17 // are exactly what the user wants visible under their node/trunk. chan_svx and // chan_svx_sat both count. Only pure non-radio infrastructure stays hidden. if strings.Contains(s, "chan_svx") || strings.Contains(s, "chan_pbx") { // chan_svx + chan_svx_sat + chan_pbx_svx (PBX) return false } nc := strings.ToLower(nodeClass) switch nc { case "dmr", "ysf", "nxdn", "m17": return false // radio-network peer — show case "xlx", "mumble", "stream", "echolink", "sip", "zello": return true // non-radio / D-Star infra — hide } // sw-based radio bridges (DMR_/YSF_/NXDN_/M17_Bridge) → show; the remaining // "*bridge" (XLX_/Mumble_/Janus Bridge) is non-radio infra → hide. if strings.Contains(s, "dmr") || strings.Contains(s, "ysf") || strings.Contains(s, "nxdn") || strings.Contains(s, "m17") { return false } if strings.Contains(s, "bridge") { return true } // pure infrastructure gateway callsigns are never real peers. u := strings.ToUpper(callsign) if strings.HasPrefix(u, "SIP") || strings.HasPrefix(u, "DCS") || strings.HasPrefix(u, "XLX") || strings.Contains(u, "MUMBLE") { return true } return false // default: show (real users + radio peers) } func toMonNode(callsign string, raw json.RawMessage) (monNode, bool) { var r rawNode _ = json.Unmarshal(raw, &r) // Display name = the node's KEY (its registered identity — exactly what the // Reflector clients view shows via the /status map key), so the two views can // never disagree on a name. Trunk/satellite array nodes have no key -> use the // callsign field (which statusnorm may have set to the per-TG group name). cs := callsign if cs == "" { cs = r.Callsign } // A configured node (in nodes[]) is an intentional own node — always show it, // even if its nodeClass would otherwise read as infra (e.g. a DMR gateway as // "Stream"). Everything else keeps the bridge/infra filter. if !isConfiguredName(cs) && isBridgeNode(cs, r.SW, r.NodeClass) { return monNode{}, false // bridge, not a real user — hide it } // TG: the node's own tg, else its first monitored/linked TG. tg := "" if s := r.TG.String(); s != "" && s != "0" { tg = s } else if len(r.MonitoredTGs) > 0 { tg = r.MonitoredTGs[0].String() } else if len(r.Links) > 0 { tg = r.Links[0].LocalTg.String() } if r.RestrictedTG && tg != "" { tg = "*" + tg } // Per-TG name: several sat nodes share ONE reflector login (KT4ROY-ASL), so // under a trunk they'd all show that same callsign. If svxdr.json configures a // per-TG via for this node's TG, show THAT (KT4ROY-ASL1..4) — the trunk's own // name is unaffected. One place (svxdr.json) drives it. // Type/mode, in priority order (no hardcoding of any mode): // 1) config nodes[].mode (explicit override, keyed by display name) // 2) the node's own "system" field from the data (DMR, DMR/YSF, DMR/Hytera…) // 3) type derived from the software string mode := modeForName(cs) if mode == "" { mode = r.System } if mode == "" { mode = monModeOf(r.SW) } return monNode{Callsign: cs, TG: tg, NodeClass: r.NodeClass, Mode: mode, Talker: r.IsTalker}, true } // ownLocalNodes reads OUR OWN reflector (reflectorURL) and returns its top-level // nodes — the ones genuinely local to THIS server. Used to keep Local accurate // when MONITOR_STATUS_URL points at a foreign master. func ownLocalNodes(client *http.Client) ([]monNode, bool) { resp, err := client.Get(reflectorURL) if err != nil { return nil, false } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return nil, false } var raw struct { Nodes map[string]json.RawMessage `json:"nodes"` } if json.NewDecoder(resp.Body).Decode(&raw) != nil { return nil, false } var out []monNode for cs, rn := range raw.Nodes { if mn, ok := toMonNode(cs, rn); ok { out = append(out, mn) } } return out, true } func buildMonitor(client *http.Client, generated string) (monStatus, bool) { resp, err := client.Get(monitorStatusURL) if err != nil { return monStatus{}, false } defer resp.Body.Close() if resp.StatusCode != http.StatusOK { return monStatus{}, false } body, err := io.ReadAll(resp.Body) if err != nil { return monStatus{}, false } // Normalize at ingest: sat-login node callsigns -> per-TG name. Covers the // case where monitorStatusURL is a foreign master (e.g. a hub box reads a foreign master // where its satellite nodes federate in). A local read via the proxy is already // normalized; re-normalizing is a harmless no-op. body = statusnorm.Normalize(body, renameSatNode) var raw struct { Nodes map[string]json.RawMessage `json:"nodes"` Trunks map[string]struct { Host string `json:"host"` Connected bool `json:"connected"` Nodes []json.RawMessage `json:"nodes"` } `json:"trunks"` // Satellite trunks (chan_svx_sat): each id (e.g. KT4ROY-ASL) carries N // AllStar nodes. They live here, NOT in top-level nodes, so Monitor→Nodes // must read them too or the sat nodes are invisible. Satellites map[string]struct { Authenticated bool `json:"authenticated"` Nodes []json.RawMessage `json:"nodes"` } `json:"satellites"` } if json.Unmarshal(body, &raw) != nil { return monStatus{}, false } out := monStatus{Source: monitorStatusURL, UpdatedAt: generated} // Local = only OUR reflector's own nodes. Whenever MONITOR_STATUS_URL is a // REMOTE master (not our own reflector), ALWAYS split: read our OWN reflector for // the local set and put the master's own top-level nodes under a section instead. // This must behave the SAME on every box regardless of its svxreflector.conf // dialect — so the split is decided purely by "is the master remote", and the // section label is derived from the DATA (masterReflectorName) when the conf // doesn't name the uplink trunk. No per-box special cases. doSplit := reflectorURL != monitorStatusURL masterLabel := "" if doSplit { if masterLabel = masterReflectorName(raw.Nodes); masterLabel == "" { masterLabel = "uplink" } } selfCalls := map[string]bool{} if doSplit { if selfNodes, ok := ownLocalNodes(client); ok { for _, mn := range selfNodes { out.Local = append(out.Local, mn) selfCalls[strings.ToUpper(mn.Callsign)] = true } } } var foreignTop []monNode for cs, rn := range raw.Nodes { mn, ok := toMonNode(cs, rn) if !ok { continue } if !doSplit { out.Local = append(out.Local, mn) } else if !selfCalls[strings.ToUpper(mn.Callsign)] { foreignTop = append(foreignTop, mn) // the master's own node → its trunk section } } sort.Slice(out.Local, func(i, j int) bool { return out.Local[i].Callsign < out.Local[j].Callsign }) // The master's own nodes become one trunk section (we reach it over a trunk). if len(foreignTop) > 0 { sort.Slice(foreignTop, func(i, j int) bool { return foreignTop[i].Callsign < foreignTop[j].Callsign }) out.Trunks = append(out.Trunks, monTrunk{Name: masterLabel, Connected: true, Nodes: foreignTop}) } for name, t := range raw.Trunks { mt := monTrunk{Name: name, Host: t.Host, Connected: t.Connected} for _, rn := range t.Nodes { if mn, ok := toMonNode("", rn); ok { mt.Nodes = append(mt.Nodes, mn) } } sort.Slice(mt.Nodes, func(i, j int) bool { return mt.Nodes[i].Callsign < mt.Nodes[j].Callsign }) out.Trunks = append(out.Trunks, mt) } // Satellite trunks: one section per satellite id (KT4ROY-ASL), its AllStar // nodes (KT4ROY-ASL1..4 via toMonNode's per-TG rename) shown under it. for id, s := range raw.Satellites { mt := monTrunk{Name: id, Connected: s.Authenticated} for _, rn := range s.Nodes { if mn, ok := toMonNode("", rn); ok { mt.Nodes = append(mt.Nodes, mn) } } sort.Slice(mt.Nodes, func(i, j int) bool { return mt.Nodes[i].Callsign < mt.Nodes[j].Callsign }) out.Trunks = append(out.Trunks, mt) } // Connected trunks first, then by name — stable, tidy sections. sort.Slice(out.Trunks, func(i, j int) bool { if out.Trunks[i].Connected != out.Trunks[j].Connected { return out.Trunks[i].Connected } return out.Trunks[i].Name < out.Trunks[j].Name }) // Make every node name unique across the whole view (backend, before the web). uniquifyNames(&out) return out, true } func monitorNodesStart(mux *http.ServeMux) { client := &http.Client{Timeout: 12 * time.Second} refresh := func() { // stamp with a wall-clock time computed off the request (Date.now is // unavailable in workflow scripts, but this is normal server code). if next, ok := buildMonitor(client, time.Now().UTC().Format(time.RFC3339)); ok { monState.mu.Lock() monState.data = next monState.mu.Unlock() } } refresh() go func() { // Local reflector reads are free (fast); a remote master URL is throttled. t := time.NewTicker(readInterval(monitorStatusURL)) defer t.Stop() for range t.C { refresh() } }() mux.HandleFunc("/api/monitor-nodes", func(w http.ResponseWriter, r *http.Request) { w.Header().Set("Content-Type", "application/json; charset=utf-8") monState.mu.RLock() b, _ := json.Marshal(monState.data) monState.mu.RUnlock() writeMaybeGzip(w, r, b) }) }