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