diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml index 8704403..9527d4f 100644 --- a/.github/workflows/release.yaml +++ b/.github/workflows/release.yaml @@ -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 }} diff --git a/.github/workflows/test_golang.yaml b/.github/workflows/test_golang.yaml index 233ad72..0f83376 100644 --- a/.github/workflows/test_golang.yaml +++ b/.github/workflows/test_golang.yaml @@ -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 ./... diff --git a/.goreleaser.yaml b/.goreleaser.yaml index abab3db..c1c8793 100644 --- a/.goreleaser.yaml +++ b/.goreleaser.yaml @@ -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:" diff --git a/LICENSE.txt b/LICENSE.txt index 3c9e3c8..9a91a54 100644 --- a/LICENSE.txt +++ b/LICENSE.txt @@ -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 diff --git a/Makefile b/Makefile new file mode 100644 index 0000000..8c46512 --- /dev/null +++ b/Makefile @@ -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 diff --git a/README.md b/README.md index 5a58af4..4996fb2 100644 --- a/README.md +++ b/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 [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 --help" for more information on a command. - -$ nginx-cache-purge p --help -Usage: nginx-cache-purge purge (p) [flags] - -Purge cache now - -Arguments: - Path to cache directory. - 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. -`` diff --git a/VERSION b/VERSION new file mode 100644 index 0000000..0ea3a94 --- /dev/null +++ b/VERSION @@ -0,0 +1 @@ +0.2.0 diff --git a/flags.go b/flags.go index dc777ae..f97af80 100644 --- a/flags.go +++ b/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 } diff --git a/go.mod b/go.mod index 685c206..3f886dc 100644 --- a/go.mod +++ b/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 +) diff --git a/go.sum b/go.sum index 7a40a1e..ef8ab34 100644 --- a/go.sum +++ b/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= diff --git a/info.go b/info.go new file mode 100644 index 0000000..c9626be --- /dev/null +++ b/info.go @@ -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" +) diff --git a/main.go b/main.go index 0a33f6e..e06eb0d 100644 --- a/main.go +++ b/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. diff --git a/purgeCmd.go b/purgeCmd.go deleted file mode 100644 index 983adc4..0000000 --- a/purgeCmd.go +++ /dev/null @@ -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) -} diff --git a/purge_cmd.go b/purge_cmd.go new file mode 100644 index 0000000..296d596 --- /dev/null +++ b/purge_cmd.go @@ -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 +} diff --git a/serverCmd.go b/serverCmd.go deleted file mode 100644 index dcfee50..0000000 --- a/serverCmd.go +++ /dev/null @@ -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 -} diff --git a/server_cmd.go b/server_cmd.go new file mode 100644 index 0000000..908aa5a --- /dev/null +++ b/server_cmd.go @@ -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 +} diff --git a/server_cmd_test.go b/server_cmd_test.go new file mode 100644 index 0000000..47bfa3b --- /dev/null +++ b/server_cmd_test.go @@ -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: \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()) +} diff --git a/service_cmd.go b/service_cmd.go new file mode 100644 index 0000000..09aceab --- /dev/null +++ b/service_cmd.go @@ -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 +} diff --git a/service_cmd_test.go b/service_cmd_test.go new file mode 100644 index 0000000..08e2d0f --- /dev/null +++ b/service_cmd_test.go @@ -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") +}