Files
Alphaeus Mote a0c937b876 feat(tls): publish the serving leaf to the Windows My store; fix context leak
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>
2026-09-03 13:23:03 -04:00

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