|
| 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