nginx-cache-purge/service_cmd.go
James Coleman 8d9b1c9302 Add service management, harden the purge server, and modernize the build.
Server:
- Add a --cache-path allowlist so a server can be limited to the caches it
  is meant to purge, defaulting to any path as before.
- Set socket permissions explicitly (--socket-mode, default 0660) instead of
  inheriting the service manager's umask, which left the socket unreachable.
- Refuse to remove a socket another instance is still serving.
- Read keys both raw and decoded, so keys nginx stored with escapes and keys
  a caller escaped by hand both purge.
- Add an exact= parameter for literal keys containing glob punctuation.
- Report purge failures as 500 rather than 502, and send error bodies through
  http.Error so a failure is not reported as a successful purge.
- Graceful shutdown with systemd readiness notification.

Purge:
- Group purge arguments into PurgeRequest and report the number of entries
  removed.
- Compile exclude globs once, and fail the purge when one is invalid rather
  than purging the keys it was meant to keep.
- Cap header scanning and tolerate entries nginx evicts mid-walk.
- Switch to filepath.WalkDir to avoid an Lstat per cache file.

New:
- service command to install, start, stop, and remove the system service.
- service install takes --cache-path, writing the allowlist into the unit it
  installs, so an installed service is restricted from its first start.
- Makefile, VERSION, and build identifiers stamped via ldflags.
- Tests for the server handler and the service command.

Build:
- Update to Go 1.25, kong v1, GoReleaser v2, and current GitHub Actions.
- Add vet and test steps to CI.
- Rename purgeCmd.go/serverCmd.go to Go's file naming convention.

Bump version to 0.2.0.
2026-08-12 14:58:12 -05:00

169 lines
5.8 KiB
Go

package main
import (
"fmt"
"os"
"path/filepath"
"github.com/kardianos/service"
)
// systemdScript is the unit template used when installing the service. It
// replaces the library default to run as a notify service with automatic
// restart, so systemd only considers the server started once the socket is
// bound. RuntimeDirectory gives the socket a directory systemd creates on
// start and removes on stop.
const systemdScript = `[Unit]
Description={{Description}}
ConditionFileIsExecutable={{Path | cmdEscape}}
{{range Dependencies}}{{.}}
{{end}}StartLimitIntervalSec=500
StartLimitBurst=5
[Service]
Type=notify
ExecStart={{Path | cmdEscape}}{{range Arguments}} {{. | cmd}}{{end}}
{{if ChRoot}}RootDirectory={{ChRoot | cmd}}
{{end}}{{if WorkingDirectory}}WorkingDirectory={{WorkingDirectory | cmdEscape}}
{{end}}{{if UserName}}User={{UserName}}
{{end}}{{if ReloadSignal}}ExecReload=/bin/kill -{{ReloadSignal}} "$MAINPID"
{{end}}{{if PIDFile}}PIDFile={{PIDFile | cmd}}
{{end}}{{if OutputFileSupport}}StandardOutput=file:{{LogDirectory}}/{{Name}}.out
StandardError=file:{{LogDirectory}}/{{Name}}.err
{{end}}{{if LimitNOFILE}}LimitNOFILE={{LimitNOFILE}}
{{end}}{{if Restart}}Restart={{Restart}}
{{end}}{{if SuccessExitStatus}}SuccessExitStatus={{SuccessExitStatus}}
{{end}}RuntimeDirectory={{Name}}
RestartSec=5
EnvironmentFile=-/etc/sysconfig/{{Name}}
{{range EnvVars}}{{.}}
{{end}}[Install]
WantedBy=multi-user.target
`
// ServiceAction lists the accepted service actions, in the order the Run
// switch handles them.
var ServiceAction = []string{"start", "stop", "status", "restart", "install", "uninstall"}
// ServiceCmd manages the purge server as a system service.
type ServiceCmd struct {
Action string `arg:"" enum:"${serviceActions}" help:"${serviceActions}" required:""`
CachePaths []string `name:"cache-path" type:"path" help:"Cache directory the installed service may purge, can be repeated. Any path is purgeable when none is given."`
}
// action returns the requested service action.
func (s *ServiceCmd) action() string {
return s.Action
}
// arguments builds the command line the installed unit runs. The allowlist
// belongs in the unit rather than in a drop-in written afterwards: a service
// installed with cache paths then serves no others from its first start.
func (s *ServiceCmd) arguments() []string {
arguments := make([]string, 0, 1+2*len(s.CachePaths))
arguments = append(arguments, "server")
for _, cachePath := range s.CachePaths {
// The unit runs from a working directory of the service manager's
// choosing, so a relative path here would name a different directory
// than the one the install was typed against. Symlinks are left alone:
// the server resolves them per request, and baking the target into the
// unit would pin the allowlist to wherever the link pointed at install.
if absolute, err := filepath.Abs(cachePath); err == nil {
cachePath = absolute
}
arguments = append(arguments, "--cache-path", cachePath)
}
return arguments
}
// Run performs the requested action against the installed service.
func (s *ServiceCmd) Run() (err error) {
// The allowlist is written into the unit, so it only takes effect at
// install. Accepting it on the other actions would read as having changed
// the allowlist of a service that carries on with the one it was installed
// with, which is the sort of misreading that leaves a cache purgeable.
if len(s.CachePaths) != 0 && s.action() != ServiceAction[4] {
return fmt.Errorf("--cache-path only applies to %s; reinstall the service to change it", ServiceAction[4])
}
svc, err := s.service()
if err != nil {
return err
}
switch s.action() {
case ServiceAction[0]:
err = svc.Start()
case ServiceAction[1]:
err = svc.Stop()
case ServiceAction[2]:
var status service.Status
status, err = svc.Status()
if err == nil {
switch status {
case service.StatusRunning:
fmt.Println("Service is running.")
case service.StatusStopped:
fmt.Println("Service is stopped.")
default:
fmt.Println("Service is in an unknown state.")
}
}
case ServiceAction[3]:
err = svc.Restart()
case ServiceAction[4]:
// A mistyped cache path installs cleanly and then refuses every purge
// of the cache it was meant to name, so say so now rather than leave it
// to be found by a 403. A cache directory nginx has not created yet is
// the same shape, which is why this is a warning and not a failure.
for _, cachePath := range s.CachePaths {
if _, statErr := os.Stat(cachePath); statErr != nil {
fmt.Printf("Warning: cache path %s cannot be read: %s\n", cachePath, statErr)
}
}
err = svc.Install()
case ServiceAction[5]:
err = svc.Uninstall()
}
if err != nil {
return err
}
// Status already printed its own result.
if s.action() != ServiceAction[2] {
fmt.Println("Command executed successfully.")
}
return
}
// service builds the service definition shared by the management actions and
// by the server when it is started by the service manager.
func (s *ServiceCmd) service() (service.Service, error) {
svcConfig := &service.Config{
Name: Name,
DisplayName: DisplayName,
Description: Description,
Arguments: s.arguments(),
Dependencies: []string{"After=network.target"},
Option: service.KeyValue{
"SystemdScript": systemdScript,
"Restart": "always",
},
}
return service.New(s, svcConfig)
}
// Start satisfies service.Interface. The server is already running in the
// foreground by the time the supervisor attaches.
func (s *ServiceCmd) Start(svc service.Service) error {
return nil
}
// Stop satisfies service.Interface, signalling the server's shutdown. The send
// cannot block: a shutdown already under way leaves nothing reading the
// channel, and holding the supervisor here would stall the stop it asked for.
func (s *ServiceCmd) Stop(svc service.Service) error {
select {
case stopChan <- struct{}{}:
default:
}
return nil
}