diff options
Diffstat (limited to 'internal/engine')
| -rw-r--r-- | internal/engine/engine.go | 134 | ||||
| -rw-r--r-- | internal/engine/logger.go | 62 | ||||
| -rw-r--r-- | internal/engine/watchdog.go | 142 |
3 files changed, 338 insertions, 0 deletions
diff --git a/internal/engine/engine.go b/internal/engine/engine.go new file mode 100644 index 0000000..f71220f --- /dev/null +++ b/internal/engine/engine.go @@ -0,0 +1,134 @@ +package engine + +import ( + "context" + "encoding/json" + "fmt" + "log" + "os" + "path/filepath" + "sync" + + box "github.com/sagernet/sing-box" + "github.com/sagernet/sing-box/include" + "github.com/sagernet/sing-box/option" + + "vpnem/internal/config" + "vpnem/internal/models" +) + +type Engine struct { + mu sync.Mutex + instance *box.Box + cancel context.CancelFunc + running bool + configPath string + dataDir string +} + +func New(dataDir string) *Engine { + return &Engine{ + dataDir: dataDir, + configPath: filepath.Join(dataDir, "config.json"), + } +} + +func (e *Engine) Start(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string) error { + return e.StartFull(server, mode, ruleSets, serverIPs, nil) +} + +func (e *Engine) StartFull(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string) error { + e.mu.Lock() + defer e.mu.Unlock() + + if e.running { + return fmt.Errorf("already running") + } + + cfg := config.BuildConfigFull(server, mode, ruleSets, serverIPs, customBypass) + data, err := json.MarshalIndent(cfg, "", " ") + if err != nil { + return fmt.Errorf("marshal config: %w", err) + } + + os.MkdirAll(e.dataDir, 0o755) + _ = os.WriteFile(e.configPath, data, 0o644) + log.Printf("engine: config saved (%d bytes)", len(data)) + + var opts option.Options + ctx := box.Context( + context.Background(), + include.InboundRegistry(), + include.OutboundRegistry(), + include.EndpointRegistry(), + ) + if err := opts.UnmarshalJSONContext(ctx, data); err != nil { + log.Printf("engine: parse FAILED: %v", err) + return fmt.Errorf("parse config: %w", err) + } + + boxCtx, cancel := context.WithCancel(ctx) + e.cancel = cancel + + instance, err := box.New(box.Options{ + Context: boxCtx, + Options: opts, + }) + if err != nil { + cancel() + log.Printf("engine: create FAILED: %v", err) + return fmt.Errorf("create sing-box: %w", err) + } + + if err := instance.Start(); err != nil { + instance.Close() + cancel() + log.Printf("engine: start FAILED: %v", err) + return fmt.Errorf("start sing-box: %w", err) + } + + e.instance = instance + e.running = true + log.Println("engine: started ok") + return nil +} + +func (e *Engine) Stop() error { + e.mu.Lock() + defer e.mu.Unlock() + + if !e.running { + return nil + } + + if e.instance != nil { + e.instance.Close() + e.instance = nil + } + if e.cancel != nil { + e.cancel() + } + e.running = false + log.Println("engine: stopped") + return nil +} + +func (e *Engine) Restart(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string) error { + e.Stop() + return e.Start(server, mode, ruleSets, serverIPs) +} + +func (e *Engine) RestartFull(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string, customBypass []string) error { + e.Stop() + return e.StartFull(server, mode, ruleSets, serverIPs, customBypass) +} + +func (e *Engine) IsRunning() bool { + e.mu.Lock() + defer e.mu.Unlock() + return e.running +} + +func (e *Engine) ConfigPath() string { + return e.configPath +} diff --git a/internal/engine/logger.go b/internal/engine/logger.go new file mode 100644 index 0000000..c448a56 --- /dev/null +++ b/internal/engine/logger.go @@ -0,0 +1,62 @@ +package engine + +import ( + "os" + "path/filepath" + "sync" +) + +// RingLog keeps last N log lines in memory and optionally writes to file. +type RingLog struct { + mu sync.Mutex + lines []string + max int + file *os.File +} + +// NewRingLog creates a ring buffer logger. +func NewRingLog(maxLines int, dataDir string) *RingLog { + rl := &RingLog{ + lines: make([]string, 0, maxLines), + max: maxLines, + } + if dataDir != "" { + f, err := os.OpenFile(filepath.Join(dataDir, "vpnem.log"), + os.O_CREATE|os.O_WRONLY|os.O_APPEND, 0o644) + if err == nil { + rl.file = f + } + } + return rl +} + +// Add appends a line. +func (rl *RingLog) Add(line string) { + rl.mu.Lock() + defer rl.mu.Unlock() + + if len(rl.lines) >= rl.max { + rl.lines = rl.lines[1:] + } + rl.lines = append(rl.lines, line) + + if rl.file != nil { + rl.file.WriteString(line + "\n") + } +} + +// Lines returns all current lines. +func (rl *RingLog) Lines() []string { + rl.mu.Lock() + defer rl.mu.Unlock() + cp := make([]string, len(rl.lines)) + copy(cp, rl.lines) + return cp +} + +// Close closes the log file. +func (rl *RingLog) Close() { + if rl.file != nil { + rl.file.Close() + } +} diff --git a/internal/engine/watchdog.go b/internal/engine/watchdog.go new file mode 100644 index 0000000..899f81f --- /dev/null +++ b/internal/engine/watchdog.go @@ -0,0 +1,142 @@ +package engine + +import ( + "context" + "io" + "log" + "net/http" + "strings" + "time" + + "vpnem/internal/config" + "vpnem/internal/models" +) + +// WatchdogConfig holds watchdog parameters. +type WatchdogConfig struct { + CheckInterval time.Duration // how often to check sing-box is alive (default 2s) + DeepCheckInterval time.Duration // how often to verify exit IP (default 30s) + ReconnectCooldown time.Duration // min time between reconnect attempts (default 5s) +} + +// DefaultWatchdogConfig returns the default watchdog settings (from vpn.py). +func DefaultWatchdogConfig() WatchdogConfig { + return WatchdogConfig{ + CheckInterval: 2 * time.Second, + DeepCheckInterval: 30 * time.Second, + ReconnectCooldown: 5 * time.Second, + } +} + +// Watchdog monitors sing-box and auto-reconnects on failure. +type Watchdog struct { + engine *Engine + cfg WatchdogConfig + cancel context.CancelFunc + running bool + + // Reconnect parameters (set via StartWatching) + server models.Server + mode config.Mode + ruleSets []models.RuleSet + serverIPs []string +} + +// NewWatchdog creates a new watchdog for the given engine. +func NewWatchdog(engine *Engine, cfg WatchdogConfig) *Watchdog { + return &Watchdog{ + engine: engine, + cfg: cfg, + } +} + +// StartWatching begins monitoring. It stores the connection params for reconnection. +func (w *Watchdog) StartWatching(server models.Server, mode config.Mode, ruleSets []models.RuleSet, serverIPs []string) { + w.StopWatching() + + w.server = server + w.mode = mode + w.ruleSets = ruleSets + w.serverIPs = serverIPs + + ctx, cancel := context.WithCancel(context.Background()) + w.cancel = cancel + w.running = true + + go w.loop(ctx) +} + +// StopWatching stops the watchdog. +func (w *Watchdog) StopWatching() { + if w.cancel != nil { + w.cancel() + } + w.running = false +} + +// IsWatching returns whether the watchdog is active. +func (w *Watchdog) IsWatching() bool { + return w.running +} + +func (w *Watchdog) loop(ctx context.Context) { + ticker := time.NewTicker(w.cfg.CheckInterval) + defer ticker.Stop() + + deepTicker := time.NewTicker(w.cfg.DeepCheckInterval) + defer deepTicker.Stop() + + lastReconnect := time.Time{} + + for { + select { + case <-ctx.Done(): + return + + case <-ticker.C: + if !w.engine.IsRunning() { + if time.Since(lastReconnect) < w.cfg.ReconnectCooldown { + continue + } + log.Println("watchdog: sing-box not running, reconnecting...") + if err := w.engine.Start(w.server, w.mode, w.ruleSets, w.serverIPs); err != nil { + log.Printf("watchdog: reconnect failed: %v", err) + } else { + log.Println("watchdog: reconnected successfully") + } + lastReconnect = time.Now() + } + + case <-deepTicker.C: + if !w.engine.IsRunning() { + continue + } + ip := checkExitIP() + if ip == "" { + log.Println("watchdog: deep check failed (no exit IP), restarting...") + if time.Since(lastReconnect) < w.cfg.ReconnectCooldown { + continue + } + if err := w.engine.Restart(w.server, w.mode, w.ruleSets, w.serverIPs); err != nil { + log.Printf("watchdog: restart failed: %v", err) + } + lastReconnect = time.Now() + } + } + } +} + +func checkExitIP() string { + client := &http.Client{Timeout: 5 * time.Second} + resp, err := client.Get("http://ifconfig.me/ip") + if err != nil { + return "" + } + defer resp.Body.Close() + + body, err := io.ReadAll(io.LimitReader(resp.Body, 64)) + if err != nil { + return "" + } + return strings.TrimSpace(string(body)) +} |
