Skip to content

Commit e361e05

Browse files
committed
feat(core): add fleet-wide Xray and TLS alerts for remote nodes
- Introduce `nodeAlerts` state in Manager to track per-node Xray/certificate health - Start `nodeWatchLoop` goroutine to monitor remote nodes and raise admin events - Report `CertError` from node agent via sync request for panel-side TLS alerting - Include `model.LocalNodeName` in all admin notifications to identify the source - Append user ID in AbuseList fleet-wide view to disambiguate non-unique names
1 parent 30ce448 commit e361e05

10 files changed

Lines changed: 577 additions & 12 deletions

File tree

internal/core/manager.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -196,6 +196,12 @@ type Manager struct {
196196
// nodeGeoFiles holds each node's last-reported geo database status.
197197
nodeGeoMu sync.Mutex
198198
nodeGeoFiles map[int64][]nodeapi.GeoFile
199+
200+
// nodeAlerts is what admins were last told about each node's reachability, Xray
201+
// and certificate — the fleet-wide half of the "Сбой Xray" / "Сертификат TLS"
202+
// admin events (see manager_nodes_notify.go).
203+
nodeAlertMu sync.Mutex
204+
nodeAlerts map[int64]*nodeAlertState
199205
}
200206

201207
// nodeLogEntry is a node's last-reported log tail.
@@ -232,6 +238,7 @@ func New(st *store.Store, sup *xray.Supervisor, opts xray.Options, tls TLSPaths,
232238
nodeGeoFiles: map[int64][]nodeapi.GeoFile{},
233239
nodeHostStats: map[int64]nodeapi.HostStats{},
234240
nodeLogsWanted: map[int64]int64{},
241+
nodeAlerts: map[int64]*nodeAlertState{},
235242
}
236243
if set, err := st.GetSettings(); err == nil {
237244
m.tz = loadLocation(set.Timezone)
@@ -251,6 +258,9 @@ func New(st *store.Store, sup *xray.Supervisor, opts xray.Options, tls TLSPaths,
251258
}
252259
m.sup.SetOnCrash(m.onXrayCrash) // alert admins when Xray exits unexpectedly
253260
m.sup.SetOnRecover(m.onXrayRecover) // ...and tell them when it is back
261+
// The same two alerts for the remote nodes. They have no bot of their own, and a
262+
// node that stops syncing altogether can only be noticed on a timer.
263+
go m.nodeWatchLoop()
254264
go m.reconcileLoop()
255265
go m.proxyLoop()
256266
go m.geoLoop() // auto-refresh geo databases on the operator's cadence

internal/core/manager_nodes.go

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1359,6 +1359,9 @@ func (m *Manager) IngestNodeSync(n *model.Node, req nodeapi.SyncRequest) (*nodea
13591359
}
13601360
m.nodeGeoMu.Unlock()
13611361
}
1362+
// The node's own TLS state, for the fleet-wide "Сертификат TLS" alert. Recorded
1363+
// here, raised by the node sweep — see manager_nodes_notify.go.
1364+
m.NoteNodeCertError(n.ID, req.CertError)
13621365
_ = m.store.UpdateNodeStatus(n.ID, model.NodeStatusUpdate{
13631366
LastSeen: now.Unix(),
13641367
NodeVersion: req.NodeVersion,
Lines changed: 268 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,268 @@
1+
package core
2+
3+
import (
4+
"fmt"
5+
"time"
6+
7+
"github.com/AppsGanin/rospanel/internal/model"
8+
)
9+
10+
// Admin alerts about remote nodes.
11+
//
12+
// A node runs the same Xray and gets its own TLS certificate, but it has no
13+
// Telegram bot of its own — the panel is the only process that can reach the
14+
// operator. So the two admin-event categories that already cover the master's Xray
15+
// and certificate ("Сбой Xray", "Сертификат TLS") are raised here for every node
16+
// too, out of what each node reports on sync.
17+
//
18+
// Every decision is made in one periodic sweep rather than on the sync path, on
19+
// purpose: the most common way a node stops serving — the box is down, the agent
20+
// died, the network went — shows up as the ABSENCE of syncs, which no sync handler
21+
// can observe. Reading the stored rows on a timer sees that failure and a reported
22+
// one the same way, and keeps notification logic off the sync hot path.
23+
const (
24+
// nodeWatchInterval is how often node state is checked for transitions. Half the
25+
// online window, so an unreachable node is reported about a minute after it
26+
// crosses it rather than a whole window later.
27+
nodeWatchInterval = 60 * time.Second
28+
// nodeXrayNotifyThrottle mirrors crashNotifyThrottle for nodes: a node whose Xray
29+
// is crash-looping alerts at most this often.
30+
nodeXrayNotifyThrottle = 5 * time.Minute
31+
// nodeCertErrMax truncates the error text a node reports before it goes into a
32+
// chat message. It is remote input, and an ACME failure can carry a whole
33+
// paragraph of server response.
34+
nodeCertErrMax = 200
35+
)
36+
37+
// nodeAlertState is what admins were last told about one node, plus enough of its
38+
// last-seen state to spot a transition. In memory only: a panel restart re-baselines
39+
// (see the `known` flag) instead of replaying an outage that began before it, which
40+
// is how notifyStatusTransitions treats a restart too.
41+
type nodeAlertState struct {
42+
known bool // false until the first observation ⇒ next sweep only baselines
43+
online bool
44+
xrayUp bool
45+
certSHA string
46+
certSelf bool
47+
48+
// offlineAlerted records that admins were actually told this node is
49+
// unreachable, so the all-clear is only sent for an alarm they saw.
50+
offlineAlerted bool
51+
offlineSince int64 // node's last_seen when it went silent (for the downtime line)
52+
53+
xrayAlerted bool
54+
xrayDownAt time.Time
55+
lastXrayNotify time.Time
56+
57+
// certErr is the last TLS error this node reported (empty ⇒ its cert is fine),
58+
// recorded on sync and acted on by the sweep.
59+
certErr string
60+
lastCertErrAt time.Time
61+
}
62+
63+
// nodeAlertMsg is one pending message: which admin-event category gates it and the
64+
// text. Collected under the lock and sent after it, so a slow Telegram send can't
65+
// block the sweep's next node.
66+
type nodeAlertMsg struct {
67+
bit int64
68+
html string
69+
}
70+
71+
// nodeWatchLoop drives the node alert sweep. The first pass only records a
72+
// baseline, so a panel that starts up next to a long-dead node stays quiet.
73+
func (m *Manager) nodeWatchLoop() {
74+
t := time.NewTicker(nodeWatchInterval)
75+
defer t.Stop()
76+
for {
77+
m.SweepNodeAlerts()
78+
<-t.C
79+
}
80+
}
81+
82+
// SweepNodeAlerts compares every node's current state against what admins were last
83+
// told and sends the differences.
84+
func (m *Manager) SweepNodeAlerts() {
85+
nodes, err := m.store.ListNodes()
86+
if err != nil {
87+
logErr("node alerts: cannot list nodes", "err", err)
88+
return
89+
}
90+
now := time.Now()
91+
live := make(map[int64]struct{}, len(nodes))
92+
for i := range nodes {
93+
n := &nodes[i]
94+
if !n.Enabled || !n.Joined() {
95+
// Switched off on purpose, or never installed on a server: neither is an
96+
// outage. Forget the state so re-enabling starts from a fresh baseline
97+
// rather than announcing an "outage" that was the operator's own doing.
98+
m.forgetNodeAlerts(n.ID)
99+
continue
100+
}
101+
live[n.ID] = struct{}{}
102+
for _, msg := range m.nodeAlertsFor(n, now) {
103+
m.notifyAdminEvent(msg.bit, msg.html)
104+
}
105+
}
106+
m.pruneNodeAlerts(live)
107+
}
108+
109+
// nodeAlertsFor advances one node's alert state and returns the messages that
110+
// transition produced. Sending is left to the caller: the state lock is held here.
111+
func (m *Manager) nodeAlertsFor(n *model.Node, now time.Time) []nodeAlertMsg {
112+
m.nodeAlertMu.Lock()
113+
defer m.nodeAlertMu.Unlock()
114+
st := m.nodeAlertLocked(n.ID)
115+
online := n.Online(now.Unix())
116+
117+
if !st.known {
118+
st.known, st.online, st.xrayUp = true, online, n.XrayRunning
119+
st.certSHA, st.certSelf = n.CertSHA256, n.CertSelfSigned
120+
return nil // baseline: report changes from here on, never the starting state
121+
}
122+
123+
var out []nodeAlertMsg
124+
switch {
125+
case st.online && !online:
126+
st.offlineAlerted, st.offlineSince = true, n.LastSeen
127+
out = append(out, nodeAlertMsg{model.AdminEventXrayDown, fmt.Sprintf(
128+
"⚠️ <b>Нет связи с сервером</b>\n%s\nНе отвечает %s — его пользователи сейчас не обслуживаются.",
129+
nodeLabel(n), fmtDowntime(now.Sub(time.Unix(n.LastSeen, 0))))})
130+
case !st.online && online && st.offlineAlerted:
131+
st.offlineAlerted = false
132+
msg := "✅ <b>Связь с сервером восстановлена</b>\n" + nodeLabel(n)
133+
if st.offlineSince > 0 {
134+
msg += fmt.Sprintf("\nПростой: %s.", fmtDowntime(now.Sub(time.Unix(st.offlineSince, 0))))
135+
}
136+
out = append(out, nodeAlertMsg{model.AdminEventXrayDown, msg})
137+
}
138+
st.online = online
139+
140+
// Everything below reads what the node reported. While it is silent that report
141+
// is stale — its Xray may well be down with the box — so it is not evaluated:
142+
// the unreachable alert above is the one that fits, and a second alarm from
143+
// frozen data would only muddy it.
144+
if !online {
145+
return out
146+
}
147+
148+
switch {
149+
case st.xrayUp && !n.XrayRunning:
150+
// Throttled like the master's own crash alert, so a crash-looping node reports
151+
// at a sane rate. A throttled-away alarm leaves xrayAlerted alone, so no
152+
// all-clear is sent for an outage nobody was told about.
153+
if now.Sub(st.lastXrayNotify) >= nodeXrayNotifyThrottle {
154+
st.lastXrayNotify, st.xrayAlerted, st.xrayDownAt = now, true, now
155+
out = append(out, nodeAlertMsg{model.AdminEventXrayDown, fmt.Sprintf(
156+
"⚠️ <b>Xray аварийно завершился</b>\n%s\nАгент перезапускает процесс автоматически.",
157+
nodeLabel(n))})
158+
}
159+
case !st.xrayUp && n.XrayRunning && st.xrayAlerted:
160+
st.xrayAlerted = false
161+
msg := "✅ <b>Xray снова работает</b>\n" + nodeLabel(n)
162+
if down := now.Sub(st.xrayDownAt); down > time.Second {
163+
msg += fmt.Sprintf("\nПростой: %s.", fmtDowntime(down))
164+
}
165+
out = append(out, nodeAlertMsg{model.AdminEventXrayDown, msg})
166+
}
167+
st.xrayUp = n.XrayRunning
168+
169+
// A changed fingerprint on a CA-signed cert is a renewal that landed. Self-signed
170+
// is the agent's fallback while ACME is unavailable, not an event: it changes on
171+
// its own schedule and says nothing an operator can act on.
172+
if n.CertSHA256 != "" && n.CertSHA256 != st.certSHA && !n.CertSelfSigned {
173+
verb := "обновлён"
174+
if st.certSHA == "" || st.certSelf {
175+
verb = "выпущен" // first real cert for this node, not a renewal
176+
}
177+
msg := fmt.Sprintf("🔒 <b>Сертификат TLS %s</b>\n%s", verb, nodeLabel(n))
178+
if days := certDaysLeft(n.CertExpiresAt, now); days >= 0 {
179+
msg += fmt.Sprintf("\nДействует ещё %d дн.", days)
180+
}
181+
out = append(out, nodeAlertMsg{model.AdminEventCert, msg})
182+
}
183+
st.certSHA, st.certSelf = n.CertSHA256, n.CertSelfSigned
184+
185+
if st.certErr != "" && now.Sub(st.lastCertErrAt) >= certErrNotifyThrottle {
186+
st.lastCertErrAt = now
187+
out = append(out, nodeAlertMsg{model.AdminEventCert, fmt.Sprintf(
188+
"🔓 <b>Не удалось обновить сертификат TLS</b>\n%s\nОшибка: %s",
189+
nodeLabel(n), escHTML(st.certErr))})
190+
}
191+
return out
192+
}
193+
194+
// NoteNodeCertError records the TLS error a node reported on its sync (empty ⇒ its
195+
// certificate is fine). Only the state is written here — the alert is raised by the
196+
// sweep, which owns the throttle and the rest of the node's alert state.
197+
func (m *Manager) NoteNodeCertError(nodeID int64, msg string) {
198+
if len(msg) > nodeCertErrMax {
199+
msg = msg[:nodeCertErrMax] + "…"
200+
}
201+
m.nodeAlertMu.Lock()
202+
defer m.nodeAlertMu.Unlock()
203+
st := m.nodeAlertLocked(nodeID)
204+
if msg != st.certErr {
205+
// A new error — or one that just cleared — starts the throttle over, so the
206+
// next distinct failure is reported promptly instead of waiting out the window
207+
// of the previous one.
208+
st.lastCertErrAt = time.Time{}
209+
}
210+
st.certErr = msg
211+
}
212+
213+
// nodeAlertLocked returns the node's alert state, creating it on first use. The map
214+
// is built lazily so a Manager assembled without New (tests, CLI paths) works too.
215+
// Caller holds nodeAlertMu.
216+
func (m *Manager) nodeAlertLocked(id int64) *nodeAlertState {
217+
if m.nodeAlerts == nil {
218+
m.nodeAlerts = map[int64]*nodeAlertState{}
219+
}
220+
st := m.nodeAlerts[id]
221+
if st == nil {
222+
st = &nodeAlertState{}
223+
m.nodeAlerts[id] = st
224+
}
225+
return st
226+
}
227+
228+
func (m *Manager) forgetNodeAlerts(id int64) {
229+
m.nodeAlertMu.Lock()
230+
defer m.nodeAlertMu.Unlock()
231+
delete(m.nodeAlerts, id)
232+
}
233+
234+
// pruneNodeAlerts drops state for nodes that are gone, so the map tracks the fleet
235+
// rather than every node the panel has ever had.
236+
func (m *Manager) pruneNodeAlerts(live map[int64]struct{}) {
237+
m.nodeAlertMu.Lock()
238+
defer m.nodeAlertMu.Unlock()
239+
for id := range m.nodeAlerts {
240+
if _, ok := live[id]; !ok {
241+
delete(m.nodeAlerts, id)
242+
}
243+
}
244+
}
245+
246+
// nodeLabel names a node in an alert. The host rides along with the name because an
247+
// abuse complaint, a hoster's mail and a traceroute all name the address, not the
248+
// label the operator picked in the panel.
249+
func nodeLabel(n *model.Node) string {
250+
s := "Сервер: " + escHTML(n.Name)
251+
if n.Host != "" {
252+
s += " (" + escHTML(n.Host) + ")"
253+
}
254+
return s
255+
}
256+
257+
// certDaysLeft is whole days from now until expiry, or -1 when the node hasn't
258+
// reported one (an older agent doesn't send it).
259+
func certDaysLeft(expiresAt int64, now time.Time) int {
260+
if expiresAt <= 0 {
261+
return -1
262+
}
263+
d := time.Unix(expiresAt, 0).Sub(now)
264+
if d < 0 {
265+
return 0
266+
}
267+
return int(d.Hours() / 24)
268+
}

0 commit comments

Comments
 (0)