commit b5d63d9ea6d44e9bb9fa5c039c584ca00ea04155 Author: GRMrGecko Date: Thu Aug 1 02:09:30 2024 -0500 First commit diff --git a/.github/workflows/release.yaml b/.github/workflows/release.yaml new file mode 100644 index 0000000..8704403 --- /dev/null +++ b/.github/workflows/release.yaml @@ -0,0 +1,29 @@ +on: + release: + types: [created] + +permissions: + contents: write + packages: write + +jobs: + goreleaser: + runs-on: ubuntu-latest + steps: + - + name: Checkout + uses: actions/checkout@v4 + with: + fetch-depth: 0 + - + name: Set up Go + uses: actions/setup-go@v4 + - + name: Run GoReleaser + uses: goreleaser/goreleaser-action@v5 + with: + distribution: goreleaser + version: latest + args: release --clean + env: + GITHUB_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/test_golang.yaml b/.github/workflows/test_golang.yaml new file mode 100644 index 0000000..233ad72 --- /dev/null +++ b/.github/workflows/test_golang.yaml @@ -0,0 +1,18 @@ +name: Go package + +on: [push] + +jobs: + build: + + runs-on: ubuntu-latest + steps: + - uses: actions/checkout@v3 + + - name: Set up Go + uses: actions/setup-go@v4 + with: + go-version: '1.21' + + - name: Build + run: go build -v ./... diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..52f1e46 --- /dev/null +++ b/.gitignore @@ -0,0 +1,2 @@ +nginx-cache-purge +dist/ diff --git a/.goreleaser.yaml b/.goreleaser.yaml new file mode 100644 index 0000000..abab3db --- /dev/null +++ b/.goreleaser.yaml @@ -0,0 +1,30 @@ +# This is an example .goreleaser.yml file with some sensible defaults. +# Make sure to check the documentation at https://goreleaser.com + +# 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 + +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 ./... + +builds: + - env: + - CGO_ENABLED=0 + goos: + - linux + - darwin + +archives: + - format: tar.gz + # this name template makes the OS and Arch compatible with the results of `uname`. + name_template: "{{ .ProjectName }}-{{ .Version }}.{{ .Os }}-{{ .Arch }}" + wrap_in_directory: true + strip_parent_binary_folder: false diff --git a/LICENSE.txt b/LICENSE.txt new file mode 100644 index 0000000..3c9e3c8 --- /dev/null +++ b/LICENSE.txt @@ -0,0 +1,19 @@ +Copyright (c) 2023 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 +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE +AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER +LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, +OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. diff --git a/README.md b/README.md new file mode 100644 index 0000000..af7ce28 --- /dev/null +++ b/README.md @@ -0,0 +1,136 @@ +# nginx-cache-purge +A tool to help purge Nginx cache. It can either run locally with the purge command, or run as a local unix service to allow for purging by Nginx http requests. + +## Install +You can install either by downloading the latest binary release, or by building. + +## Building +Building should be as simple as running: +``` +go build +``` + +## 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`. +``` +[Unit] +Description=Nginx Cache Purge +After=network.target + +[Service] +User=nginx +Group=nginx +RuntimeDirectory=nginx-cache-purge +PIDFile=/var/run/nginx-cache-purge/service.pid +ExecStart=/usr/local/bin/nginx-cache-purge server +Restart=always +RestartSec=3s + +[Install] +WantedBy=multi-user.target +``` + +You can then run the following to start the service: +``` +systemctl daemon-reload +systemctl start nginx-cache-purge.service +``` + +## Nginx config +If you want to purge via Nginx http requests, you'll need to add configuration to your Nginx config file. + +### Map PURGE requests +``` +http { + map $request_method $is_purge { + default 0; + PURGE 1; + } + + proxy_cache_path /var/nginx/proxy_temp/cache levels=1:2 keys_zone=my_cache:10m; + proxy_cache_key $host$request_uri; + + server { + location / { + if ($is_purge) { + proxy_pass http://unix:/var/run/nginx-cache-purge/http.sock; + rewrite ^ /?path=/var/nginx/proxy_temp/cache&key=$host$request_uri break; + } + + proxy_cache my_cache; + proxy_pass http://upstream; + } + } +} +``` + +### Auth via cookie +``` +http { + map $cookie_purge_token $is_purge { + default 0; + nnCgKUx1p2bIABXR 1; + } + + proxy_cache_path /var/nginx/proxy_temp/cache levels=1:2 keys_zone=my_cache:10m; + proxy_cache_key $host$request_uri; + + server { + location / { + if ($is_purge) { + proxy_pass http://unix:/var/run/nginx-cache-purge/http.sock; + rewrite ^ /?path=/var/nginx/proxy_temp/cache&key=$host$request_uri break; + } + + proxy_cache my_cache; + proxy_pass http://upstream; + } + } +} +``` + +### Auth via header +``` +http { + map $http_purge_token $is_purge { + default 0; + nnCgKUx1p2bIABXR 1; + } + + proxy_cache_path /var/nginx/proxy_temp/cache levels=1:2 keys_zone=my_cache:10m; + proxy_cache_key $host$request_uri; + + server { + location / { + if ($is_purge) { + proxy_pass http://unix:/var/run/nginx-cache-purge/http.sock; + rewrite ^ /?path=/var/nginx/proxy_temp/cache&key=$host$request_uri break; + } + + proxy_cache my_cache; + proxy_pass http://upstream; + } + } +} +``` + +### Using IP whitelists +``` +http { + proxy_cache_path /var/nginx/proxy_temp/cache levels=1:2 keys_zone=my_cache:10m; + proxy_cache_key $host$request_uri; + + server { + location / { + proxy_cache my_cache; + proxy_pass http://upstream; + } + location ~ /purge(/.*) { + allow 127.0.0.1; + deny all; + proxy_pass http://unix:/var/run/nginx-cache-purge/http.sock; + rewrite ^ /?path=/var/nginx/proxy_temp/cache&key=$host$1 break; + } + } +} +``` diff --git a/flags.go b/flags.go new file mode 100644 index 0000000..dc777ae --- /dev/null +++ b/flags.go @@ -0,0 +1,41 @@ +package main + +import ( + "fmt" + + "github.com/alecthomas/kong" +) + +// When version is requested, print the version. +type VersionFlag bool + +func (v VersionFlag) Decode(ctx *kong.DecodeContext) error { return nil } +func (v VersionFlag) IsBool() bool { return true } +func (v VersionFlag) BeforeApply(app *kong.Kong, vars kong.Vars) error { + fmt.Println(serviceName + ": " + serviceVersion) + app.Exit(0) + return nil +} + +// Flags and or commands supplied to cli. +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"` +} + +// Parse the supplied flags and commands. +func (a *App) ParseFlags() *kong.Context { + app.flags = &Flags{} + + ctx := kong.Parse(app.flags, + kong.Name(serviceName), + kong.Description(serviceDescription), + kong.UsageOnError(), + kong.ConfigureHelp(kong.HelpOptions{ + Compact: true, + }), + ) + return ctx +} diff --git a/go.mod b/go.mod new file mode 100644 index 0000000..685c206 --- /dev/null +++ b/go.mod @@ -0,0 +1,11 @@ +module github.com/grmrgecko/nginx-cache-purge + +go 1.22.5 + +require ( + github.com/alecthomas/kong v0.9.0 + github.com/gobwas/glob v0.2.3 + github.com/portmapping/go-reuse v0.0.3 +) + +require golang.org/x/sys v0.0.0-20200501145240-bc7a7d42d5c3 // indirect diff --git a/go.sum b/go.sum new file mode 100644 index 0000000..7a40a1e --- /dev/null +++ b/go.sum @@ -0,0 +1,14 @@ +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/kong v0.9.0 h1:G5diXxc85KvoV2f0ZRVuMsi45IrBgx9zDNGNj165aPA= +github.com/alecthomas/kong v0.9.0/go.mod h1:Y47y5gKfHp1hDc7CH7OeXgLIpp+Q2m1Ni0L5s3bI8Os= +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/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= diff --git a/main.go b/main.go new file mode 100644 index 0000000..33a178c --- /dev/null +++ b/main.go @@ -0,0 +1,153 @@ +package main + +import ( + "bufio" + "crypto/md5" + "encoding/hex" + "fmt" + "os" + "path/filepath" + "regexp" + "strings" + + "github.com/gobwas/glob" +) + +// Basic application info. +const ( + serviceName = "nginx-cache-purge" + serviceDescription = "Tool to help purge Nginx cache " + serviceVersion = "0.1" +) + +// App structure to access global app variables. +type App struct { + flags *Flags +} + +var app *App + +// Function to purge nginx cache keys. +func (a *App) PurgeCache(CachePath string, Key string, ExcludeKeys []string) error { + // Key must be provided. + if len(Key) == 0 { + return fmt.Errorf("no key provided") + } + + // Regex to determine if key is a glob pattern. + globRegex := regexp.MustCompile(`[\*?\[{]+`) + + // 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 + } + } + if exclude == Key { + return true + } + } + return false + } + + // Confirm that the cache path exists. + if _, err := os.Stat(CachePath); err != nil { + return 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) { + // If excluded, skip the key. + if keyIsExcluded(Key) { + fmt.Println("Key", Key, "is excluded, will not purge.") + return nil + } + + // Get the hash of the key. + hash := md5.Sum([]byte(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. + if err != nil { + return err + } + // We only care to look at files. + if info.IsDir() { + return nil + } + // If this file matches our key hash then delete. + if info.Name() == keyHash { + fmt.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 + } + // 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) + } + } else { + // This is a wildcard, so we need to find all files that match it and delete them. + g, err := glob.Compile(Key) + if err != nil { + return 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. + if err != nil { + return err + } + // We only care to look at files. + if info.IsDir() { + return nil + } + + // Read the file to extract the key. + file, err := os.Open(filePath) + if err != nil { + return err + } + defer file.Close() + scanner := bufio.NewScanner(file) + for scanner.Scan() { + line := scanner.Text() + if strings.HasPrefix(line, "KEY: ") { + key := line[5:] + if g.Match(key) { + fmt.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 + } + } + } + + return nil + }) + if err != nil { + return fmt.Errorf("error while scanning for file to purge: %s", err) + } + } + return nil +} + +// Main function to start the app. +func main() { + app = new(App) + ctx := app.ParseFlags() + + // Run the command requested. + err := ctx.Run() + ctx.FatalIfErrorf(err) +} diff --git a/purgeCmd.go b/purgeCmd.go new file mode 100644 index 0000000..983adc4 --- /dev/null +++ b/purgeCmd.go @@ -0,0 +1,13 @@ +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/serverCmd.go b/serverCmd.go new file mode 100644 index 0000000..1df99e9 --- /dev/null +++ b/serverCmd.go @@ -0,0 +1,74 @@ +package main + +import ( + "fmt" + "io" + "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 = "/var/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. + fmt.Println("Starting server at", unixSocket) + http.HandleFunc("/", a.ServeHTTP) + err = http.Serve(listener, nil) + + return err +}