diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 4906968..bd22fbb 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -86,6 +86,13 @@ jobs: - name: The agent's tests, on Windows run: go test ./... -v + # Installs the real binary as a Windows service, starts it, and waits for + # a heartbeat. Gated by a variable because it changes the machine. + - name: The agent as a Windows service + env: + CERTPILOT_SERVICE_TEST: "1" + run: go test ./cmd/ -run Service -v + iis: name: IIS end to end runs-on: windows-latest diff --git a/cmd/main.go b/cmd/main.go index d253d8b..a6153aa 100644 --- a/cmd/main.go +++ b/cmd/main.go @@ -53,6 +53,8 @@ func main() { err = runInstall(ctx, os.Args[2:]) case "profiles": err = runProfiles(os.Args[2:]) + case "service": + err = runServiceCommand(os.Args[2:]) case "-h", "--help", "help": usage() return @@ -86,6 +88,7 @@ func usage() { certpilot-agent install [--installs=FILE] [--force] [--offline] certpilot-agent profiles [NAME] [--detect] which platforms are covered certpilot-agent status [--state-dir=DIR] + certpilot-agent service install|uninstall Windows: run as a service certpilot-agent version Enrolment generates this host's identity key locally. The private half is never @@ -176,6 +179,9 @@ func runAgent(ctx context.Context, args []string) error { len(report.Certificates), report.FilesSeen) return nil } + if runningAsService() { + return runAsService(runner.Run) + } return runner.Run(ctx) } diff --git a/cmd/service.go b/cmd/service.go new file mode 100644 index 0000000..19247b5 --- /dev/null +++ b/cmd/service.go @@ -0,0 +1,46 @@ +package main + +import ( + "errors" + "flag" + "fmt" + "path/filepath" + + agent "github.com/certpilot/certpilot-agent" +) + +// serviceName is what the service manager knows the agent as. +const serviceName = "certpilot-agent" + +func runServiceCommand(args []string) error { + if err := serviceAvailable(); err != nil { + return err + } + if len(args) == 0 { + return errors.New("usage: certpilot-agent service install [--state-dir=DIR] [--installs=FILE] | uninstall") + } + switch args[0] { + case "install": + fs := flag.NewFlagSet("service install", flag.ExitOnError) + stateDir := fs.String("state-dir", agent.DefaultStateDir(), "the enrolled identity the service runs as") + specPath := fs.String("installs", "", "where the destinations are declared (defaults to /etc/certpilot/installs.json)") + if err := fs.Parse(args[1:]); err != nil { + return err + } + dir, err := filepath.Abs(*stateDir) + if err != nil { + return err + } + // A service started without an identity fails at once and then again + // on every restart, and the reason ends up in an event log nobody is + // looking at yet. Refusing here says it to the person installing. + if !agent.Enrolled(dir) { + return fmt.Errorf("no identity in %s: run `certpilot-agent enrol` before installing the service", dir) + } + return installService(dir, *specPath) + case "uninstall": + return uninstallService() + default: + return fmt.Errorf("unknown service command %q: install or uninstall", args[0]) + } +} diff --git a/cmd/service_other.go b/cmd/service_other.go new file mode 100644 index 0000000..f509029 --- /dev/null +++ b/cmd/service_other.go @@ -0,0 +1,20 @@ +//go:build !windows + +package main + +import ( + "context" + "errors" +) + +var errNotWindows = errors.New("`service` is for Windows. On Linux, the .deb and .rpm install a systemd unit and a timer; see docs/agent.md") + +func serviceAvailable() error { return errNotWindows } + +func runningAsService() bool { return false } + +func runAsService(func(context.Context) error) error { return errNotWindows } + +func installService(string, string) error { return errNotWindows } + +func uninstallService() error { return errNotWindows } diff --git a/cmd/service_windows.go b/cmd/service_windows.go new file mode 100644 index 0000000..3d24092 --- /dev/null +++ b/cmd/service_windows.go @@ -0,0 +1,216 @@ +//go:build windows + +package main + +import ( + "bytes" + "context" + "errors" + "fmt" + "log/slog" + "os" + "path/filepath" + "time" + + agent "github.com/certpilot/certpilot-agent" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/eventlog" + "golang.org/x/sys/windows/svc/mgr" +) + +func serviceAvailable() error { return nil } + +// runningAsService reports whether the service manager started this process. +func runningAsService() bool { + ok, err := svc.IsWindowsService() + return err == nil && ok +} + +func installService(stateDir, specPath string) error { + exe, err := os.Executable() + if err != nil { + return err + } + exe, err = filepath.Abs(exe) + if err != nil { + return err + } + + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("could not reach the service manager; run this as Administrator: %w", err) + } + defer m.Disconnect() + + if s, err := m.OpenService(serviceName); err == nil { + s.Close() + return fmt.Errorf("a %s service is already installed; run `certpilot-agent service uninstall` first", serviceName) + } + + args := []string{"run", "--state-dir", stateDir} + if specPath != "" { + args = append(args, "--installs", specPath) + } + s, err := m.CreateService(serviceName, exe, mgr.Config{ + DisplayName: "CertPilot agent", + Description: "Requests certificates for this host and installs them where its services read them.", + StartType: mgr.StartAutomatic, + }, args...) + if err != nil { + return fmt.Errorf("could not create the service: %w", err) + } + defer s.Close() + + // Restart after 30 seconds, as the systemd unit does. On a non-zero exit + // too, not only a crash: that is how the agent says something went wrong. + restart := mgr.RecoveryAction{Type: mgr.ServiceRestart, Delay: 30 * time.Second} + if err := s.SetRecoveryActions([]mgr.RecoveryAction{restart, restart, restart}, 24*60*60); err != nil { + return fmt.Errorf("could not set the service to restart: %w", err) + } + if err := s.SetRecoveryActionsOnNonCrashFailures(true); err != nil { + return fmt.Errorf("could not set the service to restart: %w", err) + } + + // Where the agent's log goes when there is no console. Already there after + // an earlier install, which is fine. + _ = eventlog.InstallAsEventCreate(serviceName, eventlog.Error|eventlog.Warning|eventlog.Info) + + fmt.Printf("installed the %s service for %s\nstart it with: Start-Service %s\n", serviceName, stateDir, serviceName) + return nil +} + +func uninstallService() error { + m, err := mgr.Connect() + if err != nil { + return fmt.Errorf("could not reach the service manager; run this as Administrator: %w", err) + } + defer m.Disconnect() + + s, err := m.OpenService(serviceName) + if err != nil { + return fmt.Errorf("no %s service is installed", serviceName) + } + defer s.Close() + + if status, err := s.Control(svc.Stop); err == nil { + deadline := time.Now().Add(30 * time.Second) + for status.State != svc.Stopped && time.Now().Before(deadline) { + time.Sleep(300 * time.Millisecond) + if status, err = s.Query(); err != nil { + break + } + } + } + if err := s.Delete(); err != nil { + return fmt.Errorf("could not remove the service: %w", err) + } + _ = eventlog.Remove(serviceName) + fmt.Printf("removed the %s service; its identity is left where it was\n", serviceName) + return nil +} + +// runAsService hands this process to the service manager and runs the agent +// until it is told to stop. +func runAsService(run func(context.Context) error) error { + if elog, err := eventlog.Open(serviceName); err == nil { + defer elog.Close() + slog.SetDefault(slog.New(&eventLogHandler{log: elog})) + } + return svc.Run(serviceName, &serviceHandler{run: run}) +} + +type serviceHandler struct { + run func(context.Context) error +} + +func (h *serviceHandler) Execute(_ []string, requests <-chan svc.ChangeRequest, status chan<- svc.Status) (bool, uint32) { + status <- svc.Status{State: svc.StartPending} + + ctx, cancel := context.WithCancel(context.Background()) + defer cancel() + done := make(chan error, 1) + go func() { done <- h.run(ctx) }() + + status <- svc.Status{State: svc.Running, Accepts: svc.AcceptStop | svc.AcceptShutdown} + for { + select { + case err := <-done: + // A revoked credential stops cleanly. Exiting with an error would + // have the service manager restart it every 30 seconds, which is a + // revoked credential still knocking on the door. + if err == nil || errors.Is(err, agent.ErrRevoked) { + return false, 0 + } + slog.Error("the agent stopped", "error", err) + return false, 1 + + case req := <-requests: + switch req.Cmd { + case svc.Interrogate: + status <- req.CurrentStatus + case svc.Stop, svc.Shutdown: + status <- svc.Status{State: svc.StopPending} + cancel() + select { + case <-done: + case <-time.After(20 * time.Second): + } + return false, 0 + } + } + } +} + +// eventLogHandler writes the agent's log to the Windows Event Log, the place +// an administrator looks for a service's messages. +type eventLogHandler struct { + log *eventlog.Log + // with replays WithAttrs and WithGroup onto the text handler that formats + // each record, in the order they were called. + with []func(slog.Handler) slog.Handler +} + +func (h *eventLogHandler) Enabled(_ context.Context, level slog.Level) bool { + return level >= slog.LevelInfo +} + +func (h *eventLogHandler) Handle(ctx context.Context, r slog.Record) error { + var b bytes.Buffer + var inner slog.Handler = slog.NewTextHandler(&b, &slog.HandlerOptions{ + // The Event Log stamps its own time. + ReplaceAttr: func(groups []string, a slog.Attr) slog.Attr { + if len(groups) == 0 && a.Key == slog.TimeKey { + return slog.Attr{} + } + return a + }, + }) + for _, w := range h.with { + inner = w(inner) + } + if err := inner.Handle(ctx, r); err != nil { + return err + } + msg := b.String() + switch { + case r.Level >= slog.LevelError: + return h.log.Error(1, msg) + case r.Level >= slog.LevelWarn: + return h.log.Warning(1, msg) + default: + return h.log.Info(1, msg) + } +} + +func (h *eventLogHandler) WithAttrs(attrs []slog.Attr) slog.Handler { + return h.extend(func(inner slog.Handler) slog.Handler { return inner.WithAttrs(attrs) }) +} + +func (h *eventLogHandler) WithGroup(name string) slog.Handler { + return h.extend(func(inner slog.Handler) slog.Handler { return inner.WithGroup(name) }) +} + +func (h *eventLogHandler) extend(w func(slog.Handler) slog.Handler) slog.Handler { + with := append(append([]func(slog.Handler) slog.Handler{}, h.with...), w) + return &eventLogHandler{log: h.log, with: with} +} diff --git a/cmd/service_windows_test.go b/cmd/service_windows_test.go new file mode 100644 index 0000000..da7a84c --- /dev/null +++ b/cmd/service_windows_test.go @@ -0,0 +1,125 @@ +//go:build windows + +package main + +import ( + "net/http" + "net/http/httptest" + "os" + "os/exec" + "path/filepath" + "testing" + "time" + + agent "github.com/certpilot/certpilot-agent" + "github.com/certpilot/certpilot-agent-sdk/agentauth" + "golang.org/x/sys/windows/svc" + "golang.org/x/sys/windows/svc/mgr" +) + +// The whole point of a Windows service is what the service manager does with +// it, so this installs the real binary as a real service, starts it, and +// watches it heartbeat to a core. It changes the machine it runs on, which is +// why it needs CERTPILOT_SERVICE_TEST=1 and is only set in CI. +func TestTheAgentRunsAsAWindowsService(t *testing.T) { + if os.Getenv("CERTPILOT_SERVICE_TEST") != "1" { + t.Skip("installs a Windows service; set CERTPILOT_SERVICE_TEST=1 to run") + } + + heartbeats := make(chan struct{}, 16) + core := httptest.NewServer(http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) { + if r.URL.Path == "/api/v1/agent/heartbeat" { + select { + case heartbeats <- struct{}{}: + default: + } + } + w.WriteHeader(http.StatusServiceUnavailable) + })) + defer core.Close() + + dir := t.TempDir() + bin := filepath.Join(dir, "certpilot-agent.exe") + if out, err := exec.Command("go", "build", "-o", bin, ".").CombinedOutput(); err != nil { + t.Fatalf("build: %v\n%s", err, out) + } + + stateDir := filepath.Join(dir, "state") + _, key, err := agentauth.GenerateKey() + if err != nil { + t.Fatal(err) + } + if err := agent.SaveIdentity(stateDir, key, agent.State{ + AgentID: "agent-1", Server: core.URL, Name: "service-test", + HeartbeatIntervalSeconds: 30, EnrolledAt: time.Now(), + }); err != nil { + t.Fatal(err) + } + + cli := func(args ...string) { + t.Helper() + if out, err := exec.Command(bin, args...).CombinedOutput(); err != nil { + t.Fatalf("certpilot-agent %v: %v\n%s", args, err, out) + } + } + cli("service", "install", "--state-dir", stateDir) + t.Cleanup(func() { _ = exec.Command(bin, "service", "uninstall").Run() }) + + m, err := mgr.Connect() + if err != nil { + t.Fatal(err) + } + defer m.Disconnect() + s, err := m.OpenService("certpilot-agent") + if err != nil { + t.Fatalf("installed, but the service manager has no certpilot-agent: %v", err) + } + cfg, err := s.Config() + if err != nil { + t.Fatal(err) + } + if cfg.StartType != mgr.StartAutomatic { + t.Errorf("start type %d, want automatic: the agent has to come back after a reboot", cfg.StartType) + } + + if err := s.Start(); err != nil { + t.Fatalf("start: %v", err) + } + waitFor(t, s, svc.Running) + + select { + case <-heartbeats: + case <-time.After(30 * time.Second): + t.Fatal("the service is running but never sent a heartbeat") + } + + if _, err := s.Control(svc.Stop); err != nil { + t.Fatalf("stop: %v", err) + } + waitFor(t, s, svc.Stopped) + s.Close() + + cli("service", "uninstall") + if s, err := m.OpenService("certpilot-agent"); err == nil { + s.Close() + t.Fatal("uninstall left the service registered") + } +} + +func waitFor(t *testing.T, s *mgr.Service, want svc.State) { + t.Helper() + deadline := time.Now().Add(30 * time.Second) + for { + status, err := s.Query() + if err != nil { + t.Fatal(err) + } + if status.State == want { + return + } + if time.Now().After(deadline) { + t.Fatalf("service state %d, want %d", status.State, want) + } + time.Sleep(300 * time.Millisecond) + } +} diff --git a/docs/agent.md b/docs/agent.md index 7954ee7..7a416ca 100644 --- a/docs/agent.md +++ b/docs/agent.md @@ -605,6 +605,18 @@ RestartSec=30 WantedBy=multi-user.target ``` +On Windows, as a service, from an elevated prompt after enrolling: + +```powershell +certpilot-agent service install # registers it to start with Windows +Start-Service certpilot-agent +certpilot-agent service uninstall # stops and removes it; the identity stays +``` + +It runs as LocalSystem, restarts 30 seconds after a failure, and logs to the +Application event log under `certpilot-agent`. It is not installed if the host +has not enrolled. + It runs as root when it has to write into `/etc/nginx` and reload a service. Where it does not, run it as a user that owns the certificate directory.