Files
elmprodvpn/selective-vpn-api/app/watchers.go
beckline 10a10f44a8 baseline: api+gui traffic mode + candidates picker
Snapshot before app-launcher (cgroup/mark) work; ignore binaries/backups.
2026-02-14 15:52:20 +03:00

230 lines
6.0 KiB
Go

package app
import (
"context"
"crypto/sha256"
"encoding/json"
"os"
"strings"
"time"
)
// ---------------------------------------------------------------------
// фоновые вотчеры / события
// ---------------------------------------------------------------------
// EN: Background poll-based watchers that detect file/service state changes and
// EN: publish normalized events into the in-memory event bus for SSE clients.
// RU: Фоновые poll-вотчеры, отслеживающие изменения файлов/сервисов и
// RU: публикующие нормализованные события в in-memory event bus для SSE-клиентов.
func startWatchers(ctx context.Context) {
statusEvery := time.Duration(envInt("SVPN_POLL_STATUS_MS", defaultPollStatusMs)) * time.Millisecond
loginEvery := time.Duration(envInt("SVPN_POLL_LOGIN_MS", defaultPollLoginMs)) * time.Millisecond
autoEvery := time.Duration(envInt("SVPN_POLL_AUTOLOOP_MS", defaultPollAutoloopMs)) * time.Millisecond
systemdEvery := time.Duration(envInt("SVPN_POLL_SYSTEMD_MS", defaultPollSystemdMs)) * time.Millisecond
traceEvery := time.Duration(envInt("SVPN_POLL_TRACE_MS", defaultPollTraceMs)) * time.Millisecond
go watchStatusFile(ctx, statusEvery)
go watchLoginFile(ctx, loginEvery)
go watchAutoloop(ctx, autoEvery)
go watchFileChange(ctx, traceLogPath, "trace_changed", "full", traceEvery)
go watchFileChange(ctx, smartdnsLogPath, "trace_changed", "smartdns", traceEvery)
go watchSystemdUnitDynamic(ctx, routesServiceUnitName, "routes_service", systemdEvery)
go watchSystemdUnitDynamic(ctx, routesTimerUnitName, "routes_timer", systemdEvery)
go watchSystemdUnit(ctx, adgvpnUnit, "vpn_unit", systemdEvery)
go watchSystemdUnit(ctx, "smartdns-local.service", "smartdns_unit", systemdEvery)
}
// ---------------------------------------------------------------------
// status file watcher
// ---------------------------------------------------------------------
func watchStatusFile(ctx context.Context, every time.Duration) {
var last [32]byte
have := false
for {
select {
case <-ctx.Done():
return
case <-time.After(every):
}
data, err := os.ReadFile(statusFilePath)
if err != nil {
continue
}
h := sha256.Sum256(data)
if have && h == last {
continue
}
last = h
have = true
var st Status
if err := json.Unmarshal(data, &st); err != nil {
events.push("status_error", map[string]any{"error": err.Error()})
continue
}
events.push("status_changed", st)
}
}
// ---------------------------------------------------------------------
// login file watcher
// ---------------------------------------------------------------------
func watchLoginFile(ctx context.Context, every time.Duration) {
var last [32]byte
have := false
for {
select {
case <-ctx.Done():
return
case <-time.After(every):
}
data, err := os.ReadFile(loginStatePath)
if err != nil {
continue
}
h := sha256.Sum256(data)
if have && h == last {
continue
}
last = h
have = true
var st VPNLoginState
if err := json.Unmarshal(data, &st); err != nil {
events.push("login_state_error", map[string]any{"error": err.Error()})
continue
}
events.push("login_state_changed", st)
}
}
// ---------------------------------------------------------------------
// autoloop watcher
// ---------------------------------------------------------------------
func watchAutoloop(ctx context.Context, every time.Duration) {
lastWord := ""
lastRaw := ""
for {
select {
case <-ctx.Done():
return
case <-time.After(every):
}
lines := tailFile(autoloopLogPath, 200)
word, raw := parseAutoloopStatus(lines)
if word == "" && raw == "" {
continue
}
if word == lastWord && raw == lastRaw {
continue
}
lastWord, lastRaw = word, raw
events.push("autoloop_status_changed", map[string]string{
"status_word": word,
"raw_text": raw,
})
}
}
// ---------------------------------------------------------------------
// systemd unit watcher
// ---------------------------------------------------------------------
func watchSystemdUnit(ctx context.Context, unit string, kind string, every time.Duration) {
last := ""
for {
select {
case <-ctx.Done():
return
case <-time.After(every):
}
stdout, _, _, err := runCommand("systemctl", "is-active", unit)
state := strings.TrimSpace(stdout)
if err != nil || state == "" {
state = "unknown"
}
if state == last {
continue
}
last = state
events.push("unit_state_changed", map[string]string{
"unit": unit,
"kind": kind,
"state": state,
})
}
}
func watchSystemdUnitDynamic(ctx context.Context, resolveUnit func() string, kind string, every time.Duration) {
lastUnit := ""
lastState := ""
for {
select {
case <-ctx.Done():
return
case <-time.After(every):
}
unit := strings.TrimSpace(resolveUnit())
state := "unknown"
if unit != "" {
stdout, _, _, err := runCommand("systemctl", "is-active", unit)
s := strings.TrimSpace(stdout)
if err == nil && s != "" {
state = s
}
}
if unit == lastUnit && state == lastState {
continue
}
lastUnit, lastState = unit, state
events.push("unit_state_changed", map[string]string{
"unit": unit,
"kind": kind,
"state": state,
})
}
}
// ---------------------------------------------------------------------
// generic file watcher
// ---------------------------------------------------------------------
func watchFileChange(ctx context.Context, path string, kind string, mode string, every time.Duration) {
var lastMod time.Time
var lastSize int64 = -1
for {
select {
case <-ctx.Done():
return
case <-time.After(every):
}
info, err := os.Stat(path)
if err != nil {
continue
}
if info.ModTime() == lastMod && info.Size() == lastSize {
continue
}
lastMod = info.ModTime()
lastSize = info.Size()
events.push(kind, map[string]any{
"path": path,
"mode": mode,
"size": info.Size(),
"mtime": info.ModTime().UTC().Format(time.RFC3339Nano),
})
}
}