package services import ( "encoding/json" "fmt" "os" "path/filepath" "strings" "sync" "time" "debtik/internal/logging" ) const ( DefaultServicesPath = "/etc/debtik/services.json" ServiceTelnet = "telnet" ServiceSSH = "ssh" ServiceWWW = "www" ServiceWWWSSL = "www-ssl" ) type Service struct { Name string `json:"name"` // "telnet", "ssh", "www", "www-ssl" Port int `json:"port"` // Port number Disabled bool `json:"disabled"` // Active status Certificate string `json:"certificate,omitempty"` // TLS certificate name for www-ssl Comment string `json:"comment,omitempty"` // Description } type ServiceHook func(serviceName string, port int, disabled bool) error type Manager struct { mu sync.RWMutex applyMu sync.Mutex filePath string services map[string]Service hooks map[string]ServiceHook } func NewManager(customPath string) *Manager { p := customPath if p == "" { p = DefaultServicesPath } m := &Manager{ filePath: p, services: make(map[string]Service), hooks: make(map[string]ServiceHook), } m.initDefaults() _ = m.load() return m } func (m *Manager) initDefaults() { m.services[ServiceWWW] = Service{ Name: ServiceWWW, Port: 80, Disabled: false, Comment: "HTTP Web Management & REST API", } m.services[ServiceWWWSSL] = Service{ Name: ServiceWWWSSL, Port: 443, Disabled: true, Certificate: "", Comment: "HTTPS Secure Web Management & REST API", } m.services[ServiceTelnet] = Service{ Name: ServiceTelnet, Port: 8282, Disabled: false, Comment: "DebTik CLI Console over TCP/Telnet", } m.services[ServiceSSH] = Service{ Name: ServiceSSH, Port: 2222, Disabled: false, Comment: "DebTik CLI Secure Shell (SSH)", } } func (m *Manager) RegisterHook(serviceName string, hook ServiceHook) { m.mu.Lock() defer m.mu.Unlock() m.hooks[strings.ToLower(serviceName)] = hook } func (m *Manager) load() error { data, err := os.ReadFile(m.filePath) if err != nil { if os.IsNotExist(err) { return m.saveLocked() } return err } var list []Service if err := json.Unmarshal(data, &list); err != nil { return err } for _, s := range list { name := normalizeServiceName(s.Name) if cur, ok := m.services[name]; ok { cur.Port = s.Port cur.Disabled = s.Disabled if s.Certificate != "" { cur.Certificate = s.Certificate } if s.Comment != "" { cur.Comment = strings.ReplaceAll(s.Comment, "RouterOS", "DebTik") } m.services[name] = cur } else { s.Comment = strings.ReplaceAll(s.Comment, "RouterOS", "DebTik") m.services[name] = s } } return nil } func (m *Manager) saveLocked() error { dir := filepath.Dir(m.filePath) if err := os.MkdirAll(dir, 0755); err != nil { return err } list := make([]Service, 0, len(m.services)) // Keep ordered: www, www-ssl, telnet, ssh order := []string{ServiceWWW, ServiceWWWSSL, ServiceTelnet, ServiceSSH} for _, name := range order { if s, ok := m.services[name]; ok { list = append(list, s) } } for name, s := range m.services { if name != ServiceWWW && name != ServiceWWWSSL && name != ServiceTelnet && name != ServiceSSH { list = append(list, s) } } data, err := json.MarshalIndent(list, "", " ") if err != nil { return err } tmp := filepath.Join(dir, fmt.Sprintf(".services.%d.tmp", time.Now().UnixNano())) if err := os.WriteFile(tmp, data, 0644); err != nil { return err } return os.Rename(tmp, m.filePath) } func (m *Manager) ListServices() []Service { m.mu.RLock() defer m.mu.RUnlock() var list []Service order := []string{ServiceWWW, ServiceWWWSSL, ServiceTelnet, ServiceSSH} for _, name := range order { if s, ok := m.services[name]; ok { list = append(list, s) } } return list } func normalizeServiceName(name string) string { k := strings.ToLower(strings.TrimSpace(name)) if k == "web" { return ServiceWWW } if k == "https" || k == "ssl" || k == "web-ssl" || k == "www-ssl" { return ServiceWWWSSL } return k } func (m *Manager) GetService(name string) (Service, bool) { m.mu.RLock() defer m.mu.RUnlock() s, ok := m.services[normalizeServiceName(name)] return s, ok } func (m *Manager) SetServiceState(name string, disabled bool) error { m.applyMu.Lock() defer m.applyMu.Unlock() m.mu.Lock() locked := true defer func() { if locked { m.mu.Unlock() } }() key := normalizeServiceName(name) svc, ok := m.services[key] if !ok { return fmt.Errorf("service %q not found", name) } previous := svc svc.Disabled = disabled m.services[key] = svc if err := m.saveLocked(); err != nil { m.services[key] = previous return err } logging.Log("system,info", "Service %s state changed: disabled=%t", key, disabled) // Trigger hook if registered hook := m.hooks[key] m.mu.Unlock() locked = false if hook != nil { if err := hook(key, svc.Port, svc.Disabled); err != nil { m.mu.Lock() m.services[key] = previous saveErr := m.saveLocked() m.mu.Unlock() _ = hook(key, previous.Port, previous.Disabled) if saveErr != nil { return fmt.Errorf("service apply failed: %v; rollback save failed: %w", err, saveErr) } return err } } return nil } func (m *Manager) SetService(name string, port int, disabled bool, certificate ...string) error { m.applyMu.Lock() defer m.applyMu.Unlock() m.mu.Lock() locked := true defer func() { if locked { m.mu.Unlock() } }() key := normalizeServiceName(name) svc, ok := m.services[key] if !ok { return fmt.Errorf("service %q not found", name) } previous := svc if port > 0 && port < 65536 { svc.Port = port } svc.Disabled = disabled if len(certificate) > 0 { svc.Certificate = certificate[0] } m.services[key] = svc if err := m.saveLocked(); err != nil { m.services[key] = previous return err } logging.Log("system,info", "Service %s updated: port=%d, disabled=%t, certificate=%s", key, svc.Port, svc.Disabled, svc.Certificate) hook := m.hooks[key] m.mu.Unlock() locked = false if hook != nil { if err := hook(key, svc.Port, svc.Disabled); err != nil { m.mu.Lock() m.services[key] = previous saveErr := m.saveLocked() m.mu.Unlock() _ = hook(key, previous.Port, previous.Disabled) if saveErr != nil { return fmt.Errorf("service apply failed: %v; rollback save failed: %w", err, saveErr) } return err } } return nil } func (m *Manager) SetServiceCertificate(name, certName string) error { svc, ok := m.GetService(name) if !ok { return fmt.Errorf("service not found: %s", name) } return m.SetService(name, svc.Port, svc.Disabled, certName) }