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.
169 lines
5.8 KiB
Go
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
|
|
}
|