Skip to content

Commit f5bb172

Browse files
committed
feat(xray): watchdog auto-recovers a wedged (alive but not serving) Xray
The exit monitor already restarts Xray when the process DIES. This covers the case it can't see — a process that stays alive but stops serving (its API goes unresponsive). A watchdog probes the running process every 30s and, after three failed API probes in a row (~90s wedged), restarts it, with a 5-minute cooldown so a process that wedges again can't cause a restart storm. Built into the Supervisor, so the master AND every node get it; the master also alerts the operator (AdminEventXrayDown) when it fires. - xray: watchdog loop + watchdogTick decision (pure, unit-tested via an injectable probe — threshold, cooldown, skip when down/suspended/restarting) - core: onXrayWedged alert (self-resolving, shares the crash throttle); master wires SetOnWedged + StartWatchdog - nodeagent: StartWatchdog on the node's own supervisor - i18n: notify.xrayWedged (EN+RU) - README EN+RU
1 parent 3f8e15b commit f5bb172

9 files changed

Lines changed: 194 additions & 2 deletions

File tree

README-RU.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -411,7 +411,10 @@ https://vpn.example.com/<api-путь>/v1/mcp/<ключ>/write плюс вс
411411

412412
**Обновление** одной командой: панель сверяет SHA256, прогоняет бинарь вхолостую, снимает
413413
бэкап и только потом подменяет себя, храня прошлую версию рядом. Xray-ядро зафиксировано,
414-
упавшее супервизор поднимает сам.
414+
упавшее супервизор поднимает сам. **Сторож** закрывает более сложный случай, который не видит
415+
обработчик краха, — процесс жив, но перестал обслуживать: он опрашивает API Xray и, если тот
416+
не отвечает несколько проверок подряд, перезапускает его (с задержкой против шторма
417+
перезапусков) и уведомляет оператора. Работает на мастере и на каждой ноде.
415418

416419
**Секреты в БД зашифрованы** (AES-GCM). Токены сессий и API-ключи хранятся только хэшами —
417420
даже с доступом к таблице чужую сессию не подставить. Подтверждение оплаты и управление

README.md

Lines changed: 4 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -430,7 +430,10 @@ from the panel and the CLI.
430430

431431
**Updates** in one command: the panel verifies SHA256, runs the binary dry, takes a backup and
432432
only then replaces itself, keeping the previous version next to it. The Xray core is pinned,
433-
and the supervisor restarts it if it crashes.
433+
and the supervisor restarts it if it crashes. A **watchdog** covers the harder case a crash
434+
handler can't see — a process that stays alive but stops serving: it probes Xray's API and, if
435+
it goes unresponsive for several checks in a row, restarts it (with a cooldown against restart
436+
storms) and alerts the operator. Runs on the master and every node.
434437

435438
**Secrets in the database are encrypted** (AES-GCM). Session tokens and API keys are stored as
436439
hashes only — even with table access you can't reuse someone's session. Payment confirmation

internal/core/manager.go

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -298,6 +298,8 @@ func New(st *store.Store, sup *xray.Supervisor, opts xray.Options, tls TLSPaths,
298298
}
299299
m.sup.SetOnCrash(m.onXrayCrash) // alert admins when Xray exits unexpectedly
300300
m.sup.SetOnRecover(m.onXrayRecover) // ...and tell them when it is back
301+
m.sup.SetOnWedged(m.onXrayWedged) // ...and when the watchdog revives a hung one
302+
m.sup.StartWatchdog() // auto-restart a wedged (alive-but-not-serving) Xray
301303
// The same two alerts for the remote nodes. They have no bot of their own, and a
302304
// node that stops syncing altogether can only be noticed on a timer.
303305
go m.nodeWatchLoop()

internal/core/manager_notify.go

Lines changed: 17 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -316,6 +316,23 @@ func (m *Manager) onXrayCrash(err error) {
316316
m.notifyAdminEvent(model.AdminEventXrayDown, msg)
317317
}
318318

