a0c937b876
Clients that resolve a certificate by store lookup rather than by reading our PEM files had nothing to find: in auto mode the leaf lived only under <data>/tls, and the My store was opened strictly read-only. InstallLeafToMyStore imports the exported PKCS#12 into LocalMachine\My with the private key persisted to the machine keyset (CNG KSP, matching the ncryptSigner path used when serving *from* the store). Renewal is accounted for: the new leaf is added with REPLACE_EXISTING, then pruneSupersededLeaves removes any certificate sharing its subject *and* issuer, so the store holds exactly one current leaf instead of one per renewal. Only certificates issued by our own CA to our own subject are ever deleted - anything from another issuer is left strictly alone. Best-effort: it needs admin rights and TLS serving does not depend on it. RemoveLeafFromMyStore runs on service removal, alongside the firewall rule, so uninstalling leaves no orphaned certificate. Also fixes a genuine leak found while auditing this code, in response to a question about whether listing the store could damage it (it cannot - the listing handle is read-only and stores are not exclusively locked): ensureWindowsStore returned from inside the enumeration without freeing the matched CertContext. CertEnumCertificatesInStore frees the previous context each call and the last on completion, so only the early-return paths leaked - and because CertCloseStore(store, 0) defers until outstanding contexts are released, the store handle leaked with it, once per certificate load. Verified on Windows 11: leaf appears in LocalMachine\My with a usable private key and full SANs; forcing a re-issue replaces it (one cert, new thumbprint, old one pruned) and leaves unrelated certificates untouched; HTTPS keeps serving throughout. Cross-compiles for linux via the no-op stubs. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
232 lines
7.4 KiB
Go
232 lines
7.4 KiB
Go
// 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")
|
|
}
|