First commit
This commit is contained in:
commit
b5d63d9ea6
29
.github/workflows/release.yaml
vendored
Normal file
29
.github/workflows/release.yaml
vendored
Normal 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
18
.github/workflows/test_golang.yaml
vendored
Normal 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
2
.gitignore
vendored
Normal file
@ -0,0 +1,2 @@
|
||||
nginx-cache-purge
|
||||
dist/
|
30
.goreleaser.yaml
Normal file
30
.goreleaser.yaml
Normal 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
19
LICENSE.txt
Normal 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
136
README.md
Normal 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
41
flags.go
Normal 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
11
go.mod
Normal 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
14
go.sum
Normal 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
153
main.go
Normal 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
13
purgeCmd.go
Normal 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
74
serverCmd.go
Normal 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
|
||||
}
|
Loading…
Reference in New Issue
Block a user