319+
// onXrayWedged reports that the watchdog found Xray alive but no longer answering its
320+
// API and restarted it. Unlike a crash this is self-resolving — the restart has
321+
// already run — so the message says so and there is no separate all-clear. Shares the
322+
// crash throttle so a process that keeps wedging can't spam the chat.
323+
func (m *Manager) onXrayWedged() {
324+
m.throttleMu.Lock()
325+
now := time.Now()
326+
if now.Sub(m.lastCrashNotify) < crashNotifyThrottle {
327+
m.throttleMu.Unlock()
328+
return
329+
}
330+
m.lastCrashNotify = now
331+
m.throttleMu.Unlock()
332+
lang := m.botLang()
333+
m.notifyAdminEvent(model.AdminEventXrayDown, i18n.T(lang, "notify.xrayWedged", model.LocalNodeName))
334+
}
335+
319336
// onXrayRecover reports that Xray is back, but only when this panel actually raised
320337
// the alarm. An alert with no all-clear leaves the operator unable to tell "recovered
321338
// in two seconds" from "still down" — and an all-clear for an alarm that was

internal/i18n/en.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -250,6 +250,7 @@ var en = map[string]string{
250250
"notify.userDeviceRefused": "📵 <b>This device wasn't added</b>\n\nYou already have %d of %d devices. Remove one you no longer use, or ask support.",
251251
"notify.userSuspended": "🚫 <b>Access suspended</b>\n\nContact support if this is unexpected.",
252252
"notify.xrayCrashed": "⚠️ <b>Xray crashed</b>\nServer: %s\nThe process is being restarted automatically.",
253+
"notify.xrayWedged": "⚠️ <b>Xray was not responding</b>\nServer: %s\nThe watchdog restarted the process automatically.",
253254
"notify.xrayBack": "✅ <b>Xray is running again</b>\nServer: %s",
254255
"notify.reason": "Reason: %s",
255256
"notify.downtime": "Downtime: %s.",

internal/i18n/ru.go

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -260,6 +260,7 @@ var ru = map[string]string{
260260
"notify.userDeviceRefused": "📵 <b>Это устройство не добавлено</b>\n\nУ вас уже %d из %d устройств. Удалите ненужное или напишите в поддержку.",
261261
"notify.userSuspended": "🚫 <b>Доступ приостановлен</b>\n\nОбратитесь в поддержку, если это неожиданно.",
262262
"notify.xrayCrashed": "⚠️ <b>Xray аварийно завершился</b>\nСервер: %s\nПроцесс перезапускается автоматически.",
263+
"notify.xrayWedged": "⚠️ <b>Xray перестал отвечать</b>\nСервер: %s\nСторож перезапустил процесс автоматически.",
263264
"notify.xrayBack": "✅ <b>Xray снова работает</b>\nСервер: %s",
264265
"notify.reason": "Причина: %s",
265266
"notify.downtime": "Простой: %s.",

internal/nodeagent/agent.go

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -388,6 +388,10 @@ func newAgent(dataDir string, ident *Identity) (*Agent, error) {
388388
// Tap Xray's access log so the panel can count this node's devices (mirrors the
389389
// master's sup.SetOnAccess(RecordAccess)).
390390
a.sup.SetOnAccess(a.recordConn)
391+
// Same wedged-process watchdog as the master: a node's Xray that goes unresponsive
392+
// (alive but not serving) is restarted locally. The master learns of the bounce
393+
// from the changed start time and its own node-health alerts.
394+
a.sup.StartWatchdog()
391395
return a, nil
392396
}
393397

internal/xray/supervisor.go

Lines changed: 108 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -28,6 +28,12 @@ const (
2828
restartBackoff = time.Second // base crash-restart delay (doubles, capped)
2929
maxBackoff = 30 * time.Second
3030
healthyUptime = 30 * time.Second // a run longer than this resets the backoff
31+
32+
// Wedged-process watchdog: the exit monitor restarts a process that DIES; this
33+
// covers one that stays alive but stops serving (Xray's API stops answering).
34+
watchdogInterval = 30 * time.Second // how often to probe a running process
35+
watchdogFailsToAct = 3 // consecutive failed probes before restarting (~90s wedged)
36+
watchdogCooldown = 5 * time.Minute // min gap between watchdog restarts (anti-storm)
3137
)
3238

3339
// proc is a single running Xray child. done is closed once Wait() has reaped it;
@@ -70,8 +76,16 @@ type Supervisor struct {
7076
suspended bool
7177
restarting bool // a deliberate stop→start is in flight (the ~1s bounce)
7278

79+
// lastWatchdog is when the wedged-process watchdog last restarted Xray, for its
80+
// anti-storm cooldown (zero until it fires).
81+
lastWatchdog time.Time
82+
// probe reports whether Xray is answering its API; defaults to apiResponsive and is
83+
// swappable in tests so the watchdog decision can be exercised without a live Xray.
84+
probe func() bool
85+
7386
onAccess func(email, ip, dest string) // called per access-log connection line
7487
onCrash func(err error) // called when Xray exits unexpectedly (crash)
88+
onWedged func() // called when the watchdog restarts a wedged process
7589
// onRecover is called when a SUPERVISED restart succeeds — i.e. Xray is back up
7690
// after a crash. Deliberately not fired by Apply-driven restarts (a reconcile, a
7791
// renewed certificate): those are routine, and reporting them as recovery would
@@ -143,6 +157,100 @@ func (s *Supervisor) SetOnCrash(fn func(err error)) { s.onCrash = fn }
143157
// SetOnRecover registers a callback invoked when Xray comes back after a crash.
144158
func (s *Supervisor) SetOnRecover(fn func()) { s.onRecover = fn }
145159

160+
// SetOnWedged registers a callback invoked when the watchdog restarts a wedged
161+
// process (alive but no longer serving). Used to alert the operator — this is an
162+
// outage the crash path never reported, because the process never exited.
163+
func (s *Supervisor) SetOnWedged(fn func()) { s.onWedged = fn }
164+
165+
// StartWatchdog launches the wedged-process watchdog in the background: it probes a
166+
// running Xray and, if the process is alive but its API has stopped answering for
167+
// several checks in a row, restarts it. This is the gap the exit monitor cannot
168+
// cover — that one only sees a process that DIES. Idempotent-ish (call once); a
169+
// missing binary makes it a no-op. The loop exits when the supervisor is Stopped.
170+
func (s *Supervisor) StartWatchdog() {
171+
if s.bin == "" {
172+
return
173+
}
174+
go s.watchdogLoop()
175+
}
176+
177+
func (s *Supervisor) watchdogLoop() {
178+
if s.probe == nil {
179+
s.probe = s.apiResponsive
180+
}
181+
t := time.NewTicker(watchdogInterval)
182+
defer t.Stop()
183+
fails := 0
184+
for range t.C {
185+
s.mu.Lock()
186+
closed := s.closed
187+
s.mu.Unlock()
188+
if closed {
189+
return
190+
}
191+
var act bool
192+
fails, act = s.watchdogTick(fails)
193+
if !act {
194+
continue
195+
}
196+
slog.Error("xray watchdog: wedged (alive but not serving) — restarting")
197+
if s.onWedged != nil {
198+
go s.onWedged()
199+
}
200+
if err := s.Restart(); err != nil {
201+
slog.Error("xray watchdog: restart failed", "err", err)
202+
}
203+
}
204+
}
205+
206+
// watchdogTick evaluates one probe cycle and returns the updated consecutive-failure
207+
// count and whether a wedged process should be restarted now. The decision, minus the
208+
// ticker and the restart itself, so it is unit-testable without a live Xray:
209+
// - a down / suspended / mid-bounce supervisor is never judged (a routine restart is
210+
// not a wedge), and resets the counter;
211+
// - a responsive process resets the counter;
212+
// - only after watchdogFailsToAct failures in a row, and past the cooldown since the
213+
// last watchdog restart, does it say to act (recording the restart time).
214+
func (s *Supervisor) watchdogTick(fails int) (int, bool) {
215+
s.mu.Lock()
216+
watch := s.cur != nil && !s.suspended && !s.restarting
217+
s.mu.Unlock()
218+
if !watch {
219+
return 0, false
220+
}
221+
probe := s.probe
222+
if probe == nil {
223+
probe = s.apiResponsive
224+
}
225+
if probe() {
226+
return 0, false
227+
}
228+
fails++
229+
if fails < watchdogFailsToAct {
230+
slog.Warn("xray watchdog: process alive but not answering its API", "fails", fails)
231+
return fails, false
232+
}
233+
// Wedged for watchdogFailsToAct probes in a row. Honour a cooldown so a process
234+
// that wedges again right after a restart can't spin us into a restart storm.
235+
s.mu.Lock()
236+
cooling := !s.lastWatchdog.IsZero() && time.Since(s.lastWatchdog) < watchdogCooldown
237+
if !cooling {
238+
s.lastWatchdog = time.Now()
239+
}
240+
s.mu.Unlock()
241+
if cooling {
242+
return fails, false // still wedged; hold off until the cooldown elapses
243+
}
244+
return 0, true
245+
}
246+
247+
// apiResponsive reports whether the running Xray still answers its API — a failed,
248+
// timeout-bounded stats query is the "wedged" signal the exit monitor never sees.
249+
func (s *Supervisor) apiResponsive() bool {
250+
_, err := s.QueryStats(s.APIAddr())
251+
return err == nil
252+
}
253+
146254
// recovered fires the recovery callback off the restart path, mirroring onCrash.
147255
func (s *Supervisor) recovered() {
148256
if s.onRecover != nil {

internal/xray/watchdog_test.go

Lines changed: 53 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,53 @@
1+
package xray
2+
3+
import (
4+
"testing"
5+
"time"
6+
)
7+
8+
func TestWatchdogTick(t *testing.T) {
9+
up := func() *Supervisor { return &Supervisor{cur: &proc{started: time.Now()}} }
10+
responsive := func() bool { return true }
11+
wedged := func() bool { return false }
12+
13+
// A responsive process never acts and keeps the counter at zero.
14+
s := up()
15+
s.probe = responsive
16+
if f, act := s.watchdogTick(0); act || f != 0 {
17+
t.Fatalf("responsive process acted: (%d, %v)", f, act)
18+
}
19+
20+
// A wedged process acts only after watchdogFailsToAct failures in a row.
21+
s = up()
22+
s.probe = wedged
23+
fails := 0
24+
for i := 1; i < watchdogFailsToAct; i++ {
25+
var act bool
26+
if fails, act = s.watchdogTick(fails); act {
27+
t.Fatalf("acted early at %d fails", fails)
28+
}
29+
}
30+
if _, act := s.watchdogTick(fails); !act {
31+
t.Fatalf("did not act after %d consecutive fails", watchdogFailsToAct)
32+
}
33+
34+
// The cooldown blocks an immediate second restart even while still wedged.
35+
if _, act := s.watchdogTick(watchdogFailsToAct); act {
36+
t.Fatal("acted again inside the cooldown window — restart storm not prevented")
37+
}
38+
39+
// A down, suspended, or mid-bounce supervisor is never judged (and resets the
40+
// counter): a routine restart must not read as a wedge.
41+
for name, mut := range map[string]func(*Supervisor){
42+
"down": func(s *Supervisor) { s.cur = nil },
43+
"suspended": func(s *Supervisor) { s.suspended = true },
44+
"restarting": func(s *Supervisor) { s.restarting = true },
45+
} {
46+
s := up()
47+
s.probe = wedged
48+
mut(s)
49+
if f, act := s.watchdogTick(watchdogFailsToAct); act || f != 0 {
50+
t.Errorf("%s supervisor was judged: (%d, %v)", name, f, act)
51+
}
52+
}
53+
}

0 commit comments

Comments
 (0)