From b5d63d9ea6d44e9bb9fa5c039c584ca00ea04155 Mon Sep 17 00:00:00 2001 From: GRMrGecko Date: Thu, 1 Aug 2024 02:09:30 -0500 Subject: [PATCH] First commit --- .github/workflows/release.yaml | 29 ++++++ .github/workflows/test_golang.yaml | 18 ++++ .gitignore | 2 + .goreleaser.yaml | 30 ++++++ LICENSE.txt | 19 ++++ README.md | 136 +++++++++++++++++++++++++ flags.go | 41 ++++++++ go.mod | 11 +++ go.sum | 14 +++ main.go | 153 +++++++++++++++++++++++++++++ purgeCmd.go | 13 +++ serverCmd.go | 74 ++++++++++++++ 12 files changed, 540 insertions(+) create mode 100644 .github/workflows/release.yaml create mode 100644 .github/workflows/test_golang.yaml create mode 100644 .gitignore create mode 100644 .goreleaser.yaml create mode 100644 LICENSE.txt create mode 100644 README.md create mode 100644 flags.go create mode 100644 go.mod create mode 100644 go.sum create mode 100644 main.go create mode 100644 purgeCmd.go create mode 100644 serverCmd.go 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 +}