nginx-cache-purge/server_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

360 lines
12 KiB
Go

package main
import (
"context"
"errors"
"fmt"
"log"
"net"
"net/http"
"net/url"
"os"
"os/signal"
"path/filepath"
"slices"
"strconv"
"strings"
"syscall"
"time"
"github.com/coreos/go-systemd/v22/daemon"
"github.com/kardianos/service"
)
// Where the socket goes when the command line does not say. Matches the
// RuntimeDirectory the packaged systemd unit creates.
const defaultSocketPath = "/run/nginx-cache-purge/http.sock"
// What the socket's permissions are set to when the command line does not say.
// Connecting to a UNIX socket requires write permission on it, so this decides
// whether nginx can purge at all. Left to the umask a service manager starts us
// with, the socket comes out 0755, which no other user can connect to however
// the deployment is arranged.
const defaultSocketMode = "0660"
// stopChan carries a shutdown request from the service manager. It is
// buffered so a stop delivered after the signal loop exits cannot block the
// service supervisor.
var stopChan = make(chan struct{}, 1)
// The server command for the CLI to run the HTTP server.
type ServerCmd struct {
Socket string `help:"Socket path for HTTP communication (default ${defaultSocket})." type:"path"`
SocketMode string `help:"Octal permissions to give the socket." default:"${defaultMode}"`
CachePaths []string `name:"cache-path" help:"Cache directory that may be purged, can be repeated. Any path is purgeable when none is given."`
}
// allows reports whether a request may purge the given cache path. The path
// arrives from the caller, and the purge deletes what it finds there, so a
// server started with a list of cache directories will serve no other. Naming
// none keeps every path purgeable, which is what a server without the flag has
// always done.
func (a *ServerCmd) allows(cachePath string) bool {
if len(a.CachePaths) == 0 {
return true
}
target := resolvePath(cachePath)
for _, allowed := range a.CachePaths {
root := resolvePath(allowed)
// Compare by path element rather than by string prefix, so that
// /var/cache-other is not taken for a directory inside /var/cache.
relative, err := filepath.Rel(root, target)
if err != nil {
continue
}
if relative == "." {
return true
}
if relative != ".." && !strings.HasPrefix(relative, ".."+string(filepath.Separator)) {
return true
}
}
return false
}
// resolvePath canonicalises a path so two spellings of one directory compare
// equal, following symlinks so that a link to an allowed cache is recognised as
// that cache. A path that cannot be resolved is only cleaned: it is one the
// purge is about to fail on anyway, and inventing a resolution for it could let
// it match an allowed directory it does not name.
func resolvePath(path string) string {
if absolute, err := filepath.Abs(path); err == nil {
path = absolute
}
if resolved, err := filepath.EvalSymlinks(path); err == nil {
return resolved
}
return filepath.Clean(path)
}
// socketMode is the permissions to give the socket, falling back to the default
// when the command line does not say.
func (a *ServerCmd) socketMode() (os.FileMode, error) {
mode := a.SocketMode
if mode == "" {
mode = defaultSocketMode
}
parsed, err := strconv.ParseUint(mode, 8, 32)
if err != nil || parsed > 0o777 {
return 0, fmt.Errorf("invalid socket mode %q, expected octal permissions such as %s", mode, defaultSocketMode)
}
return os.FileMode(parsed), nil
}
// rawQuery splits a query string into its parameters without percent-decoding
// them, which url.Values.Get would do.
func rawQuery(query string) url.Values {
values := make(url.Values)
for query != "" {
var parameter string
parameter, query, _ = strings.Cut(query, "&")
if parameter == "" {
continue
}
name, value, _ := strings.Cut(parameter, "=")
values[name] = append(values[name], value)
}
return values
}
// firstValue returns the first value given for a parameter, and whether it was
// present at all. url.Values.Get cannot tell a parameter that was left empty
// from one that was never sent.
func firstValue(values url.Values, name string) (string, bool) {
given, ok := values[name]
if !ok || len(given) == 0 {
return "", false
}
return given[0], true
}
// exactRequested reports whether the request asked for the key to be read as a
// literal. Like the other parameters it is taken first-wins, so a client whose
// request URI carries its own exact= cannot override the one the Nginx rewrite
// set ahead of the key. A value that is not a boolean is refused rather than
// assumed false: silently falling back to pattern matching is how a key holding
// ? or [ stops being purged while the response still reports that it was.
func exactRequested(raw, query url.Values) (bool, error) {
value, ok := firstValue(raw, "exact")
if !ok {
value, ok = firstValue(query, "exact")
}
if !ok {
return false, nil
}
// A bare exact, with no value at all, asks for it.
if value == "" {
return true, nil
}
exact, err := strconv.ParseBool(value)
if err != nil {
return false, fmt.Errorf("invalid exact parameter %q, expected a boolean such as 1", value)
}
return exact, nil
}
// Handle request.
func (a *ServerCmd) ServeHTTP(w http.ResponseWriter, req *http.Request) {
// Parse query parameters. The key is read twice: nginx substitutes
// $request_uri into the query string as-is, so the cache key it stored is
// the escapes and plus signs exactly as they arrive here, while a caller
// purging by hand is more likely to have escaped the key properly. Decoding
// is therefore a guess either way, so the purge tries both.
query := req.URL.Query()
raw := rawQuery(req.URL.RawQuery)
// The cache path names a directory rather than a cache key, so the decoded
// form is the one that matches what is on disk. A value the parser could
// not decode is dropped rather than reported, so fall back to what was sent
// instead of answering that no path was given.
cachePath := query.Get("path")
if cachePath == "" {
cachePath = raw.Get("path")
}
if cachePath == "" {
// http.Error must send the message itself. Writing the body first
// commits a 200 status, leaving the failure indistinguishable from
// a successful purge.
http.Error(w, "Need path parameter.", http.StatusBadRequest)
return
}
if !a.allows(cachePath) {
log.Println("Refusing to purge", cachePath, "as it is not an allowed cache path.")
http.Error(w, "Cache path is not allowed.", http.StatusForbidden)
return
}
key := raw.Get("key")
if key == "" {
http.Error(w, "Need key parameter.", http.StatusBadRequest)
return
}
exact, err := exactRequested(raw, query)
if err != nil {
http.Error(w, err.Error(), http.StatusBadRequest)
return
}
// An exclude asks for a key to be kept, so both readings of one are
// honoured rather than picking a side and purging what it named.
excludes := make([]string, 0, len(raw["exclude"])+len(query["exclude"]))
excludes = append(excludes, raw["exclude"]...)
for _, exclude := range query["exclude"] {
if !slices.Contains(excludes, exclude) {
excludes = append(excludes, exclude)
}
}
// Purge cache.
purge := PurgeRequest{
CachePath: cachePath,
Key: key,
ExcludeKeys: excludes,
Exact: exact,
}
purged, err := app.PurgeCache(purge)
// Nothing matched the key as it was sent, so try it decoded before giving
// up: that is the same key for all but the callers that escaped it.
if err == nil && purged == 0 {
if decoded := query.Get("key"); decoded != "" && decoded != key {
purge.Key = decoded
var decodedPurged int
decodedPurged, err = app.PurgeCache(purge)
if decodedPurged > 0 {
key = decoded
purged = decodedPurged
}
}
}
// If error, return error. The purge is this server's own work, so a
// failure is ours to report rather than a bad gateway upstream.
if err != nil {
log.Println("Error purging cache:", err)
http.Error(w, "Error occurred while processing purge.", http.StatusInternalServerError)
return
}
// Successful purge.
log.Printf("Purged %d cache entries matching %s.\n", purged, key)
w.Write([]byte("PURGED"))
}
// Bind the UNIX socket, replacing a socket left behind by an earlier run.
func (a *ServerCmd) listen(unixSocket string) (net.Listener, error) {
// Read the mode before binding, so an unusable one is reported without
// having taken the socket path over first.
mode, err := a.socketMode()
if err != nil {
return nil, err
}
// The socket directory may not exist yet, and net.Listen will not create
// it. Under systemd RuntimeDirectory this is already there.
if err := os.MkdirAll(filepath.Dir(unixSocket), 0o755); err != nil {
return nil, fmt.Errorf("unable to create socket directory: %s", err)
}
// A socket file from a previous run has to go before we can bind, but
// removing one that another instance is still serving would silently take
// over its requests. Connecting tells the two apart: a refused connection
// means nothing is listening.
info, err := os.Lstat(unixSocket)
switch {
case err == nil && info.Mode()&os.ModeSocket == 0:
return nil, fmt.Errorf("%s exists and is not a socket", unixSocket)
case err == nil:
conn, dialErr := net.DialTimeout("unix", unixSocket, time.Second)
if dialErr == nil {
conn.Close()
return nil, fmt.Errorf("%s is already in use by another instance", unixSocket)
}
if err := os.Remove(unixSocket); err != nil && !os.IsNotExist(err) {
return nil, fmt.Errorf("unable to remove stale socket: %s", err)
}
case !os.IsNotExist(err):
return nil, fmt.Errorf("unable to check socket path: %s", err)
}
listener, err := net.Listen("unix", unixSocket)
if err != nil {
return nil, err
}
// Bind leaves the socket at whatever the umask allows, so set the mode
// rather than let the environment we were started from decide who can
// reach the purge.
if err := os.Chmod(unixSocket, mode); err != nil {
listener.Close()
return nil, fmt.Errorf("unable to set socket permissions: %s", err)
}
return listener, nil
}
// Start the HTTP server.
func (a *ServerCmd) Run() error {
// Determine UNIX socket path.
unixSocket := a.Socket
if unixSocket == "" {
unixSocket = defaultSocketPath
}
listener, err := a.listen(unixSocket)
if err != nil {
return err
}
defer listener.Close()
// Start the HTTP server. Use our own mux rather than the global default
// one so the handler registration is scoped to this server.
log.Println("Starting server at", unixSocket)
mux := http.NewServeMux()
mux.HandleFunc("/", a.ServeHTTP)
server := &http.Server{
Handler: mux,
ReadHeaderTimeout: 10 * time.Second,
}
// Shut down on signal instead of dying where we stand, so the listener
// gets closed and the socket file is unlinked for the next run. No write
// timeout is set on the server: purging a large cache walks every entry,
// and a deadline would cut the response off mid-purge.
stop := make(chan os.Signal, 1)
signal.Notify(stop, os.Interrupt, syscall.SIGTERM)
defer signal.Stop(stop)
serveErr := make(chan error, 1)
go func() {
serveErr <- server.Serve(listener)
}()
// Attach to the service manager when not run interactively, so a stop
// request arrives on stopChan, then report readiness now that the socket
// is bound and requests can be served.
if !service.Interactive() {
svc, err := new(ServiceCmd).service()
if err != nil {
return err
}
go svc.Run()
}
_, _ = daemon.SdNotify(false, daemon.SdNotifyReady)
select {
case err := <-serveErr:
return err
case <-stop:
case <-stopChan:
}
log.Println("Shutting down server.")
ctx, cancel := context.WithTimeout(context.Background(), 10*time.Second)
defer cancel()
if err := server.Shutdown(ctx); err != nil {
return err
}
// Serve always ends with an error; a closed server is the expected one.
if err := <-serveErr; !errors.Is(err, http.ErrServerClosed) {
return err
}
return nil
}