Skip to content

Commit 2403d0d

Browse files
committed
feat(alerts): emit wireguard-disconnect when a peer drops its handshake (#1348)
pollWireGuard now tracks when each peer became active and emits a VPN-category info event on the transition to inactive, with the session duration in the description. Transitions are evaluated only on successful polls; a peer never seen active cannot emit a disconnect, and the first post-boot cycle stays silent (warmup).
1 parent b6ea1f8 commit 2403d0d

6 files changed

Lines changed: 277 additions & 33 deletions

File tree

‎app/public/locales/en/translation.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,10 @@
243243
"title": "WireGuard handshake",
244244
"description": "{{peer}} connected"
245245
},
246+
"wireguard-disconnect": {
247+
"title": "WireGuard disconnect",
248+
"description": "{{peer}} disconnected after {{duration}}"
249+
},
246250
"firmware-upgrade-done": {
247251
"title": "Firmware upgraded",
248252
"description": "{{router}}: {{from}} → {{to}} (owut, {{origin}})"

‎app/public/locales/es/translation.json‎

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -243,6 +243,10 @@
243243
"title": "Handshake WireGuard",
244244
"description": "{{peer}} conectado"
245245
},
246+
"wireguard-disconnect": {
247+
"title": "Desconexión WireGuard",
248+
"description": "{{peer}} desconectado tras {{duration}}"
249+
},
246250
"firmware-upgrade-done": {
247251
"title": "Firmware actualizado",
248252
"description": "{{router}}: {{from}} → {{to}} (owut, {{origin}})"

‎server-go/internal/adapters/live.go‎

Lines changed: 65 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -306,6 +306,7 @@ type Live struct {
306306
recoverStreak map[string]int
307307
engine *alerts.Engine
308308
wgActive map[string]bool
309+
wgActiveSince map[string]time.Time // when each active peer became active (disconnect duration)
309310
weakAlerted map[string]int64
310311
onlineMacs map[string]bool
311312
// unknownGrace: ticks consecutivos online+sin nombre (Name == MAC) por
@@ -455,6 +456,7 @@ func NewLive(cfg *config.Config, d *db.DB, initial []RouterConfig, pool *SSHPool
455456
recoverStreak: map[string]int{},
456457
engine: alerts.New(d, nil),
457458
wgActive: map[string]bool{},
459+
wgActiveSince: map[string]time.Time{},
458460
weakAlerted: map[string]int64{},
459461
onlineMacs: map[string]bool{},
460462
unknownGrace: map[string]int{},
@@ -2431,7 +2433,7 @@ func (l *Live) pollAdGuard(ctx context.Context) *AdGuardStats {
24312433
return stats
24322434
}
24332435

2434-
// pollWireGuard: stats WG del gateway + alerta de handshake nuevo.
2436+
// pollWireGuard: gateway WireGuard stats plus connect/disconnect alerts.
24352437
func (l *Live) pollWireGuard(devices []Device) *WireGuardStats {
24362438
l.mu.Lock()
24372439
gw := l.gatewayCfg
@@ -2450,36 +2452,90 @@ func (l *Live) pollWireGuard(devices []Device) *WireGuardStats {
24502452
log.Printf("[netpulse] WireGuard no disponible: %v", err)
24512453
return &WireGuardStats{Interface: l.cfg.WGInterface, Subnet: "", Status: "inactive", Peers: []WGPeer{}}
24522454
}
2453-
// Alerta en handshake nuevo (peer pasa a activo)
2455+
// Transitions run only on a successful poll: a failed poll would mark every
2456+
// previously active peer as spuriously disconnected.
2457+
l.applyWireGuardTransitions(stats, gw.ID)
2458+
return stats
2459+
}
2460+
2461+
// applyWireGuardTransitions emits connect (handshake) and disconnect alerts for
2462+
// peers whose active state changed since the last successful poll, and keeps
2463+
// wgActive/wgActiveSince in sync. A peer that was never active cannot emit a
2464+
// disconnect; the first post-boot cycle never emits a connect (warmup).
2465+
func (l *Live) applyWireGuardTransitions(stats *WireGuardStats, gwID string) {
24542466
activeNow := map[string]bool{}
24552467
for _, p := range stats.Peers {
24562468
if p.Active {
24572469
activeNow[p.ID] = true
24582470
}
24592471
}
24602472
l.mu.Lock()
2473+
defer l.mu.Unlock()
2474+
// Disconnects: previously active peers no longer present in activeNow.
2475+
for id := range l.wgActive {
2476+
if activeNow[id] {
2477+
continue
2478+
}
2479+
name := id
2480+
for _, p := range stats.Peers {
2481+
if p.ID == id {
2482+
name = p.Name
2483+
}
2484+
}
2485+
dur := wgDuration(time.Since(l.wgActiveSince[id]))
2486+
l.engine.Emit(AlertEvent{
2487+
ID: fmt.Sprintf("alert-wg-down-%s-%d", id, time.Now().UnixMilli()),
2488+
Category: alerts.CatVPN,
2489+
Urgent: false,
2490+
Severity: "info",
2491+
Title: "WireGuard disconnect",
2492+
Description: fmt.Sprintf("%s disconnected after %s", name, dur),
2493+
Type: alerts.TypeWireguardDisconnect,
2494+
Vars: map[string]string{"peer": name, "duration": dur},
2495+
Time: "just now",
2496+
RouterID: gwID,
2497+
})
2498+
delete(l.wgActiveSince, id)
2499+
}
2500+
// Connects: newly active peers, skipping the first post-boot cycle.
24612501
for id := range activeNow {
2462-
if !l.wgActive[id] && len(l.wgActive) > 0 {
2502+
if l.wgActive[id] {
2503+
continue
2504+
}
2505+
if len(l.wgActive) > 0 {
24632506
name := id
24642507
for _, p := range stats.Peers {
24652508
if p.ID == id {
24662509
name = p.Name
24672510
}
24682511
}
24692512
l.engine.Emit(AlertEvent{
2470-
ID: fmt.Sprintf("alert-wg-%s-%d", id, time.Now().UnixMilli()),
2471-
Category: alerts.CatVPN, Urgent: false,
2472-
Severity: "info", Title: "WireGuard handshake",
2513+
ID: fmt.Sprintf("alert-wg-%s-%d", id, time.Now().UnixMilli()),
2514+
Category: alerts.CatVPN,
2515+
Urgent: false,
2516+
Severity: "info",
2517+
Title: "WireGuard handshake",
24732518
Description: name + " connected",
24742519
Type: alerts.TypeWireguardHandshake,
24752520
Vars: map[string]string{"peer": name},
2476-
Time: "just now", RouterID: gw.ID,
2521+
Time: "just now",
2522+
RouterID: gwID,
24772523
})
24782524
}
2525+
l.wgActiveSince[id] = time.Now()
24792526
}
24802527
l.wgActive = activeNow
2481-
l.mu.Unlock()
2482-
return stats
2528+
}
2529+
2530+
// wgDuration humanizes a WireGuard session duration as "Xh Ym" (hours and
2531+
// minutes). Negative values collapse to "0h 0m".
2532+
func wgDuration(d time.Duration) string {
2533+
if d < 0 {
2534+
d = 0
2535+
}
2536+
h := int(d.Hours())
2537+
m := int(d.Minutes()) % 60
2538+
return fmt.Sprintf("%dh %dm", h, m)
24832539
}
24842540

24852541
// buildDevices: unión de leases + MACs vistas (wireless > FDB satélites >
Lines changed: 178 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,178 @@
1+
// wireguard_disconnect_test.go - WireGuard disconnect alerts (#1348).
2+
package adapters
3+
4+
import (
5+
"strings"
6+
"testing"
7+
"time"
8+
9+
"github.com/gnacho/netpulse/server-go/internal/alerts"
10+
)
11+
12+
// wgAlertLive builds a Live whose engine accepts VPN alerts.
13+
func wgAlertLive(t *testing.T) *Live {
14+
t.Helper()
15+
l := NewLive(nil, nil, nil, nil)
16+
if err := l.engine.SetConfig(map[string]string{alerts.CatVPN: alerts.LevelAll}); err != nil {
17+
t.Fatalf("SetConfig vpn: %v", err)
18+
}
19+
return l
20+
}
21+
22+
func wgDisconnects(l *Live) []alerts.AlertEvent {
23+
out := []alerts.AlertEvent{}
24+
for _, ev := range l.engine.List() {
25+
if ev.Type == alerts.TypeWireguardDisconnect {
26+
out = append(out, ev)
27+
}
28+
}
29+
return out
30+
}
31+
32+
func TestWireGuardDisconnectOnActiveToInactive(t *testing.T) {
33+
l := wgAlertLive(t)
34+
l.wgActive = map[string]bool{"peerA": true}
35+
l.wgActiveSince = map[string]time.Time{"peerA": time.Now().Add(-90 * time.Minute)}
36+
37+
l.applyWireGuardTransitions(&WireGuardStats{Peers: []WGPeer{
38+
{ID: "peerA", Name: "Alice", Active: false},
39+
}}, "gw")
40+
41+
got := wgDisconnects(l)
42+
if len(got) != 1 {
43+
t.Fatalf("disconnects: %d, want 1", len(got))
44+
}
45+
ev := got[0]
46+
if ev.Category != alerts.CatVPN {
47+
t.Errorf("category: %q, want %q", ev.Category, alerts.CatVPN)
48+
}
49+
if ev.Urgent {
50+
t.Errorf("urgent: true, want false")
51+
}
52+
if ev.Severity != "info" {
53+
t.Errorf("severity: %q, want info", ev.Severity)
54+
}
55+
if ev.Title != "WireGuard disconnect" {
56+
t.Errorf("title: %q, want %q", ev.Title, "WireGuard disconnect")
57+
}
58+
if ev.Vars["peer"] != "Alice" {
59+
t.Errorf("vars.peer: %q, want Alice", ev.Vars["peer"])
60+
}
61+
if ev.RouterID != "gw" {
62+
t.Errorf("routerId: %q, want gw", ev.RouterID)
63+
}
64+
if !strings.HasPrefix(ev.ID, "alert-wg-down-") {
65+
t.Errorf("id: %q, want prefix alert-wg-down-", ev.ID)
66+
}
67+
}
68+
69+
func TestWireGuardNoDisconnectWhileActive(t *testing.T) {
70+
l := wgAlertLive(t)
71+
l.wgActive = map[string]bool{"peerA": true}
72+
l.wgActiveSince = map[string]time.Time{"peerA": time.Now().Add(-90 * time.Minute)}
73+
74+
l.applyWireGuardTransitions(&WireGuardStats{Peers: []WGPeer{
75+
{ID: "peerA", Name: "Alice", Active: true},
76+
}}, "gw")
77+
78+
if got := wgDisconnects(l); len(got) != 0 {
79+
t.Fatalf("disconnects: %d, want 0", len(got))
80+
}
81+
if !l.wgActive["peerA"] {
82+
t.Errorf("peerA no longer active")
83+
}
84+
}
85+
86+
func TestWireGuardNoDisconnectForNeverActive(t *testing.T) {
87+
l := wgAlertLive(t) // wgActive empty (fresh boot)
88+
89+
l.applyWireGuardTransitions(&WireGuardStats{Peers: []WGPeer{
90+
{ID: "peerA", Name: "Alice", Active: false},
91+
}}, "gw")
92+
93+
if got := wgDisconnects(l); len(got) != 0 {
94+
t.Fatalf("disconnects: %d, want 0", len(got))
95+
}
96+
}
97+
98+
func TestWireGuardDisconnectDuration(t *testing.T) {
99+
l := wgAlertLive(t)
100+
l.wgActive = map[string]bool{"peerA": true}
101+
l.wgActiveSince = map[string]time.Time{"peerA": time.Now().Add(-90 * time.Minute)}
102+
103+
l.applyWireGuardTransitions(&WireGuardStats{Peers: []WGPeer{
104+
{ID: "peerA", Name: "Alice", Active: false},
105+
}}, "gw")
106+
107+
got := wgDisconnects(l)
108+
if len(got) != 1 {
109+
t.Fatalf("disconnects: %d, want 1", len(got))
110+
}
111+
if !strings.Contains(got[0].Description, "after 1h 30m") {
112+
t.Errorf("description: %q, want to contain %q", got[0].Description, "after 1h 30m")
113+
}
114+
if got[0].Vars["duration"] != "1h 30m" {
115+
t.Errorf("vars.duration: %q, want 1h 30m", got[0].Vars["duration"])
116+
}
117+
}
118+
119+
func TestWireGuardActiveSinceConsistency(t *testing.T) {
120+
l := wgAlertLive(t)
121+
base := time.Now().Add(-90 * time.Minute)
122+
l.wgActive = map[string]bool{"peerA": true}
123+
l.wgActiveSince = map[string]time.Time{"peerA": base}
124+
125+
// Disconnect: peer removed from both maps.
126+
l.applyWireGuardTransitions(&WireGuardStats{Peers: []WGPeer{
127+
{ID: "peerA", Name: "Alice", Active: false},
128+
}}, "gw")
129+
130+
if _, ok := l.wgActive["peerA"]; ok {
131+
t.Errorf("peerA still in wgActive after disconnect")
132+
}
133+
if _, ok := l.wgActiveSince["peerA"]; ok {
134+
t.Errorf("peerA still in wgActiveSince after disconnect")
135+
}
136+
137+
// Reconnect: peer tracked again with a fresh since timestamp.
138+
l.applyWireGuardTransitions(&WireGuardStats{Peers: []WGPeer{
139+
{ID: "peerA", Name: "Alice", Active: true},
140+
}}, "gw")
141+
142+
if !l.wgActive["peerA"] {
143+
t.Errorf("peerA not active after reconnect")
144+
}
145+
since, ok := l.wgActiveSince["peerA"]
146+
if !ok {
147+
t.Fatalf("peerA missing since after reconnect")
148+
}
149+
if !since.After(base) {
150+
t.Errorf("since: %v, want after %v", since, base)
151+
}
152+
}
153+
154+
// Regression: the connect path still emits a handshake for a newly active peer
155+
// and tracks its since timestamp.
156+
func TestWireGuardHandshakeStillEmitted(t *testing.T) {
157+
l := wgAlertLive(t)
158+
l.wgActive = map[string]bool{"peerB": true}
159+
l.wgActiveSince = map[string]time.Time{"peerB": time.Now().Add(-5 * time.Minute)}
160+
161+
l.applyWireGuardTransitions(&WireGuardStats{Peers: []WGPeer{
162+
{ID: "peerA", Name: "Alice", Active: true},
163+
{ID: "peerB", Name: "Bob", Active: true},
164+
}}, "gw")
165+
166+
handshakes := 0
167+
for _, ev := range l.engine.List() {
168+
if ev.Type == alerts.TypeWireguardHandshake && ev.Vars["peer"] == "Alice" {
169+
handshakes++
170+
}
171+
}
172+
if handshakes != 1 {
173+
t.Fatalf("handshakes for Alice: %d, want 1", handshakes)
174+
}
175+
if _, ok := l.wgActiveSince["peerA"]; !ok {
176+
t.Errorf("peerA missing since")
177+
}
178+
}

‎server-go/internal/alerts/alerts.go‎

Lines changed: 23 additions & 22 deletions
Original file line numberDiff line numberDiff line change
@@ -109,28 +109,29 @@ const (
109109
// Additional stable alert-type slugs (issue #712): recovery/info/beacon/rearmer
110110
// types emitted without Type/Vars in #671. They carry no hint.
111111
const (
112-
TypeAgentRecovered = "agent-recovered"
113-
TypeSSHAccessLost = "ssh-access-lost"
114-
TypeSSHAccessRecovered = "ssh-access-recovered"
115-
TypeAgentUpdated = "agent-updated"
116-
TypeRouterRecovered = "router-recovered"
117-
TypeWireguardHandshake = "wireguard-handshake"
118-
TypePortStable = "port-stable"
119-
TypeGhostPortRecovered = "ghost-port-recovered"
120-
TypeLinkRecovered = "link-recovered"
121-
TypeSwitchLoop = "switch-loop"
122-
TypePortDisabled = "port-disabled"
123-
TypePortRecovered = "port-recovered"
124-
TypeLinkDown = "link-down"
125-
TypeLinkUp = "link-up"
126-
TypeSfpRxLow = "sfp-rx-low"
127-
TypeSfpTempHigh = "sfp-temp-high"
128-
TypeSwitchRebooted = "switch-rebooted"
129-
TypeAgentRearmed = "agent-rearmed"
130-
TypeAgentReinstalled = "agent-reinstalled"
131-
TypeAutoRearmFailed = "auto-rearm-failed"
132-
TypeUplinkRestored = "uplink-restored"
133-
TypeSnmpRecovered = "snmp-recovered"
112+
TypeAgentRecovered = "agent-recovered"
113+
TypeSSHAccessLost = "ssh-access-lost"
114+
TypeSSHAccessRecovered = "ssh-access-recovered"
115+
TypeAgentUpdated = "agent-updated"
116+
TypeRouterRecovered = "router-recovered"
117+
TypeWireguardHandshake = "wireguard-handshake"
118+
TypeWireguardDisconnect = "wireguard-disconnect"
119+
TypePortStable = "port-stable"
120+
TypeGhostPortRecovered = "ghost-port-recovered"
121+
TypeLinkRecovered = "link-recovered"
122+
TypeSwitchLoop = "switch-loop"
123+
TypePortDisabled = "port-disabled"
124+
TypePortRecovered = "port-recovered"
125+
TypeLinkDown = "link-down"
126+
TypeLinkUp = "link-up"
127+
TypeSfpRxLow = "sfp-rx-low"
128+
TypeSfpTempHigh = "sfp-temp-high"
129+
TypeSwitchRebooted = "switch-rebooted"
130+
TypeAgentRearmed = "agent-rearmed"
131+
TypeAgentReinstalled = "agent-reinstalled"
132+
TypeAutoRearmFailed = "auto-rearm-failed"
133+
TypeUplinkRestored = "uplink-restored"
134+
TypeSnmpRecovered = "snmp-recovered"
134135
// #1354: router offline (was reused from HintDeviceOffline, now its own slug)
135136
// and client device offline (opt-in, volatile).
136137
TypeRouterOffline = "router-offline"

‎server-go/internal/alerts/alerts_i18n_test.go‎

Lines changed: 3 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -15,6 +15,7 @@ import (
1515
var newTypeSlugs = []string{
1616
TypeAgentRecovered, TypeSSHAccessLost, TypeSSHAccessRecovered,
1717
TypeAgentUpdated, TypeRouterRecovered, TypeWireguardHandshake,
18+
TypeWireguardDisconnect,
1819
TypePortStable, TypeGhostPortRecovered, TypeLinkRecovered,
1920
TypeSwitchLoop, TypePortDisabled, TypePortRecovered,
2021
TypeLinkDown, TypeLinkUp, TypeSfpRxLow, TypeSfpTempHigh,
@@ -49,8 +50,8 @@ func TestNewTypeSlugsAreKebabCaseAndUnique(t *testing.T) {
4950
}
5051
seen[slug] = true
5152
}
52-
if len(newTypeSlugs) != 23 {
53-
t.Errorf("se esperan 23 slugs nuevos, hay %d", len(newTypeSlugs))
53+
if len(newTypeSlugs) != 24 {
54+
t.Errorf("se esperan 24 slugs nuevos, hay %d", len(newTypeSlugs))
5455
}
5556
}
5657

0 commit comments

Comments
 (0)