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), }) } }