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.
This commit is contained in:
parent
44cd195109
commit
8d9b1c9302
19 changed files with 1769 additions and 259 deletions
10
.github/workflows/release.yaml
vendored
10
.github/workflows/release.yaml
vendored
|
|
@ -12,18 +12,20 @@ jobs:
|
|||
steps:
|
||||
-
|
||||
name: Checkout
|
||||
uses: actions/checkout@v4
|
||||
uses: actions/checkout@v7
|
||||
with:
|
||||
fetch-depth: 0
|
||||
-
|
||||
name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version-file: 'go.mod'
|
||||
-
|
||||
name: Run GoReleaser
|
||||
uses: goreleaser/goreleaser-action@v5
|
||||
uses: goreleaser/goreleaser-action@v7
|
||||
with:
|
||||
distribution: goreleaser
|
||||
version: latest
|
||||
version: '~> v2'
|
||||
args: release --clean
|
||||
env:
|
||||
GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }}
|
||||
|
|
|
|||
12
.github/workflows/test_golang.yaml
vendored
12
.github/workflows/test_golang.yaml
vendored
|
|
@ -7,12 +7,18 @@ jobs:
|
|||
|
||||
runs-on: ubuntu-latest
|
||||
steps:
|
||||
- uses: actions/checkout@v3
|
||||
- uses: actions/checkout@v7
|
||||
|
||||
- name: Set up Go
|
||||
uses: actions/setup-go@v4
|
||||
uses: actions/setup-go@v7
|
||||
with:
|
||||
go-version: '1.21'
|
||||
go-version-file: 'go.mod'
|
||||
|
||||
- name: Build
|
||||
run: go build -v ./...
|
||||
|
||||
- name: Vet
|
||||
run: go vet ./...
|
||||
|
||||
- name: Test
|
||||
run: go test -v ./...
|
||||
|
|
|
|||
|
|
@ -1,30 +1,74 @@
|
|||
# This is an example .goreleaser.yml file with some sensible defaults.
|
||||
# Make sure to check the documentation at https://goreleaser.com
|
||||
# GoReleaser config for nginx-cache-purge.
|
||||
# https://goreleaser.com
|
||||
#
|
||||
# CGO is disabled so the binary is fully static (no glibc dependency) and runs
|
||||
# unmodified across modern Linux distributions.
|
||||
version: 2
|
||||
|
||||
# The lines below are called `modelines`. See `:help modeline`
|
||||
# Feel free to remove those if you don't want/need to use them.
|
||||
# yaml-language-server: $schema=https://goreleaser.com/static/schema.json
|
||||
# vim: set ts=2 sw=2 tw=0 fo=cnqoj
|
||||
|
||||
version: 1
|
||||
project_name: nginx-cache-purge
|
||||
|
||||
before:
|
||||
hooks:
|
||||
# You may remove this if you don't use go modules.
|
||||
- go mod tidy
|
||||
# you may remove this if you don't need go generate
|
||||
- go generate ./...
|
||||
- go test ./...
|
||||
|
||||
builds:
|
||||
- env:
|
||||
- id: nginx-cache-purge
|
||||
main: .
|
||||
binary: nginx-cache-purge
|
||||
env:
|
||||
- CGO_ENABLED=0
|
||||
flags:
|
||||
- -trimpath
|
||||
# The build identifiers live in package main, so they are stamped there
|
||||
# rather than in an imported configuration package.
|
||||
ldflags:
|
||||
- -s -w
|
||||
- -X main.Version={{ .Version }}
|
||||
- -X main.Commit={{ .ShortCommit }}
|
||||
- -X main.Date={{ .Date }}
|
||||
- -X main.Mode=release
|
||||
goos:
|
||||
- linux
|
||||
- darwin
|
||||
goarch:
|
||||
- "386"
|
||||
- amd64
|
||||
- arm
|
||||
- arm64
|
||||
- ppc64le
|
||||
goarm:
|
||||
- "6"
|
||||
ignore:
|
||||
- goos: darwin
|
||||
goarch: "386"
|
||||
- goos: darwin
|
||||
goarch: arm
|
||||
- goos: darwin
|
||||
goarch: ppc64le
|
||||
|
||||
archives:
|
||||
- format: tar.gz
|
||||
# this name template makes the OS and Arch compatible with the results of `uname`.
|
||||
name_template: "{{ .ProjectName }}-{{ .Version }}.{{ .Os }}-{{ .Arch }}"
|
||||
- id: default
|
||||
formats: [tar.gz]
|
||||
# Kept compatible with the results of uname, matching the naming earlier
|
||||
# releases were published under.
|
||||
name_template: "{{ .ProjectName }}-{{ .Version }}.{{ .Os }}-{{ .Arch }}{{ if .Arm }}v{{ .Arm }}{{ end }}"
|
||||
wrap_in_directory: true
|
||||
strip_parent_binary_folder: false
|
||||
files:
|
||||
- README.md
|
||||
- LICENSE.txt
|
||||
|
||||
checksum:
|
||||
name_template: "checksums.txt"
|
||||
|
||||
snapshot:
|
||||
version_template: "{{ incpatch .Version }}-snapshot"
|
||||
|
||||
changelog:
|
||||
use: git
|
||||
sort: asc
|
||||
filters:
|
||||
exclude:
|
||||
- "^docs:"
|
||||
- "^test:"
|
||||
- "^chore:"
|
||||
|
|
|
|||
|
|
@ -1,4 +1,4 @@
|
|||
Copyright (c) 2023 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
|
||||
Copyright (c) 2023-2026 Mr. Gecko's Media (James Coleman). http://mrgeckosmedia.com/
|
||||
|
||||
Permission is hereby granted, free of charge, to any person obtaining a copy
|
||||
of this software and associated documentation files (the "Software"), to deal
|
||||
|
|
|
|||
39
Makefile
Normal file
39
Makefile
Normal file
|
|
@ -0,0 +1,39 @@
|
|||
BINARY := nginx-cache-purge
|
||||
# The build identifiers live in package main, so ldflags target it directly
|
||||
# rather than an imported configuration package.
|
||||
PACKAGE := main
|
||||
# VERSION is the single source of truth for the version string. COMMIT and DATE
|
||||
# are derived from git and the build clock.
|
||||
VERSION ?= $(shell cat VERSION 2>/dev/null || echo dev)
|
||||
COMMIT := $(shell git rev-parse --short HEAD 2>/dev/null)
|
||||
DATE := $(shell date -u '+%Y-%m-%dT%H:%M:%SZ')
|
||||
LDFLAGS := -s -w -X $(PACKAGE).Version=$(VERSION) -X $(PACKAGE).Commit=$(COMMIT) -X $(PACKAGE).Date=$(DATE)
|
||||
|
||||
.PHONY: all build test vet fmt snapshot release clean
|
||||
|
||||
all: build
|
||||
|
||||
## build: native static binary into dist/
|
||||
build:
|
||||
CGO_ENABLED=0 go build -trimpath -ldflags '$(LDFLAGS)' -o dist/$(BINARY) .
|
||||
|
||||
## test: run the unit tests
|
||||
test:
|
||||
go test ./...
|
||||
|
||||
vet:
|
||||
go vet ./...
|
||||
|
||||
fmt:
|
||||
gofmt -w .
|
||||
|
||||
## snapshot: local GoReleaser build without publishing (artifacts in dist/)
|
||||
snapshot:
|
||||
goreleaser release --snapshot --clean
|
||||
|
||||
## release: full GoReleaser release (CI runs this on a tag)
|
||||
release:
|
||||
goreleaser release --clean
|
||||
|
||||
clean:
|
||||
rm -rf dist
|
||||
162
README.md
162
README.md
|
|
@ -6,8 +6,9 @@ You can install either by downloading the latest binary release, or by building.
|
|||
|
||||
## Building
|
||||
Building should be as simple as running:
|
||||
|
||||
```
|
||||
go build
|
||||
make
|
||||
```
|
||||
|
||||
## Usage
|
||||
|
|
@ -33,34 +34,100 @@ $ nginx-cache-purge purge /var/nginx/proxy_temp/cache 'example.com/*.{jpg,jpeg,p
|
|||
$ nginx-cache-purge purge /var/nginx/proxy_temp/cache '*'
|
||||
```
|
||||
|
||||
## Running as a service
|
||||
If you want to run as a service to allow purge requests via http requests, you'll need to create a systemd service file and place it in `/etc/systemd/system/nginx-cache-purge.service`.
|
||||
### Purge a key that contains wildcard characters
|
||||
Whether a key is a wildcard is otherwise guessed from the punctuation in it, and
|
||||
real cache keys carry that punctuation: a request URI with a query string puts
|
||||
`?` in the key, and PHP-style array parameters put `[` and `]`. `--exact` says
|
||||
the key is a literal, for both the key and any excludes.
|
||||
|
||||
```
|
||||
[Unit]
|
||||
Description=Nginx Cache Purge
|
||||
After=network.target
|
||||
|
||||
[Service]
|
||||
User=nginx
|
||||
Group=nginx
|
||||
RuntimeDirectory=nginx-cache-purge
|
||||
ExecStart=/usr/local/bin/nginx-cache-purge server
|
||||
Restart=always
|
||||
RestartSec=3s
|
||||
|
||||
[Install]
|
||||
WantedBy=multi-user.target
|
||||
$ nginx-cache-purge purge --exact /var/nginx/proxy_temp/cache 'example.com/list.php?f[]=x'
|
||||
```
|
||||
|
||||
You can then run the following to start the service:
|
||||
## Running as a service
|
||||
If you want to run as a service to allow purge requests via http requests, the
|
||||
service can install itself:
|
||||
|
||||
```
|
||||
systemctl daemon-reload
|
||||
systemctl start nginx-cache-purge.service
|
||||
nginx-cache-purge service install --cache-path /var/nginx/proxy_temp/cache
|
||||
nginx-cache-purge service start
|
||||
```
|
||||
|
||||
`service` also accepts `stop`, `restart`, `status`, and `uninstall`. The
|
||||
installed unit runs `nginx-cache-purge server` as a notify service, creates the
|
||||
runtime directory the socket lives in, and restarts on failure. `--cache-path`
|
||||
restricts which caches that server will purge, and is covered below.
|
||||
|
||||
Connecting to a UNIX socket needs write permission on it, so the socket is
|
||||
created mode `0660`: the purge server and Nginx have to run as the same user, or
|
||||
share a group. The installed unit runs as root, so either add a
|
||||
`User=`/`Group=` drop-in for it, or widen the socket with
|
||||
`--socket-mode`.
|
||||
|
||||
### Restricting which caches may be purged
|
||||
The directory to purge comes from the request, and the purge deletes what it
|
||||
finds under it, so a server left unrestricted will purge any path its user can
|
||||
reach. Pass `--cache-path` to name the caches it may serve, repeating it for
|
||||
more than one:
|
||||
|
||||
```
|
||||
nginx-cache-purge server --cache-path /var/nginx/proxy_temp/cache
|
||||
```
|
||||
|
||||
A request naming any other directory is refused with `403`. Directories inside a
|
||||
named cache are allowed, and symlinks are resolved, so a link to a named cache is
|
||||
recognised as that cache. Naming no path leaves every path purgeable, which is
|
||||
what a server without the flag has always done.
|
||||
|
||||
`service install` takes the same flag and writes it into the unit's `ExecStart`,
|
||||
so an installed service is restricted from its first start:
|
||||
|
||||
```
|
||||
nginx-cache-purge service install --cache-path /var/nginx/proxy_temp/cache
|
||||
```
|
||||
|
||||
Repeat the flag for more than one cache. Paths are made absolute at install, as
|
||||
the unit runs from a working directory of the service manager's choosing;
|
||||
symlinks are left as written and resolved per request. The allowlist lives in the
|
||||
unit, so changing it means installing again: `service uninstall` then
|
||||
`service install` with the paths you want.
|
||||
|
||||
## Nginx config
|
||||
If you want to purge via Nginx http requests, you'll need to add configuration to your Nginx config file.
|
||||
|
||||
The server reads four query parameters:
|
||||
|
||||
| Parameter | Description |
|
||||
| --- | --- |
|
||||
| `path` | Path to the cache directory, the same one given to `proxy_cache_path`. |
|
||||
| `key` | Cache key or wildcard match, the same one built by `proxy_cache_key`. |
|
||||
| `exclude` | Key to keep, can be a wildcard. Repeat it to exclude more than one. |
|
||||
| `exact` | Read the key and excludes as literals rather than wildcards. |
|
||||
|
||||
Nginx substitutes `$request_uri` into the rewrite without escaping it, so a key
|
||||
containing `%` or `+` arrives as the literal bytes Nginx stored it under, and
|
||||
that is what is purged. A key escaped by a caller writing the request itself is
|
||||
purged too, so either convention works.
|
||||
|
||||
### Literal keys and wildcards
|
||||
A key built from `$request_uri` is a literal, and a request URI routinely holds
|
||||
the punctuation that would otherwise mark the key as a wildcard: `?` from a query
|
||||
string, `[` and `]` from PHP-style array parameters. Read as wildcards those keys
|
||||
purge the wrong entries, fail outright, or match nothing while still answering
|
||||
`PURGED` and leaving the stale entry served. The examples below therefore pass
|
||||
`exact=1`, which is what you want when a PURGE request names one URL.
|
||||
|
||||
Leave `exact` off where the purge is meant to take a wildcard, such as the
|
||||
`/purge(/.*)` location further down, where a request for `/purge/images/*`
|
||||
clears everything under `/images/`.
|
||||
|
||||
Because `$request_uri` carries the client's own query string into the purge
|
||||
parameters, put `exact=1` **before** `key=` in the rewrite. Parameters are taken
|
||||
first-wins, so an `exact=0` a client appends to its request URI arrives second
|
||||
and is ignored. The same ordering already protects `path`. Note that `exclude` is
|
||||
collected rather than taken first-wins, so a client can append an `exclude` that
|
||||
holds back its own purge.
|
||||
|
||||
### Map PURGE requests
|
||||
```
|
||||
http {
|
||||
|
|
@ -77,7 +144,7 @@ http {
|
|||
proxy_cache_bypass $is_purge;
|
||||
if ($is_purge) {
|
||||
proxy_pass http://unix:/run/nginx-cache-purge/http.sock;
|
||||
rewrite ^ /?path=/var/nginx/proxy_temp/cache&key=$server_name$request_uri break;
|
||||
rewrite ^ /?path=/var/nginx/proxy_temp/cache&exact=1&key=$server_name$request_uri break;
|
||||
}
|
||||
|
||||
proxy_cache my_cache;
|
||||
|
|
@ -103,7 +170,7 @@ http {
|
|||
proxy_cache_bypass $is_purge;
|
||||
if ($is_purge) {
|
||||
proxy_pass http://unix:/run/nginx-cache-purge/http.sock;
|
||||
rewrite ^ /?path=/var/nginx/proxy_temp/cache&key=$server_name$request_uri break;
|
||||
rewrite ^ /?path=/var/nginx/proxy_temp/cache&exact=1&key=$server_name$request_uri break;
|
||||
}
|
||||
|
||||
proxy_cache my_cache;
|
||||
|
|
@ -129,7 +196,7 @@ http {
|
|||
proxy_cache_bypass $is_purge;
|
||||
if ($is_purge) {
|
||||
proxy_pass http://unix:/run/nginx-cache-purge/http.sock;
|
||||
rewrite ^ /?path=/var/nginx/proxy_temp/cache&key=$server_name$request_uri break;
|
||||
rewrite ^ /?path=/var/nginx/proxy_temp/cache&exact=1&key=$server_name$request_uri break;
|
||||
}
|
||||
|
||||
proxy_cache my_cache;
|
||||
|
|
@ -165,7 +232,7 @@ http {
|
|||
proxy_cache_bypass $should_purge;
|
||||
if ($should_purge) {
|
||||
proxy_pass http://unix:/run/nginx-cache-purge/http.sock;
|
||||
rewrite ^ /?path=/var/nginx/proxy_temp/cache&key=$server_name$request_uri break;
|
||||
rewrite ^ /?path=/var/nginx/proxy_temp/cache&exact=1&key=$server_name$request_uri break;
|
||||
}
|
||||
|
||||
proxy_cache my_cache;
|
||||
|
|
@ -176,6 +243,9 @@ http {
|
|||
```
|
||||
|
||||
### Using IP whitelists
|
||||
This location takes a wildcard, so it leaves `exact` off: a request for
|
||||
`/purge/images/*` clears everything under `/images/`.
|
||||
|
||||
```
|
||||
http {
|
||||
proxy_cache_path /var/nginx/proxy_temp/cache levels=1:2 keys_zone=my_cache:10m;
|
||||
|
|
@ -195,47 +265,3 @@ http {
|
|||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Help
|
||||
```
|
||||
$ nginx-cache-purge --help
|
||||
Usage: nginx-cache-purge <command> [flags]
|
||||
|
||||
Tool to help purge cache from Nginx
|
||||
|
||||
Flags:
|
||||
-h, --help Show context-sensitive help.
|
||||
--version Print version information and quit
|
||||
|
||||
Commands:
|
||||
server (s) Run the server
|
||||
purge (p) Purge cache now
|
||||
|
||||
Run "nginx-cache-purge <command> --help" for more information on a command.
|
||||
|
||||
$ nginx-cache-purge p --help
|
||||
Usage: nginx-cache-purge purge (p) <cache-path> <key> [flags]
|
||||
|
||||
Purge cache now
|
||||
|
||||
Arguments:
|
||||
<cache-path> Path to cache directory.
|
||||
<key> Cache key or wildcard match.
|
||||
|
||||
Flags:
|
||||
-h, --help Show context-sensitive help.
|
||||
--version Print version information and quit
|
||||
|
||||
--exclude-key=EXCLUDE-KEY,... Key to exclude, can be wild card and can add multiple excludes.
|
||||
|
||||
$ nginx-cache-purge s --help
|
||||
Usage: nginx-cache-purge server (s) [flags]
|
||||
|
||||
Run the server
|
||||
|
||||
Flags:
|
||||
-h, --help Show context-sensitive help.
|
||||
--version Print version information and quit
|
||||
|
||||
--socket=STRING Socket path for HTTP communication.
|
||||
``
|
||||
|
|
|
|||
1
VERSION
Normal file
1
VERSION
Normal file
|
|
@ -0,0 +1 @@
|
|||
0.2.0
|
||||
58
flags.go
58
flags.go
|
|
@ -2,17 +2,45 @@ package main
|
|||
|
||||
import (
|
||||
"fmt"
|
||||
"runtime/debug"
|
||||
"strings"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
)
|
||||
|
||||
// When version is requested, print the version.
|
||||
// VersionFlag prints build information and exits.
|
||||
type VersionFlag bool
|
||||
|
||||
// Decode satisfies kong.MapperValue. The flag is treated as a boolean toggle.
|
||||
func (v VersionFlag) Decode(ctx *kong.DecodeContext) error { return nil }
|
||||
func (v VersionFlag) IsBool() bool { return true }
|
||||
|
||||
// IsBool reports the flag as a boolean for kong's parser.
|
||||
func (v VersionFlag) IsBool() bool { return true }
|
||||
|
||||
// BeforeApply emits version information then exits before the rest of the
|
||||
// command is executed.
|
||||
func (v VersionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error {
|
||||
fmt.Println(serviceName + ": " + serviceVersion)
|
||||
fmt.Printf("%s: %s (%s)\n", Name, Version, Mode)
|
||||
if Commit != "" {
|
||||
fmt.Printf(" commit: %s\n", Commit)
|
||||
}
|
||||
if Date != "" {
|
||||
fmt.Printf(" built: %s\n", Date)
|
||||
}
|
||||
// Without build stamps, a module-aware build still records the revision
|
||||
// it was built from, so fall back to what the toolchain embedded.
|
||||
if Commit == "" {
|
||||
if bi, ok := debug.ReadBuildInfo(); ok {
|
||||
for _, s := range bi.Settings {
|
||||
switch s.Key {
|
||||
case "vcs.revision":
|
||||
fmt.Printf(" commit: %s\n", s.Value)
|
||||
case "vcs.time":
|
||||
fmt.Printf(" built: %s\n", s.Value)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
app.Exit(0)
|
||||
return nil
|
||||
}
|
||||
|
|
@ -21,21 +49,33 @@ func (v VersionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error {
|
|||
type Flags struct {
|
||||
Version VersionFlag `name:"version" help:"Print version information and quit"`
|
||||
|
||||
Server ServerCmd `cmd:"" aliases:"s" default:"1" help:"Run the server"`
|
||||
Purge PurgeCmd `cmd:"" aliases:"p" help:"Purge cache now"`
|
||||
Server ServerCmd `cmd:"" aliases:"s" default:"1" help:"Run the server"`
|
||||
Purge PurgeCmd `cmd:"" aliases:"p" help:"Purge cache now"`
|
||||
Service ServiceCmd `cmd:"" help:"Manage the purge server system service."`
|
||||
}
|
||||
|
||||
// kongVars are the values the command tags interpolate. They live in one place
|
||||
// so that a parser built anywhere describes the same command line.
|
||||
func kongVars() kong.Vars {
|
||||
return kong.Vars{
|
||||
"serviceActions": strings.Join(ServiceAction, ","),
|
||||
"defaultSocket": defaultSocketPath,
|
||||
"defaultMode": defaultSocketMode,
|
||||
}
|
||||
}
|
||||
|
||||
// Parse the supplied flags and commands.
|
||||
func (a *App) ParseFlags() *kong.Context {
|
||||
app.flags = &Flags{}
|
||||
a.flags = &Flags{}
|
||||
|
||||
ctx := kong.Parse(app.flags,
|
||||
kong.Name(serviceName),
|
||||
kong.Description(serviceDescription),
|
||||
ctx := kong.Parse(a.flags,
|
||||
kong.Name(Name),
|
||||
kong.Description(Description),
|
||||
kong.UsageOnError(),
|
||||
kong.ConfigureHelp(kong.HelpOptions{
|
||||
Compact: true,
|
||||
}),
|
||||
kongVars(),
|
||||
)
|
||||
return ctx
|
||||
}
|
||||
|
|
|
|||
15
go.mod
15
go.mod
|
|
@ -1,11 +1,18 @@
|
|||
module github.com/grmrgecko/nginx-cache-purge
|
||||
|
||||
go 1.22.5
|
||||
go 1.25.0
|
||||
|
||||
require (
|
||||
github.com/alecthomas/kong v0.9.0
|
||||
github.com/alecthomas/kong v1.16.1
|
||||
github.com/coreos/go-systemd/v22 v22.7.0
|
||||
github.com/gobwas/glob v0.2.3
|
||||
github.com/portmapping/go-reuse v0.0.3
|
||||
github.com/kardianos/service v1.3.0
|
||||
github.com/stretchr/testify v1.11.1
|
||||
)
|
||||
|
||||
require golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3 // indirect
|
||||
require (
|
||||
github.com/davecgh/go-spew v1.1.1 // indirect
|
||||
github.com/pmezard/go-difflib v1.0.0 // indirect
|
||||
golang.org/x/sys v0.47.0 // indirect
|
||||
gopkg.in/yaml.v3 v3.0.1 // indirect
|
||||
)
|
||||
|
|
|
|||
26
go.sum
26
go.sum
|
|
@ -1,14 +1,32 @@
|
|||
github.com/alecthomas/assert/v2 v2.6.0 h1:o3WJwILtexrEUk3cUVal3oiQY2tfgr/FHWiz/v2n4FU=
|
||||
github.com/alecthomas/assert/v2 v2.6.0/go.mod h1:Bze95FyfUr7x34QZrjL+XP+0qgp/zg8yS+TtBj1WA3k=
|
||||
github.com/alecthomas/assert/v2 v2.11.0 h1:2Q9r3ki8+JYXvGsDyBXwH3LcJ+WK5D0gc5E8vS6K3D0=
|
||||
github.com/alecthomas/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA=
|
||||
github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os=
|
||||
github.com/alecthomas/kong v1.16.1 h1:ixhCt93XkJ98kGposQ54+bl0IK6XwqB40AsMynU7Z8E=
|
||||
github.com/alecthomas/kong v1.16.1/go.mod h1:wrlbXem1CWqUV5Vbmss5ISYhsVPkBb1Yo7YKJghju2I=
|
||||
github.com/alecthomas/repr v0.4.0 h1:GhI2A8MACjfegCPVq9f1FLvIBS+DrQ2KQBFZP1iFzXc=
|
||||
github.com/alecthomas/repr v0.4.0/go.mod h1:Fr0507jx4eOXV7AlPV6AVZLYrLIuIeSOWtW57eE/O/4=
|
||||
github.com/alecthomas/repr v0.5.2 h1:SU73FTI9D1P5UNtvseffFSGmdNci/O6RsqzeXJtP0Qs=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0 h1:LAEzFkke61DFROc7zNLX/WA2i5J8gYqe0rSj9KI28KA=
|
||||
github.com/coreos/go-systemd/v22 v22.7.0/go.mod h1:xNUYtjHu2EDXbsxz1i41wouACIwT7Ybq9o0BQhMwD0w=
|
||||
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
|
||||
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
|
||||
github.com/gobwas/glob v0.2.3 h1:A4xDbljILXROh+kObIiy5kIaPYD8e96x1tgBhUI5J+Y=
|
||||
github.com/gobwas/glob v0.2.3/go.mod h1:d3Ez4x06l9bZtSvzIay5+Yzi0fmZzPgnTbPcKjJAkT8=
|
||||
github.com/hexops/gotextdiff v1.0.3 h1:gitA9+qJrrTCsiCl7+kh75nPqQt1cx4ZkudSTLoUqJM=
|
||||
github.com/hexops/gotextdiff v1.0.3/go.mod h1:pSWU5MAI3yDq+fZBTazCSJysOMbxWL1BSow5/V2vxeg=
|
||||
github.com/portmapping/go-reuse v0.0.3 h1:iY0JDxTTUaYopewHL0CLN5BqJ0BvDP48VzC2osPpkBQ=
|
||||
github.com/portmapping/go-reuse v0.0.3/go.mod h1:xKeiOLrJpAUOineqiMEm1bpy6cq0vTdpoiebdRD45mo=
|
||||
golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3 h1:5B6i6EAiSYyejWfvc5Rc9BbI3rzIsrrXfAQBWnYfn+w=
|
||||
golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3/go.mod h1:h1NjWce9XRLGQEsW7wpKNCjG9DtNlClVuFLEZdDNbEs=
|
||||
github.com/kardianos/service v1.3.0 h1:/LGy+xPP2TM+GLTiCZ2di7cy0Jd/qrawlTUfqKYFdTI=
|
||||
github.com/kardianos/service v1.3.0/go.mod h1:E4V9ufUuY82F7Ztlu1eN9VXWIQxg8NoLQlmFe0MtrXc=
|
||||
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
|
||||
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
|
||||
github.com/stretchr/testify v1.11.1 h1:7s2iGBzp5EwR7/aIZr8ao5+dra3wiQyKjjFuvgVKu7U=
|
||||
github.com/stretchr/testify v1.11.1/go.mod h1:wZwfW3scLgRK+23gO65QZefKpKQRnfz6sD981Nm4B6U=
|
||||
golang.org/x/sys v0.34.0 h1:H5Y5sJ2L2JRdyv7ROF1he/lPdvFsd0mJHFw2ThKHxLA=
|
||||
golang.org/x/sys v0.34.0/go.mod h1:BJP2sWEmIv4KK5OTEluFJCKSidICx8ciO85XgH3Ak8k=
|
||||
golang.org/x/sys v0.47.0 h1:o7XGOvZQCADBQQ4Y7VNq2dRWQR7JmOUW8Kxx4ZsNgWs=
|
||||
golang.org/x/sys v0.47.0/go.mod h1:4GL1E5IUh+htKOUEOaiffhrAeqysfVGipDYzABqnCmw=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405 h1:yhCVgyC4o1eVCa2tZl7eS0r+SDo693bJlVdllGtEeKM=
|
||||
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
|
||||
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
|
||||
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
|
||||
|
|
|
|||
17
info.go
Normal file
17
info.go
Normal file
|
|
@ -0,0 +1,17 @@
|
|||
package main
|
||||
|
||||
// Build identifiers populated at build time via -ldflags. A plain go build
|
||||
// leaves them at their defaults, so the binary reports itself as a dev build.
|
||||
var (
|
||||
Version = "dev"
|
||||
Commit = ""
|
||||
Date = ""
|
||||
Mode = "dev"
|
||||
)
|
||||
|
||||
// Application identifiers used by the CLI and the system service.
|
||||
const (
|
||||
Name = "nginx-cache-purge"
|
||||
DisplayName = "Nginx Cache Purge"
|
||||
Description = "Tool to help purge Nginx cache"
|
||||
)
|
||||
216
main.go
216
main.go
|
|
@ -4,7 +4,10 @@ import (
|
|||
"bufio"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"errors"
|
||||
"fmt"
|
||||
"io"
|
||||
"io/fs"
|
||||
"log"
|
||||
"os"
|
||||
"path/filepath"
|
||||
|
|
@ -14,12 +17,9 @@ import (
|
|||
"github.com/gobwas/glob"
|
||||
)
|
||||
|
||||
// Basic application info.
|
||||
const (
|
||||
serviceName = "nginx-cache-purge"
|
||||
serviceDescription = "Tool to help purge Nginx cache "
|
||||
serviceVersion = "0.1.4"
|
||||
)
|
||||
// The KEY line lives in the cache entry header, within the first few hundred
|
||||
// bytes. This bounds how far we read looking for it.
|
||||
const maxHeaderScan = 64 * 1024
|
||||
|
||||
// App structure to access global app variables.
|
||||
type App struct {
|
||||
|
|
@ -28,26 +28,69 @@ type App struct {
|
|||
|
||||
var app *App
|
||||
|
||||
// Function to purge nginx cache keys.
|
||||
func (a *App) PurgeCache(CachePath string, Key string, ExcludeKeys []string) error {
|
||||
// Regex to determine if a key is a glob pattern. Compiled once, as the server
|
||||
// purges on every request.
|
||||
var globRegex = regexp.MustCompile(`[\*?\[{]+`)
|
||||
|
||||
// PurgeRequest describes one purge. The fields travel together through the CLI
|
||||
// and the server, so they are grouped rather than passed as a widening list of
|
||||
// arguments.
|
||||
type PurgeRequest struct {
|
||||
// CachePath is the directory to purge from, the same one given to
|
||||
// proxy_cache_path.
|
||||
CachePath string
|
||||
// Key is the cache key to purge, read as a wildcard pattern unless Exact
|
||||
// says otherwise.
|
||||
Key string
|
||||
// ExcludeKeys name keys to keep, read the same way as Key.
|
||||
ExcludeKeys []string
|
||||
// Exact turns pattern matching off, for both the key and the excludes.
|
||||
// Whether a key is a pattern is otherwise guessed from the punctuation in
|
||||
// it, and real cache keys carry that punctuation: a request URI with a
|
||||
// query string puts ? in the key, PHP-style array parameters put [ and ],
|
||||
// and either one is read as a pattern that was never meant. Exact is how a
|
||||
// caller that knows it holds a literal key says so.
|
||||
Exact bool
|
||||
}
|
||||
|
||||
// Function to purge nginx cache keys. It reports how many entries were removed,
|
||||
// which is what tells a purge that cleared the cache from one that matched
|
||||
// nothing at all.
|
||||
func (a *App) PurgeCache(req PurgeRequest) (int, error) {
|
||||
// Key must be provided.
|
||||
if len(Key) == 0 {
|
||||
return fmt.Errorf("no key provided")
|
||||
if len(req.Key) == 0 {
|
||||
return 0, fmt.Errorf("no key provided")
|
||||
}
|
||||
|
||||
// Regex to determine if key is a glob pattern.
|
||||
globRegex := regexp.MustCompile(`[\*?\[{]+`)
|
||||
// Compile the exclude patterns up front. Doing it per key would repeat
|
||||
// the work for every file in the cache, and a pattern that fails to
|
||||
// compile has to be fatal: ignoring it would purge the very keys the
|
||||
// caller asked to keep. An exact purge has no patterns to compile, so the
|
||||
// excludes stay literal and one holding glob punctuation keeps its key
|
||||
// rather than failing the purge it appeared in.
|
||||
var excludeGlobs []glob.Glob
|
||||
if !req.Exact {
|
||||
for _, exclude := range req.ExcludeKeys {
|
||||
if !globRegex.MatchString(exclude) {
|
||||
continue
|
||||
}
|
||||
g, err := glob.Compile(exclude)
|
||||
if err != nil {
|
||||
return 0, fmt.Errorf("error while compiling exclude glob %q: %s", exclude, err)
|
||||
}
|
||||
excludeGlobs = append(excludeGlobs, g)
|
||||
}
|
||||
}
|
||||
|
||||
// Inline function to check if excludes contains a key.
|
||||
keyIsExcluded := func(Key string) bool {
|
||||
for _, exclude := range ExcludeKeys {
|
||||
if globRegex.MatchString(exclude) {
|
||||
g, err := glob.Compile(exclude)
|
||||
if err == nil && g != nil && g.Match(Key) {
|
||||
return true
|
||||
}
|
||||
keyIsExcluded := func(key string) bool {
|
||||
for _, g := range excludeGlobs {
|
||||
if g.Match(key) {
|
||||
return true
|
||||
}
|
||||
if exclude == Key {
|
||||
}
|
||||
for _, exclude := range req.ExcludeKeys {
|
||||
if exclude == key {
|
||||
return true
|
||||
}
|
||||
}
|
||||
|
|
@ -55,101 +98,150 @@ func (a *App) PurgeCache(CachePath string, Key string, ExcludeKeys []string) err
|
|||
}
|
||||
|
||||
// Confirm that the cache path exists.
|
||||
if _, err := os.Stat(CachePath); err != nil {
|
||||
return fmt.Errorf("cache directory error: %s", err)
|
||||
if _, err := os.Stat(req.CachePath); err != nil {
|
||||
return 0, fmt.Errorf("cache directory error: %s", err)
|
||||
}
|
||||
|
||||
// Check if the key is a wildcard. If its not, we should purge the key by hash.
|
||||
if !globRegex.MatchString(Key) {
|
||||
// Count of entries actually removed, reported to the caller.
|
||||
purged := 0
|
||||
|
||||
// Check if the key is a wildcard. If its not, we should purge the key by
|
||||
// hash, which is also the only thing an exact purge does.
|
||||
if req.Exact || !globRegex.MatchString(req.Key) {
|
||||
// If excluded, skip the key.
|
||||
if keyIsExcluded(Key) {
|
||||
log.Println("Key", Key, "is excluded, will not purge.")
|
||||
return nil
|
||||
if keyIsExcluded(req.Key) {
|
||||
log.Println("Key", req.Key, "is excluded, will not purge.")
|
||||
return 0, nil
|
||||
}
|
||||
|
||||
// Get the hash of the key.
|
||||
hash := md5.Sum([]byte(Key))
|
||||
hash := md5.Sum([]byte(req.Key))
|
||||
keyHash := hex.EncodeToString(hash[:])
|
||||
|
||||
// Find key in cache directory.
|
||||
err := filepath.Walk(CachePath, func(filePath string, info os.FileInfo, err error) error {
|
||||
// Do not tolerate errors.
|
||||
// Find key in cache directory. The walk reads directory entries rather
|
||||
// than calling Lstat on each one, as the name is all this branch
|
||||
// compares against and a cache holds a great many files.
|
||||
err := filepath.WalkDir(req.CachePath, func(filePath string, entry fs.DirEntry, err error) error {
|
||||
// Do not tolerate errors, other than an entry going away while
|
||||
// we walk. Nginx maintains the cache as we read it, so entries
|
||||
// disappearing mid-walk is expected rather than a failure.
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// We only care to look at files.
|
||||
if info.IsDir() {
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
// If this file matches our key hash then delete.
|
||||
if info.Name() == keyHash {
|
||||
log.Printf("Purging %s as it matches the key %s requested to be purged.\n", filePath, Key)
|
||||
if entry.Name() == keyHash {
|
||||
log.Printf("Purging %s as it matches the key %s requested to be purged.\n", filePath, req.Key)
|
||||
err := os.Remove(filePath)
|
||||
if err != nil {
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
purged++
|
||||
}
|
||||
// We're done, so lets stop the walk.
|
||||
return filepath.SkipAll
|
||||
}
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while scanning for file to purge: %s", err)
|
||||
return purged, fmt.Errorf("error while scanning for file to purge: %s", err)
|
||||
}
|
||||
} else {
|
||||
// This is a wildcard, so we need to find all files that match it and delete them.
|
||||
g, err := glob.Compile(Key)
|
||||
g, err := glob.Compile(req.Key)
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while compiling glob: %s", err)
|
||||
return 0, fmt.Errorf("error while compiling glob: %s", err)
|
||||
}
|
||||
err = filepath.Walk(CachePath, func(filePath string, info os.FileInfo, err error) error {
|
||||
// Do not tolerate errors.
|
||||
err = filepath.WalkDir(req.CachePath, func(filePath string, entry fs.DirEntry, err error) error {
|
||||
// Do not tolerate errors, other than an entry going away while
|
||||
// we walk. Nginx maintains the cache as we read it, so entries
|
||||
// disappearing mid-walk is expected rather than a failure.
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
// We only care to look at files.
|
||||
if info.IsDir() {
|
||||
if entry.IsDir() {
|
||||
return nil
|
||||
}
|
||||
|
||||
// Read the file to extract the key.
|
||||
file, err := os.Open(filePath)
|
||||
if err != nil {
|
||||
if os.IsNotExist(err) {
|
||||
return nil
|
||||
}
|
||||
return err
|
||||
}
|
||||
defer file.Close()
|
||||
scanner := bufio.NewScanner(file)
|
||||
// Scan file for the key.
|
||||
keyRead := ""
|
||||
keyFound := false
|
||||
// Scan file for the key. There is exactly one KEY line per cache
|
||||
// entry, in the header, so stop at the first one found. Reading on
|
||||
// would scan the cached body for a line that cannot exist, which
|
||||
// is why the reader is capped at the header size rather than left
|
||||
// to run through gigabytes of cached response body.
|
||||
scanner := bufio.NewScanner(io.LimitReader(file, maxHeaderScan))
|
||||
for scanner.Scan() {
|
||||
line := scanner.Text()
|
||||
// If line is the key, check if it matches our glob pattern and delete.
|
||||
if strings.HasPrefix(line, "KEY: ") {
|
||||
keyRead := line[5:]
|
||||
if g.Match(keyRead) {
|
||||
// If excluded, skip the key.
|
||||
if keyIsExcluded(keyRead) {
|
||||
log.Println("Key", keyRead, "is excluded, will not purge.")
|
||||
return nil
|
||||
}
|
||||
keyRead = line[5:]
|
||||
keyFound = true
|
||||
break
|
||||
}
|
||||
}
|
||||
scanErr := scanner.Err()
|
||||
file.Close()
|
||||
|
||||
// Delete the file.
|
||||
log.Printf("Purging %s as it matches the key %s requested to be purged.\n", filePath, Key)
|
||||
err := os.Remove(filePath)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
break
|
||||
}
|
||||
// A line longer than the scan limit means no cache header here,
|
||||
// only binary, so this is not an entry we can match against. The
|
||||
// temporary files nginx writes alongside the cache look exactly
|
||||
// like this. Anything else is a real read error, which we surface
|
||||
// rather than silently leaving a matching key in the cache.
|
||||
if scanErr != nil && !errors.Is(scanErr, bufio.ErrTooLong) && !os.IsNotExist(scanErr) {
|
||||
return fmt.Errorf("error while reading %s: %s", filePath, scanErr)
|
||||
}
|
||||
|
||||
// Without a key, there is nothing to match against.
|
||||
if !keyFound {
|
||||
return nil
|
||||
}
|
||||
|
||||
// If the key matches our glob pattern, delete it.
|
||||
if g.Match(keyRead) {
|
||||
// If excluded, skip the key.
|
||||
if keyIsExcluded(keyRead) {
|
||||
log.Println("Key", keyRead, "is excluded, will not purge.")
|
||||
return nil
|
||||
}
|
||||
|
||||
// Delete the file. An entry nginx already evicted between the
|
||||
// walk and here is one less file to purge, not a failure.
|
||||
log.Printf("Purging %s with key %s as it matches %s requested to be purged.\n", filePath, keyRead, req.Key)
|
||||
err := os.Remove(filePath)
|
||||
if err != nil && !os.IsNotExist(err) {
|
||||
return err
|
||||
}
|
||||
if err == nil {
|
||||
purged++
|
||||
}
|
||||
}
|
||||
|
||||
return nil
|
||||
})
|
||||
if err != nil {
|
||||
return fmt.Errorf("error while scanning for file to purge: %s", err)
|
||||
return purged, fmt.Errorf("error while scanning for file to purge: %s", err)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
return purged, nil
|
||||
}
|
||||
|
||||
// Main function to start the app.
|
||||
|
|
|
|||
13
purgeCmd.go
13
purgeCmd.go
|
|
@ -1,13 +0,0 @@
|
|||
package main
|
||||
|
||||
// Purge command for CLI to purge cache keys.
|
||||
type PurgeCmd struct {
|
||||
CachePath string `arg:"" name:"cache-path" help:"Path to cache directory." type:"existingdir"`
|
||||
Key string `arg:"" name:"key" help:"Cache key or wildcard match."`
|
||||
ExcludeKeys []string `optional:"" name:"exclude-key" help:"Key to exclude, can be wild card and can add multiple excludes."`
|
||||
}
|
||||
|
||||
// The purge command execution just runs the apps purge cache function.
|
||||
func (a *PurgeCmd) Run() error {
|
||||
return app.PurgeCache(a.CachePath, a.Key, a.ExcludeKeys)
|
||||
}
|
||||
28
purge_cmd.go
Normal file
28
purge_cmd.go
Normal file
|
|
@ -0,0 +1,28 @@
|
|||
package main
|
||||
|
||||
import "log"
|
||||
|
||||
// Purge command for CLI to purge cache keys.
|
||||
type PurgeCmd struct {
|
||||
CachePath string `arg:"" name:"cache-path" help:"Path to cache directory." type:"existingdir"`
|
||||
Key string `arg:"" name:"key" help:"Cache key or wildcard match."`
|
||||
ExcludeKeys []string `optional:"" name:"exclude-key" help:"Key to exclude, can be wild card and can add multiple excludes."`
|
||||
Exact bool `optional:"" name:"exact" help:"Treat the key and excludes as literal keys rather than wildcard matches."`
|
||||
}
|
||||
|
||||
// The purge command execution just runs the apps purge cache function, then
|
||||
// says how much it removed. A key that matched nothing is not an error, so the
|
||||
// count is the only thing that distinguishes it from a purge that worked.
|
||||
func (a *PurgeCmd) Run() error {
|
||||
purged, err := app.PurgeCache(PurgeRequest{
|
||||
CachePath: a.CachePath,
|
||||
Key: a.Key,
|
||||
ExcludeKeys: a.ExcludeKeys,
|
||||
Exact: a.Exact,
|
||||
})
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
log.Printf("Purged %d cache entries matching %s.\n", purged, a.Key)
|
||||
return nil
|
||||
}
|
||||
75
serverCmd.go
75
serverCmd.go
|
|
@ -1,75 +0,0 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"fmt"
|
||||
"io"
|
||||
"log"
|
||||
"net"
|
||||
"net/http"
|
||||
"os"
|
||||
)
|
||||
|
||||
// The server command for the CLI to run the HTTP server.
|
||||
type ServerCmd struct {
|
||||
Socket string `help:"Socket path for HTTP communication." type:"path"`
|
||||
}
|
||||
|
||||
// Handle request.
|
||||
func (a *ServerCmd) ServeHTTP(w http.ResponseWriter, req *http.Request) {
|
||||
// Parse query parameters.
|
||||
query := req.URL.Query()
|
||||
cachePath := query.Get("path")
|
||||
if cachePath == "" {
|
||||
io.WriteString(w, "Need path parameter.")
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
key := query.Get("key")
|
||||
if key == "" {
|
||||
io.WriteString(w, "Need key parameter.")
|
||||
http.Error(w, http.StatusText(http.StatusBadRequest), http.StatusBadRequest)
|
||||
return
|
||||
}
|
||||
excludes := query["exclude"]
|
||||
|
||||
// Purge cache.
|
||||
err := app.PurgeCache(cachePath, key, excludes)
|
||||
// If error, return error.
|
||||
if err != nil {
|
||||
fmt.Println("Error purging cache:", err)
|
||||
io.WriteString(w, "Error occurred while processing purge.")
|
||||
http.Error(w, http.StatusText(http.StatusBadGateway), http.StatusBadGateway)
|
||||
return
|
||||
}
|
||||
|
||||
// Successful purge.
|
||||
w.Write([]byte("PURGED"))
|
||||
}
|
||||
|
||||
// Start the FastCGI server.
|
||||
func (a *ServerCmd) Run() error {
|
||||
// Determine UNIX socket path.
|
||||
unixSocket := a.Socket
|
||||
if unixSocket == "" {
|
||||
unixSocket = "/run/nginx-cache-purge/http.sock"
|
||||
}
|
||||
|
||||
// If socket exists, remove it.
|
||||
if _, err := os.Stat(unixSocket); !os.IsNotExist(err) {
|
||||
os.Remove(unixSocket)
|
||||
}
|
||||
|
||||
// Open the socket for FCGI communication.
|
||||
listener, err := net.Listen("unix", unixSocket)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
defer listener.Close()
|
||||
|
||||
// Start the FastCGI server.
|
||||
log.Println("Starting server at", unixSocket)
|
||||
http.HandleFunc("/", a.ServeHTTP)
|
||||
err = http.Serve(listener, nil)
|
||||
|
||||
return err
|
||||
}
|
||||
360
server_cmd.go
Normal file
360
server_cmd.go
Normal file
|
|
@ -0,0 +1,360 @@
|
|||
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
|
||||
}
|
||||
628
server_cmd_test.go
Normal file
628
server_cmd_test.go
Normal file
|
|
@ -0,0 +1,628 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"bytes"
|
||||
"context"
|
||||
"crypto/md5"
|
||||
"encoding/hex"
|
||||
"io"
|
||||
"net"
|
||||
"net/http"
|
||||
"net/http/httptest"
|
||||
"net/url"
|
||||
"os"
|
||||
"path/filepath"
|
||||
"syscall"
|
||||
"testing"
|
||||
"time"
|
||||
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// Build a file that looks like an entry nginx wrote: a binary header, then the
|
||||
// "\nKEY: <key>\n" line the purge relies on, then the cached response.
|
||||
func cacheEntry(key string) []byte {
|
||||
var buf bytes.Buffer
|
||||
// Stand in for ngx_http_file_cache_header_t. Any byte but LF works, as the
|
||||
// header is opaque to us and only the KEY line is parsed.
|
||||
for i := 0; i < 56; i++ {
|
||||
buf.WriteByte(byte(i%9) + 1)
|
||||
}
|
||||
buf.WriteString("\nKEY: " + key + "\n")
|
||||
buf.WriteString("HTTP/1.1 200 OK\r\nContent-Length: 5\r\n\r\nhello")
|
||||
return buf.Bytes()
|
||||
}
|
||||
|
||||
// Populate a cache with a known set of keys, writing each entry where nginx
|
||||
// would put it: hashed name under levels=1:2 directories. Returns the cache
|
||||
// path and the file each key was written to.
|
||||
func newCache(t *testing.T, keys ...string) (string, map[string]string) {
|
||||
t.Helper()
|
||||
cachePath := t.TempDir()
|
||||
paths := make(map[string]string, len(keys))
|
||||
for _, key := range keys {
|
||||
sum := md5.Sum([]byte(key))
|
||||
name := hex.EncodeToString(sum[:])
|
||||
dir := filepath.Join(cachePath, name[31:], name[29:31])
|
||||
require.NoError(t, os.MkdirAll(dir, 0o755))
|
||||
paths[key] = filepath.Join(dir, name)
|
||||
require.NoError(t, os.WriteFile(paths[key], cacheEntry(key), 0o644))
|
||||
}
|
||||
return cachePath, paths
|
||||
}
|
||||
|
||||
func exists(t *testing.T, path string) bool {
|
||||
t.Helper()
|
||||
_, err := os.Lstat(path)
|
||||
return err == nil
|
||||
}
|
||||
|
||||
// The handler reaches the purge through the package global, as the CLI wires it.
|
||||
func withApp(t *testing.T) {
|
||||
t.Helper()
|
||||
previous := app
|
||||
app = new(App)
|
||||
t.Cleanup(func() { app = previous })
|
||||
}
|
||||
|
||||
// Send a purge to a given server, for the tests that need one configured.
|
||||
func purgeRawTo(t *testing.T, cmd *ServerCmd, query string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
req := httptest.NewRequest(http.MethodGet, "/?"+query, nil)
|
||||
w := httptest.NewRecorder()
|
||||
cmd.ServeHTTP(w, req)
|
||||
return w
|
||||
}
|
||||
|
||||
// Send a purge with the query string exactly as given, so a test can pass a key
|
||||
// the way nginx does rather than the way url.Values would escape it.
|
||||
func purgeRaw(t *testing.T, query string) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return purgeRawTo(t, new(ServerCmd), query)
|
||||
}
|
||||
|
||||
func purgeRequest(t *testing.T, query url.Values) *httptest.ResponseRecorder {
|
||||
t.Helper()
|
||||
return purgeRaw(t, query.Encode())
|
||||
}
|
||||
|
||||
func TestPurgeByKey(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
purged []string
|
||||
kept []string
|
||||
}{
|
||||
{
|
||||
name: "exact key",
|
||||
key: "example.com/index.html",
|
||||
purged: []string{"example.com/index.html"},
|
||||
kept: []string{"example.com/img/logo.png", "other.com/index.html"},
|
||||
},
|
||||
{
|
||||
// The glob has no separator, so it spans path segments in the key.
|
||||
name: "wildcard",
|
||||
key: "example.com/*",
|
||||
purged: []string{"example.com/index.html", "example.com/img/logo.png"},
|
||||
kept: []string{"other.com/index.html"},
|
||||
},
|
||||
{
|
||||
// A key that is not in the cache is not a failure; there is simply
|
||||
// nothing to remove, the common case when purges race each other.
|
||||
name: "key not in cache",
|
||||
key: "example.com/absent.html",
|
||||
kept: []string{"example.com/index.html", "example.com/img/logo.png", "other.com/index.html"},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
withApp(t)
|
||||
cachePath, paths := newCache(t,
|
||||
"example.com/index.html",
|
||||
"example.com/img/logo.png",
|
||||
"other.com/index.html",
|
||||
)
|
||||
|
||||
w := purgeRequest(t, url.Values{"path": {cachePath}, "key": {test.key}})
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "PURGED", w.Body.String())
|
||||
for _, key := range test.purged {
|
||||
require.False(t, exists(t, paths[key]), "%s was not purged", key)
|
||||
}
|
||||
for _, key := range test.kept {
|
||||
require.True(t, exists(t, paths[key]), "%s was purged", key)
|
||||
}
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestPurgeExcludes(t *testing.T) {
|
||||
withApp(t)
|
||||
cachePath, paths := newCache(t,
|
||||
"example.com/index.html",
|
||||
"example.com/keep.html",
|
||||
"example.com/img/logo.png",
|
||||
)
|
||||
|
||||
// Nginx repeats the parameter to pass more than one exclude.
|
||||
w := purgeRequest(t, url.Values{
|
||||
"path": {cachePath},
|
||||
"key": {"example.com/*"},
|
||||
"exclude": {"example.com/keep.html", "example.com/img/*"},
|
||||
})
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.False(t, exists(t, paths["example.com/index.html"]), "matching key was not purged")
|
||||
require.True(t, exists(t, paths["example.com/keep.html"]), "literal exclude was purged")
|
||||
require.True(t, exists(t, paths["example.com/img/logo.png"]), "glob exclude was purged")
|
||||
}
|
||||
|
||||
// Nginx substitutes $request_uri into the query string without escaping it, so
|
||||
// the cache key it stored is the one on the wire byte for byte. Decoding it
|
||||
// would look for a key nginx never wrote, and answer PURGED having done
|
||||
// nothing. A caller escaping the key properly still has its purge land, so both
|
||||
// conventions work over the same socket.
|
||||
func TestPurgeKeyEncoding(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
key string
|
||||
sent string
|
||||
}{
|
||||
{"percent escape as sent", "example.com/caf%C3%A9.html", "example.com/caf%C3%A9.html"},
|
||||
{"plus sign as sent", "example.com/a+b.html", "example.com/a+b.html"},
|
||||
{"percent sign as sent", "example.com/100%.html", "example.com/100%.html"},
|
||||
{"escaped by the caller", "example.com/café.html", "example.com/caf%C3%A9.html"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
withApp(t)
|
||||
cachePath, paths := newCache(t, test.key, "other.com/index.html")
|
||||
|
||||
w := purgeRaw(t, "path="+url.QueryEscape(cachePath)+"&key="+test.sent)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.False(t, exists(t, paths[test.key]), "key was not purged")
|
||||
require.True(t, exists(t, paths["other.com/index.html"]), "unrelated key was purged")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Excludes carry the same ambiguity as the key, and an exclude asks for a key
|
||||
// to be kept, so either reading of one has to be enough to keep it.
|
||||
func TestPurgeExcludeEncoding(t *testing.T) {
|
||||
for _, exclude := range []string{"example.com/a+b.html", "example.com/a%2Bb.html"} {
|
||||
t.Run(exclude, func(t *testing.T) {
|
||||
withApp(t)
|
||||
cachePath, paths := newCache(t, "example.com/a+b.html", "example.com/index.html")
|
||||
|
||||
w := purgeRaw(t, "path="+url.QueryEscape(cachePath)+"&key=example.com/*&exclude="+exclude)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.True(t, exists(t, paths["example.com/a+b.html"]), "excluded key was purged")
|
||||
require.False(t, exists(t, paths["example.com/index.html"]), "matching key was not purged")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Nginx writes partial responses to temporary files in the same tree. They have
|
||||
// no cache header, so they must be left alone rather than read as entries, and
|
||||
// a KEY line past the header scan belongs to a cached body rather than a header.
|
||||
func TestPurgeLeavesNonEntriesAlone(t *testing.T) {
|
||||
withApp(t)
|
||||
cachePath, paths := newCache(t, "example.com/index.html")
|
||||
|
||||
// Longer than the scanner's buffer with no line break: the shape of a
|
||||
// body-only temporary file.
|
||||
temporary := filepath.Join(cachePath, "0000000123")
|
||||
require.NoError(t, os.WriteFile(temporary, bytes.Repeat([]byte{'a'}, maxHeaderScan*2), 0o644))
|
||||
// An empty file is the other thing a half-written entry looks like.
|
||||
empty := filepath.Join(cachePath, "0000000124")
|
||||
require.NoError(t, os.WriteFile(empty, nil, 0o644))
|
||||
// Short lines, so the scanner keeps reading until the limit cuts it off.
|
||||
var body bytes.Buffer
|
||||
for body.Len() < maxHeaderScan {
|
||||
body.WriteString("filler line\n")
|
||||
}
|
||||
body.WriteString("KEY: example.com/deep.html\n")
|
||||
deep := filepath.Join(cachePath, "0000000125")
|
||||
require.NoError(t, os.WriteFile(deep, body.Bytes(), 0o644))
|
||||
|
||||
w := purgeRequest(t, url.Values{"path": {cachePath}, "key": {"*"}})
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.False(t, exists(t, paths["example.com/index.html"]), "matching key was not purged")
|
||||
require.True(t, exists(t, temporary), "temporary file was purged")
|
||||
require.True(t, exists(t, empty), "empty file was purged")
|
||||
require.True(t, exists(t, deep), "file whose KEY line is past the header scan was purged")
|
||||
}
|
||||
|
||||
// A cache key built from a request URI carries the punctuation that decides
|
||||
// whether a key is read as a pattern: a query string puts ? in the key, and
|
||||
// PHP-style array parameters put [ and ]. Neither was meant as a wildcard, and
|
||||
// read as one they purge the wrong thing, fail to compile, or match nothing
|
||||
// while still answering PURGED. Exact says the key is a literal, which is the
|
||||
// only way those entries can be purged at all.
|
||||
func TestPurgeExactKey(t *testing.T) {
|
||||
keys := []string{
|
||||
"example.com/index.html",
|
||||
"example.com/page.html?id=5",
|
||||
"example.com/list.php?f[]=x",
|
||||
"example.com/{weird}",
|
||||
// A key that holds what would otherwise be a wildcard purging the lot.
|
||||
"example.com/*",
|
||||
}
|
||||
for _, key := range keys {
|
||||
t.Run(key, func(t *testing.T) {
|
||||
withApp(t)
|
||||
cachePath, paths := newCache(t, key, "other.com/index.html")
|
||||
|
||||
// Sent unescaped, the way the Nginx rewrite substitutes it.
|
||||
w := purgeRaw(t, "path="+url.QueryEscape(cachePath)+"&exact=1&key="+key)
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, "PURGED", w.Body.String())
|
||||
require.False(t, exists(t, paths[key]), "key was not purged")
|
||||
require.True(t, exists(t, paths["other.com/index.html"]), "unrelated key was purged")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Without exact those same keys are patterns, which is what the parameter
|
||||
// exists to turn off. The wildcard purging everything under it is the case that
|
||||
// makes reading a literal key as a pattern dangerous rather than merely wrong.
|
||||
func TestPurgeWithoutExactReadsKeyAsPattern(t *testing.T) {
|
||||
withApp(t)
|
||||
cachePath, paths := newCache(t, "example.com/*", "other.com/index.html")
|
||||
|
||||
w := purgeRaw(t, "path="+url.QueryEscape(cachePath)+"&key=example.com/*")
|
||||
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.False(t, exists(t, paths["example.com/*"]), "the pattern matched its own key")
|
||||
require.True(t, exists(t, paths["other.com/index.html"]), "unrelated key was purged")
|
||||
}
|
||||
|
||||
// An exclude carries the same punctuation as a key, so an exact purge reads it
|
||||
// literally too. Compiled as a pattern, an exclude holding [ fails the whole
|
||||
// purge it appears in.
|
||||
func TestPurgeExactExcludes(t *testing.T) {
|
||||
const excluded = "example.com/list.php?f[]=x"
|
||||
|
||||
withApp(t)
|
||||
cachePath, paths := newCache(t, excluded, "example.com/other.php")
|
||||
query := "path=" + url.QueryEscape(cachePath) + "&key=example.com/*&exclude=" + excluded
|
||||
|
||||
// As a pattern the exclude will not compile, and a purge that cannot honour
|
||||
// an exclude must not run.
|
||||
w := purgeRawTo(t, new(ServerCmd), query)
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.True(t, exists(t, paths[excluded]), "purged despite the failure")
|
||||
|
||||
// Exact reads both the key and the exclude literally, so the key named by
|
||||
// the exclude is the one kept.
|
||||
w = purgeRawTo(t, new(ServerCmd), query+"&exact=1")
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.True(t, exists(t, paths[excluded]), "excluded key was purged")
|
||||
// The key is a literal too, so it purges only itself and not the sibling.
|
||||
require.True(t, exists(t, paths["example.com/other.php"]), "unrelated key was purged")
|
||||
}
|
||||
|
||||
// A value that is not a boolean has to be refused. Falling back to pattern
|
||||
// matching on a typo is how a key stops being purged while the response still
|
||||
// reports that it was.
|
||||
func TestPurgeExactValues(t *testing.T) {
|
||||
tests := []struct {
|
||||
value string
|
||||
exact bool
|
||||
bad bool
|
||||
}{
|
||||
{value: "1", exact: true},
|
||||
{value: "true", exact: true},
|
||||
{value: "0", exact: false},
|
||||
{value: "false", exact: false},
|
||||
// Present with no value at all still asks for it.
|
||||
{value: "", exact: true},
|
||||
{value: "yes", bad: true},
|
||||
{value: "2", bad: true},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run("exact="+test.value, func(t *testing.T) {
|
||||
withApp(t)
|
||||
// Read as a pattern this key is an alternation that matches
|
||||
// nothing on disk, so only an exact purge removes it.
|
||||
const key = "example.com/{weird}"
|
||||
cachePath, paths := newCache(t, key)
|
||||
|
||||
parameter := "&exact"
|
||||
if test.value != "" {
|
||||
parameter += "=" + test.value
|
||||
}
|
||||
w := purgeRaw(t, "path="+url.QueryEscape(cachePath)+parameter+"&key="+key)
|
||||
|
||||
if test.bad {
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
require.True(t, exists(t, paths[key]), "purged despite the bad parameter")
|
||||
return
|
||||
}
|
||||
require.Equal(t, http.StatusOK, w.Code)
|
||||
require.Equal(t, test.exact, !exists(t, paths[key]), "key purged with exact=%v", test.exact)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A missing parameter has to come back as a failure status. Answering 200 with
|
||||
// an error in the body reads to nginx as a successful purge.
|
||||
func TestPurgeRejectsMissingParameters(t *testing.T) {
|
||||
withApp(t)
|
||||
cachePath, _ := newCache(t)
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
query url.Values
|
||||
}{
|
||||
{"no path", url.Values{"key": {"example.com/*"}}},
|
||||
{"no key", url.Values{"path": {cachePath}}},
|
||||
{"empty path", url.Values{"path": {""}, "key": {"example.com/*"}}},
|
||||
{"empty key", url.Values{"path": {cachePath}, "key": {""}}},
|
||||
{"neither", url.Values{}},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
w := purgeRequest(t, test.query)
|
||||
require.Equal(t, http.StatusBadRequest, w.Code)
|
||||
require.NotEqual(t, "PURGED", w.Body.String())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A purge that could not be carried out has to say so, rather than answer
|
||||
// PURGED and leave the caller believing the keys are gone. An exclude that
|
||||
// cannot compile is the sharpest case: carrying on would delete the very keys
|
||||
// the caller asked to keep.
|
||||
func TestPurgeReportsFailure(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
absentPath bool
|
||||
key string
|
||||
exclude string
|
||||
}{
|
||||
{name: "cache path does not exist", absentPath: true, key: "example.com/*"},
|
||||
{name: "key glob will not compile", key: "example.com/[a-"},
|
||||
{name: "exclude glob will not compile", key: "example.com/*", exclude: "example.com/[a-"},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
withApp(t)
|
||||
cachePath, paths := newCache(t, "example.com/index.html")
|
||||
|
||||
query := url.Values{"path": {cachePath}, "key": {test.key}}
|
||||
if test.absentPath {
|
||||
query.Set("path", filepath.Join(cachePath, "absent"))
|
||||
}
|
||||
if test.exclude != "" {
|
||||
query.Set("exclude", test.exclude)
|
||||
}
|
||||
w := purgeRequest(t, query)
|
||||
|
||||
require.Equal(t, http.StatusInternalServerError, w.Code)
|
||||
require.NotEqual(t, "PURGED", w.Body.String())
|
||||
require.True(t, exists(t, paths["example.com/index.html"]), "purged despite the failure")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The path to purge comes from the caller, and the purge deletes what it finds
|
||||
// under it, so a server given cache directories has to serve no others.
|
||||
func TestServerAllowsCachePath(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
allowed := filepath.Join(base, "cache")
|
||||
// A sibling whose name starts with the allowed one, which a plain string
|
||||
// prefix would take for a directory inside it.
|
||||
sibling := filepath.Join(base, "cache-other")
|
||||
require.NoError(t, os.MkdirAll(allowed, 0o755))
|
||||
require.NoError(t, os.MkdirAll(sibling, 0o755))
|
||||
link := filepath.Join(base, "link")
|
||||
require.NoError(t, os.Symlink(allowed, link))
|
||||
|
||||
tests := []struct {
|
||||
name string
|
||||
roots []string
|
||||
path string
|
||||
want bool
|
||||
}{
|
||||
// A server without the flag purges wherever it is told, as it always has.
|
||||
{"no allowlist", nil, sibling, true},
|
||||
{"the named directory", []string{allowed}, allowed, true},
|
||||
{"a directory inside it", []string{allowed}, filepath.Join(allowed, "inner"), true},
|
||||
{"an unclean spelling of it", []string{allowed}, filepath.Join(allowed, "inner", ".."), true},
|
||||
{"one of several", []string{sibling, allowed}, allowed, true},
|
||||
// Symlinks are resolved, so a link to the cache is the cache whichever
|
||||
// side of the comparison it is written on.
|
||||
{"a symlink to it", []string{allowed}, link, true},
|
||||
{"named by a symlink to it", []string{link}, allowed, true},
|
||||
{"a sibling sharing its name", []string{allowed}, sibling, false},
|
||||
{"the directory above it", []string{allowed}, base, false},
|
||||
{"an unrelated directory", []string{allowed}, t.TempDir(), false},
|
||||
{"the root", []string{allowed}, "/", false},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := &ServerCmd{CachePaths: test.roots}
|
||||
require.Equal(t, test.want, cmd.allows(test.path))
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// A path the server will not purge has to be refused outright, rather than
|
||||
// walked and reported on.
|
||||
func TestPurgeRefusesCachePathOutsideAllowlist(t *testing.T) {
|
||||
withApp(t)
|
||||
cachePath, paths := newCache(t, "example.com/index.html")
|
||||
|
||||
cmd := &ServerCmd{CachePaths: []string{t.TempDir()}}
|
||||
w := purgeRawTo(t, cmd, "path="+url.QueryEscape(cachePath)+"&key=*")
|
||||
|
||||
require.Equal(t, http.StatusForbidden, w.Code)
|
||||
require.NotEqual(t, "PURGED", w.Body.String())
|
||||
require.True(t, exists(t, paths["example.com/index.html"]), "purged from a path that is not allowed")
|
||||
}
|
||||
|
||||
// Connecting to a UNIX socket needs write permission on it, so the mode has to
|
||||
// be set rather than left to the umask the service manager started us with, and
|
||||
// a mode that cannot be used has to stop the server before it takes the socket
|
||||
// path over.
|
||||
func TestListenSocketMode(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
mode string
|
||||
want os.FileMode
|
||||
}{
|
||||
{"default", "", 0o660},
|
||||
{"from the command line", "0666", 0o666},
|
||||
{"not a number", "junk", 0},
|
||||
{"not octal", "0999", 0},
|
||||
{"out of range", "1777", 0},
|
||||
{"negative", "-1", 0},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := &ServerCmd{SocketMode: test.mode}
|
||||
socket := filepath.Join(t.TempDir(), "http.sock")
|
||||
|
||||
listener, err := cmd.listen(socket)
|
||||
if test.want == 0 {
|
||||
require.Error(t, err)
|
||||
require.False(t, exists(t, socket), "socket was bound despite the mode being rejected")
|
||||
return
|
||||
}
|
||||
require.NoError(t, err)
|
||||
defer listener.Close()
|
||||
|
||||
info, err := os.Lstat(socket)
|
||||
require.NoError(t, err)
|
||||
require.Equal(t, test.want, info.Mode().Perm())
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
func TestListenSocketPath(t *testing.T) {
|
||||
// The socket directory may not exist yet, and net.Listen will not create it.
|
||||
t.Run("creates the socket directory", func(t *testing.T) {
|
||||
socket := filepath.Join(t.TempDir(), "run", "http.sock")
|
||||
|
||||
listener, err := new(ServerCmd).listen(socket)
|
||||
require.NoError(t, err)
|
||||
defer listener.Close()
|
||||
|
||||
info, err := os.Lstat(socket)
|
||||
require.NoError(t, err, "socket was not created")
|
||||
require.NotZero(t, info.Mode()&os.ModeSocket, "path exists but is not a socket")
|
||||
})
|
||||
|
||||
// A socket left behind by a killed run must not stop the next one binding.
|
||||
t.Run("replaces a stale socket", func(t *testing.T) {
|
||||
socket := filepath.Join(t.TempDir(), "http.sock")
|
||||
|
||||
// Closing normally unlinks the file, so keep it to look like a crash.
|
||||
stale, err := net.Listen("unix", socket)
|
||||
require.NoError(t, err)
|
||||
stale.(*net.UnixListener).SetUnlinkOnClose(false)
|
||||
require.NoError(t, stale.Close())
|
||||
require.True(t, exists(t, socket), "stale socket was not left behind")
|
||||
|
||||
listener, err := new(ServerCmd).listen(socket)
|
||||
require.NoError(t, err, "listen over stale socket")
|
||||
require.NoError(t, listener.Close())
|
||||
})
|
||||
|
||||
// Taking over a socket another instance is serving would silently steal its
|
||||
// requests, so binding has to fail instead.
|
||||
t.Run("refuses a live socket", func(t *testing.T) {
|
||||
socket := filepath.Join(t.TempDir(), "http.sock")
|
||||
|
||||
live, err := net.Listen("unix", socket)
|
||||
require.NoError(t, err)
|
||||
defer live.Close()
|
||||
|
||||
_, err = new(ServerCmd).listen(socket)
|
||||
require.Error(t, err)
|
||||
require.True(t, exists(t, socket), "live socket was removed")
|
||||
})
|
||||
|
||||
// A path holding something other than a socket is not ours to unlink.
|
||||
t.Run("refuses a path that is not a socket", func(t *testing.T) {
|
||||
socket := filepath.Join(t.TempDir(), "http.sock")
|
||||
require.NoError(t, os.WriteFile(socket, nil, 0o644))
|
||||
|
||||
_, err := new(ServerCmd).listen(socket)
|
||||
require.Error(t, err)
|
||||
require.True(t, exists(t, socket), "the existing file was removed")
|
||||
})
|
||||
}
|
||||
|
||||
// The whole command: bind the socket given on the command line, serve a purge
|
||||
// over it, and shut down on the signal systemd sends to stop the service.
|
||||
func TestRunServesAndShutsDown(t *testing.T) {
|
||||
withApp(t)
|
||||
cachePath, paths := newCache(t, "example.com/index.html", "other.com/index.html")
|
||||
|
||||
socket := filepath.Join(t.TempDir(), "http.sock")
|
||||
cmd := &ServerCmd{Socket: socket}
|
||||
|
||||
runErr := make(chan error, 1)
|
||||
go func() { runErr <- cmd.Run() }()
|
||||
|
||||
client := &http.Client{
|
||||
Transport: &http.Transport{
|
||||
DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
|
||||
return (&net.Dialer{}).DialContext(ctx, "unix", socket)
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
// Run binds asynchronously, so wait for the socket to answer.
|
||||
var resp *http.Response
|
||||
query := url.Values{"path": {cachePath}, "key": {"example.com/*"}}
|
||||
deadline := time.Now().Add(5 * time.Second)
|
||||
for {
|
||||
var err error
|
||||
resp, err = client.Get("http://socket/purge?" + query.Encode())
|
||||
if err == nil {
|
||||
break
|
||||
}
|
||||
require.False(t, time.Now().After(deadline), "server never accepted a connection: %s", err)
|
||||
time.Sleep(10 * time.Millisecond)
|
||||
}
|
||||
|
||||
body, err := io.ReadAll(resp.Body)
|
||||
require.NoError(t, err)
|
||||
resp.Body.Close()
|
||||
require.Equal(t, http.StatusOK, resp.StatusCode)
|
||||
require.Equal(t, "PURGED", string(body))
|
||||
require.False(t, exists(t, paths["example.com/index.html"]), "matching key was not purged")
|
||||
require.True(t, exists(t, paths["other.com/index.html"]), "unrelated key was purged")
|
||||
|
||||
// The command handles SIGTERM itself, so this stops the server rather than
|
||||
// the test binary.
|
||||
require.NoError(t, syscall.Kill(syscall.Getpid(), syscall.SIGTERM))
|
||||
|
||||
select {
|
||||
case err := <-runErr:
|
||||
require.NoError(t, err, "run returned an error on shutdown")
|
||||
case <-time.After(15 * time.Second):
|
||||
t.Fatal("server did not shut down on SIGTERM")
|
||||
}
|
||||
|
||||
// Shutting down closes the listener, which unlinks the socket for the run
|
||||
// after this one.
|
||||
require.False(t, exists(t, socket), "socket file was left behind")
|
||||
}
|
||||
|
||||
// A socket that cannot be bound is the command failing, not the server running
|
||||
// with nowhere to listen.
|
||||
func TestRunReportsListenFailure(t *testing.T) {
|
||||
socket := filepath.Join(t.TempDir(), "http.sock")
|
||||
require.NoError(t, os.WriteFile(socket, nil, 0o644))
|
||||
|
||||
require.Error(t, (&ServerCmd{Socket: socket}).Run())
|
||||
}
|
||||
169
service_cmd.go
Normal file
169
service_cmd.go
Normal file
|
|
@ -0,0 +1,169 @@
|
|||
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
|
||||
}
|
||||
121
service_cmd_test.go
Normal file
121
service_cmd_test.go
Normal file
|
|
@ -0,0 +1,121 @@
|
|||
package main
|
||||
|
||||
import (
|
||||
"os"
|
||||
"path/filepath"
|
||||
"testing"
|
||||
|
||||
"github.com/alecthomas/kong"
|
||||
"github.com/stretchr/testify/require"
|
||||
)
|
||||
|
||||
// The allowlist has to reach the installed unit's ExecStart, as that is the
|
||||
// only place the server it starts reads it from.
|
||||
func TestServiceArguments(t *testing.T) {
|
||||
tests := []struct {
|
||||
name string
|
||||
cachePaths []string
|
||||
want []string
|
||||
}{
|
||||
{
|
||||
// What the command has always installed, so a service installed
|
||||
// without the flag runs exactly as it did before.
|
||||
name: "no allowlist",
|
||||
want: []string{"server"},
|
||||
},
|
||||
{
|
||||
name: "one cache path",
|
||||
cachePaths: []string{"/var/nginx/proxy_temp/cache"},
|
||||
want: []string{"server", "--cache-path", "/var/nginx/proxy_temp/cache"},
|
||||
},
|
||||
{
|
||||
// The flag repeats, and each one has to arrive as its own argument
|
||||
// rather than joined into a value the server reads as one path.
|
||||
name: "several cache paths",
|
||||
cachePaths: []string{"/var/cache/one", "/var/cache/two"},
|
||||
want: []string{
|
||||
"server",
|
||||
"--cache-path", "/var/cache/one",
|
||||
"--cache-path", "/var/cache/two",
|
||||
},
|
||||
},
|
||||
}
|
||||
for _, test := range tests {
|
||||
t.Run(test.name, func(t *testing.T) {
|
||||
cmd := &ServiceCmd{Action: "install", CachePaths: test.cachePaths}
|
||||
require.Equal(t, test.want, cmd.arguments())
|
||||
|
||||
// The same arguments have to be what the service definition is
|
||||
// built with, or the unit is written without them.
|
||||
svc, err := cmd.service()
|
||||
require.NoError(t, err)
|
||||
require.NotNil(t, svc)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The unit runs from a working directory of the service manager's choosing, so
|
||||
// a relative path would name a different directory there than the one the
|
||||
// install was typed against.
|
||||
func TestServiceArgumentsAbsolutePaths(t *testing.T) {
|
||||
cmd := &ServiceCmd{Action: "install", CachePaths: []string{"cache"}}
|
||||
|
||||
arguments := cmd.arguments()
|
||||
|
||||
require.Len(t, arguments, 3)
|
||||
require.True(t, filepath.IsAbs(arguments[2]), "%s is not absolute", arguments[2])
|
||||
require.Equal(t, "cache", filepath.Base(arguments[2]))
|
||||
}
|
||||
|
||||
// A symlinked cache is resolved per request by the server, so the install must
|
||||
// leave the link alone rather than pin the allowlist to today's target.
|
||||
func TestServiceArgumentsKeepsSymlinks(t *testing.T) {
|
||||
base := t.TempDir()
|
||||
target := filepath.Join(base, "cache")
|
||||
link := filepath.Join(base, "link")
|
||||
require.NoError(t, os.MkdirAll(target, 0o755))
|
||||
require.NoError(t, os.Symlink(target, link))
|
||||
|
||||
cmd := &ServiceCmd{Action: "install", CachePaths: []string{link}}
|
||||
|
||||
require.Equal(t, []string{"server", "--cache-path", link}, cmd.arguments())
|
||||
}
|
||||
|
||||
// The allowlist is written into the unit at install, so accepting it on an
|
||||
// action that cannot act on it would read as having changed the allowlist of a
|
||||
// service that carries on with the one it was installed with.
|
||||
func TestServiceRejectsCachePathsOutsideInstall(t *testing.T) {
|
||||
for _, action := range ServiceAction {
|
||||
if action == "install" {
|
||||
continue
|
||||
}
|
||||
t.Run(action, func(t *testing.T) {
|
||||
cmd := &ServiceCmd{Action: action, CachePaths: []string{t.TempDir()}}
|
||||
|
||||
err := cmd.Run()
|
||||
|
||||
require.Error(t, err)
|
||||
require.Contains(t, err.Error(), "--cache-path")
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// The flag has to be reachable from the command line it is documented on, and
|
||||
// kong's path type has to expand each value rather than only the first.
|
||||
func TestServiceCachePathFlagParses(t *testing.T) {
|
||||
flags := &Flags{}
|
||||
parser, err := kong.New(flags, kong.Name(Name), kongVars())
|
||||
require.NoError(t, err)
|
||||
|
||||
_, err = parser.Parse([]string{
|
||||
"service", "install",
|
||||
"--cache-path", "/var/cache/one",
|
||||
"--cache-path", "relative/cache",
|
||||
})
|
||||
require.NoError(t, err)
|
||||
|
||||
require.Equal(t, "install", flags.Service.Action)
|
||||
require.Len(t, flags.Service.CachePaths, 2)
|
||||
require.Equal(t, "/var/cache/one", flags.Service.CachePaths[0])
|
||||
require.True(t, filepath.IsAbs(flags.Service.CachePaths[1]), "relative path was not expanded")
|
||||
}
|
||||
Loading…
Add table
Reference in a new issue