package aerotun import ( "encoding/json" "fmt" "log" "os" "path/filepath" "sort" "sync" "time" ) type Manager struct { mu sync.RWMutex instances map[string]TunnelInstance runners map[string]*TunnelRunner errors map[string]string filePath string health *healthMonitor } func NewManager() *Manager { return NewManagerWithPath(StoragePath) } func NewManagerWithPath(path string) *Manager { m := &Manager{ instances: make(map[string]TunnelInstance), runners: make(map[string]*TunnelRunner), errors: make(map[string]string), filePath: path, } _ = os.MkdirAll(filepath.Dir(m.filePath), 0755) if err := m.load(); err != nil { log.Printf("[AeroTUN] Error loading saved instances: %v", err) } // Auto-start enabled tunnels for id, inst := range m.instances { if !inst.Disabled { if err := m.startRunnerLocked(id); err != nil { m.errors[id] = err.Error() log.Printf("[AeroTUN] Failed to auto-start tunnel %s (%s): %v", inst.Name, id, err) } } } m.health = newHealthMonitor(filepath.Join(filepath.Dir(path), "aerotun_health.json")) go m.monitorHealth() return m } func (m *Manager) load() error { m.mu.Lock() defer m.mu.Unlock() data, err := os.ReadFile(m.filePath) if err != nil { if os.IsNotExist(err) { return nil } return err } var list []TunnelInstance if err := json.Unmarshal(data, &list); err != nil { return err } for _, inst := range list { m.instances[inst.ID] = inst } return nil } func (m *Manager) saveLocked() error { list := make([]TunnelInstance, 0, len(m.instances)) for _, inst := range m.instances { // Do not persist volatile runtime counters copyInst := inst copyInst.Running = false copyInst.Handshakes = 0 copyInst.Backend = "" copyInst.ConfiguredMTU = 0 copyInst.DropBreakdown = nil copyInst.HasPSK = false copyInst.HasPrivateKey = false copyInst.Connected = false copyInst.LastError = "" copyInst.DroppedPackets = 0 copyInst.AuthFailures = 0 copyInst.ReplayDrops = 0 copyInst.ActivePeerEndpoint = "" copyInst.LastHandshake = "" copyInst.RxBytes = 0 copyInst.TxBytes = 0 copyInst.RawBytesIn = 0 copyInst.RawBytesOut = 0 copyInst.WireBytesIn = 0 copyInst.WireBytesOut = 0 copyInst.SavedBytes = 0 copyInst.CompressionRatio = "" copyInst.PacketsRx = 0 copyInst.PacketsTx = 0 copyInst.CompressedPackets = 0 copyInst.RawPackets = 0 list = append(list, copyInst) } data, err := json.MarshalIndent(list, "", " ") if err != nil { return err } f, err := os.CreateTemp(filepath.Dir(m.filePath), ".aerotun-*.json") if err != nil { return err } defer os.Remove(f.Name()) if err = f.Chmod(0600); err != nil { f.Close() return err } if _, err = f.Write(data); err != nil { f.Close() return err } if err = f.Sync(); err != nil { f.Close() return err } if err = f.Close(); err != nil { return err } return os.Rename(f.Name(), m.filePath) } func (m *Manager) List() []TunnelInstance { m.mu.RLock() defer m.mu.RUnlock() result := make([]TunnelInstance, 0, len(m.instances)) for id, inst := range m.instances { if runner, ok := m.runners[id]; ok && runner != nil { tele := runner.GetTelemetry() tele.Disabled = inst.Disabled tele.Comment = inst.Comment result = append(result, tele) } else { inst.Running = false inst.Connected = false inst.LastError = m.errors[id] result = append(result, inst) } } sort.Slice(result, func(i, j int) bool { return result[i].Name < result[j].Name }) return result } func (m *Manager) Get(id string) (TunnelInstance, bool) { m.mu.RLock() defer m.mu.RUnlock() inst, exists := m.instances[id] if !exists { return TunnelInstance{}, false } if runner, ok := m.runners[id]; ok && runner != nil { tele := runner.GetTelemetry() tele.Disabled = inst.Disabled tele.Comment = inst.Comment return tele, true } inst.Running = false inst.Connected = false inst.LastError = m.errors[id] return inst, true } func (m *Manager) Save(inst TunnelInstance) (TunnelInstance, error) { m.mu.Lock() defer m.mu.Unlock() if inst.ID == "" { inst.ID = fmt.Sprintf("aerotun-%d", time.Now().UnixNano()) } if inst.Name == "" { inst.Name = fmt.Sprintf("aerotun%d", len(m.instances)) } if inst.MTU == 0 { inst.MTU = DefaultMTU } if inst.Mode == "server" && inst.ListenPort == 0 { inst.ListenPort = DefaultPort } if inst.CreatedAt.IsZero() { inst.CreatedAt = time.Now() } if old, ok := m.instances[inst.ID]; ok { if inst.PSK == "" { inst.PSK = old.PSK } if inst.PrivateKey == "" { inst.PrivateKey = old.PrivateKey inst.PublicKey = old.PublicKey } } if inst.Protocol == 3 && inst.PrivateKey == "" { var err error inst.PrivateKey, inst.PublicKey, err = GenerateIdentity() if err != nil { return inst, err } } if err := validateConfig(&inst); err != nil { return inst, err } for id, existing := range m.instances { if id != inst.ID && existing.Name == inst.Name { return inst, fmt.Errorf("interface name already configured: %s", inst.Name) } } previous, existed := m.instances[inst.ID] m.instances[inst.ID] = inst if err := m.saveLocked(); err != nil { if existed { m.instances[inst.ID] = previous } else { delete(m.instances, inst.ID) } return inst, fmt.Errorf("failed to save configuration: %w", err) } if runner := m.runners[inst.ID]; runner != nil { runner.Stop() delete(m.runners, inst.ID) } delete(m.errors, inst.ID) // Restart if not disabled if !inst.Disabled { if err := m.startRunnerLocked(inst.ID); err != nil { m.errors[inst.ID] = err.Error() return inst, fmt.Errorf("configuration saved but tunnel failed to start: %w", err) } } if runner, ok := m.runners[inst.ID]; ok && runner != nil { return runner.GetTelemetry(), nil } return inst, nil } func (m *Manager) Delete(id string) error { m.mu.Lock() defer m.mu.Unlock() if runner, exists := m.runners[id]; exists && runner != nil { runner.Stop() delete(m.runners, id) } delete(m.instances, id) delete(m.errors, id) return m.saveLocked() } func (m *Manager) StartTunnel(id string) error { m.mu.Lock() defer m.mu.Unlock() return m.toggleStateLocked(id, false) } func (m *Manager) StopTunnel(id string) error { m.mu.Lock() defer m.mu.Unlock() if runner, exists := m.runners[id]; exists && runner != nil { runner.Stop() delete(m.runners, id) } return nil } func (m *Manager) ToggleState(id string, disabled bool) error { m.mu.Lock() defer m.mu.Unlock() return m.toggleStateLocked(id, disabled) } func (m *Manager) toggleStateLocked(id string, disabled bool) error { inst, exists := m.instances[id] if !exists { return fmt.Errorf("tunnel not found: %s", id) } previous := inst inst.Disabled = disabled m.instances[id] = inst if err := m.saveLocked(); err != nil { m.instances[id] = previous return err } if disabled { if runner := m.runners[id]; runner != nil { runner.Stop() delete(m.runners, id) } delete(m.errors, id) return nil } return m.startRunnerLocked(id) } func (m *Manager) startRunnerLocked(id string) error { inst, exists := m.instances[id] if !exists { return fmt.Errorf("tunnel not found: %s", id) } if runner, exists := m.runners[id]; exists && runner != nil { runner.Stop() delete(m.runners, id) } delete(m.errors, id) runner, err := NewTunnelRunner(inst) if err != nil { m.errors[id] = err.Error() return err } if err := runner.Start(); err != nil { runner.Stop() m.errors[id] = err.Error() return err } m.runners[id] = runner return nil } func (m *Manager) StopAll() { if m.health != nil { m.health.stop() } m.mu.Lock() defer m.mu.Unlock() for id, runner := range m.runners { if runner != nil { runner.Stop() } delete(m.runners, id) } } func (m *Manager) GetSummaryStats() SummaryStats { tunnels := m.List() stats := SummaryStats{ TotalTunnels: len(tunnels), AlgorithmCrypto: "XChaCha20-Poly1305 AEAD (v3: Noise IKpsk2)", AlgorithmCompress: "Adaptive LZ4 (Dynamic Threshold)", } var totalRawOut, totalWireOut uint64 for _, t := range tunnels { if t.Connected { stats.ActiveTunnels++ } stats.TotalRxBytes += t.RxBytes stats.TotalTxBytes += t.TxBytes stats.TotalSavedBytes += t.SavedBytes totalRawOut += t.RawBytesOut totalWireOut += t.WireBytesOut } if totalRawOut > 0 && totalWireOut > 0 { ratio := float64(totalRawOut) / float64(totalWireOut) if ratio > 1.0 { savedPct := (1.0 - (float64(totalWireOut) / float64(totalRawOut))) * 100.0 stats.AverageRatio = fmt.Sprintf("%.2fx (%.1f%% saved)", ratio, savedPct) } else { stats.AverageRatio = "1.00x (0% saved)" } } else { stats.AverageRatio = "1.00x (0% saved)" } return stats }