First commit

This commit is contained in:
GRMrGecko 2024-08-01 02:09:30 -05:00
commit b5d63d9ea6
12 changed files with 540 additions and 0 deletions

29
.github/workflows/release.yaml vendored Normal file
View File

@ -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 }}

18
.github/workflows/test_golang.yaml vendored Normal file
View File

@ -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 ./...

2
.gitignore vendored Normal file
View File

@ -0,0 +1,2 @@
nginx-cache-purge
dist/

30
.goreleaser.yaml Normal file
View File

@ -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

19
LICENSE.txt Normal file
View File

@ -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.

136
README.md Normal file
View File

@ -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;
}
}
}
```

41
flags.go Normal file
View File

@ -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
}

11
go.mod Normal file
View File

@ -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

14
go.sum Normal file
View File

@ -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=

153
main.go Normal file
View File

@ -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)
}

13
purgeCmd.go Normal file
View File

@ -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)
}

74
serverCmd.go Normal file
View File

@ -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
}