// Service lifecycle management (install / uninstall / start / stop) plus the // service-hosted run loop. Uses kardianos/service so the same binary runs as a // Windows service (SCM), a systemd/upstart/sysv daemon on Linux, or a launchd // daemon on macOS, and can also run interactively (foreground / container). package cli import ( "context" "errors" "fmt" "os" "path/filepath" "github.com/Grace-Solutions/OrchestrAD/internal/config" "github.com/Grace-Solutions/OrchestrAD/internal/logging" "github.com/Grace-Solutions/OrchestrAD/internal/pki" "github.com/Grace-Solutions/OrchestrAD/internal/tlsmgr" "github.com/kardianos/service" ) const ( serviceName = "OrchestrAD" serviceDisplayName = "OrchestrAD" serviceDescription = "OrchestrAD - Active Directory Rule Automation Platform" ) // program adapts the server run loop to the service.Interface contract. Start // must not block, so the server runs on a goroutine whose lifetime is bound to // a context cancelled by Stop. type program struct { cancel context.CancelFunc done chan error } // Start is called by the service manager (or by Run when interactive). It // launches the server without blocking. func (p *program) Start(s service.Service) error { // When launched by a service manager the process inherits the manager's // working directory (e.g. C:\Windows\System32 on Windows), which would put a // relative ORCHESTRAD_DATA_PATH (default ./data) in the wrong place. Pin the // working directory to the executable's directory so data lands next to the // installed binary. Interactive runs (foreground / container) keep the // caller's working directory and any absolute data path they set. if !service.Interactive() { if exe, err := os.Executable(); err == nil { _ = os.Chdir(filepath.Dir(exe)) } } ctx, cancel := context.WithCancel(context.Background()) p.cancel = cancel p.done = make(chan error, 1) go func() { p.done <- runServer(ctx) }() return nil } // Stop is called on service stop / interactive interrupt. It cancels the run // context and waits for the server to unwind so the process exits cleanly. func (p *program) Stop(s service.Service) error { if p.cancel != nil { p.cancel() } if p.done != nil { return <-p.done } return nil } // serviceConfig builds the platform service definition. WorkingDirectory is // pinned to the executable's directory so a relative ORCHESTRAD_DATA_PATH // (default ./data) resolves next to the installed binary rather than wherever // the service manager happens to launch it from. Arguments ["run"] make the // service manager start the process in run mode, which routes back through // service.Run. func serviceConfig() (*service.Config, error) { exe, err := os.Executable() if err != nil { return nil, fmt.Errorf("resolving executable path: %w", err) } return &service.Config{ Name: serviceName, DisplayName: serviceDisplayName, Description: serviceDescription, Arguments: []string{"run"}, WorkingDirectory: filepath.Dir(exe), }, nil } // newService constructs the service handle and its program. func newService() (service.Service, error) { cfg, err := serviceConfig() if err != nil { return nil, err } return service.New(&program{}, cfg) } // runService runs the server through the service framework. When launched by a // service manager it speaks the manager's control protocol (required on // Windows); when launched interactively it runs Start, blocks until an // interrupt, then runs Stop. This is the single entry point used by the `run` // command so foreground, container, and service execution share one path. func runService() error { s, err := newService() if err != nil { return fmt.Errorf("creating service: %w", err) } return s.Run() } // controlService applies a single service control action (start or stop) using // the platform service manager. func controlService(action string) error { s, err := newService() if err != nil { return fmt.Errorf("creating service: %w", err) } if err := service.Control(s, action); err != nil { return fmt.Errorf("%s service: %w", action, err) } return nil } // initializeService idempotently ensures the service is installed and running. // Re-running it is safe: an already-installed service is not reinstalled, and an // already-running one is left alone. This backs the `initialize` (and `install`) // command so provisioning is repeatable. func initializeService() error { s, err := newService() if err != nil { return fmt.Errorf("creating service: %w", err) } status, err := s.Status() if errors.Is(err, service.ErrNotInstalled) { if err := s.Install(); err != nil { return fmt.Errorf("installing service: %w", err) } logging.Info("Service", "Service installed") status = service.StatusStopped } else if err != nil { return fmt.Errorf("querying service status: %w", err) } else { logging.Info("Service", "Service already installed") } ensureFirewall() if status == service.StatusRunning { logging.Info("Service", "Service already running") return nil } if err := s.Start(); err != nil { return fmt.Errorf("starting service: %w", err) } logging.Info("Service", "Service started") return nil } // ensureFirewall creates the inbound allow rule for the configured listen port. // Best-effort: a failure (e.g. missing privileges) is logged but does not block // service provisioning. func ensureFirewall() { port := 18090 if cfg, err := config.Load(); err == nil { port = cfg.Server.Port } if err := EnsureFirewallRule(port); err != nil { logging.Warn("Firewall", "Could not create inbound firewall rule (%v); open TCP %d manually if needed", err, port) } } // removeService idempotently ensures the service is stopped and removed. // Re-running it is safe: a not-installed service is treated as already removed, // and a stop failure on an already-stopped service does not block removal. This // backs the `remove` (and `uninstall`) command. func removeService() error { s, err := newService() if err != nil { return fmt.Errorf("creating service: %w", err) } status, err := s.Status() if errors.Is(err, service.ErrNotInstalled) { logging.Info("Service", "Service not installed; nothing to remove") return nil } else if err != nil { return fmt.Errorf("querying service status: %w", err) } if status == service.StatusRunning { if err := s.Stop(); err != nil { logging.Warn("Service", "Could not stop service before removal: %v", err) } else { logging.Info("Service", "Service stopped") } } if err := s.Uninstall(); err != nil { return fmt.Errorf("removing service: %w", err) } _ = RemoveFirewallRule() removeStoreCertificate() logging.Info("Service", "Service removed") return nil } // removeStoreCertificate deletes the self-managed leaf we published into the // host's personal certificate store, so uninstalling does not leave an orphan // behind. Best-effort: the certificate may never have been installed, and the // data directory may already be gone. func removeStoreCertificate() { cfg, err := config.Load() if err != nil { return } certPEM, err := os.ReadFile(filepath.Join(cfg.DataPath, "tls", "server.crt")) if err != nil { return } cert, err := pki.ParseCertPEM(certPEM) if err != nil { return } if err := tlsmgr.RemoveLeafFromMyStore(cert); err != nil { logging.Warn("Service", "Could not remove the server certificate from the host store: %v", err) return } logging.Info("Service", "Removed the server certificate from the host store") }