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.
360 lines
12 KiB
Go
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
|
|
}
|