Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
7 changes: 7 additions & 0 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
6 changes: 6 additions & 0 deletions cmd/main.go
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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
Expand Down Expand Up @@ -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)
}

Expand Down
46 changes: 46 additions & 0 deletions cmd/service.go
Original file line number Diff line number Diff line change
@@ -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])
}
}
20 changes: 20 additions & 0 deletions cmd/service_other.go
Original file line number Diff line number Diff line change
@@ -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 }
216 changes: 216 additions & 0 deletions cmd/service_windows.go
Original file line number Diff line number Diff line change
@@ -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}
}
Loading
Loading