├── .gitignore ├── maintenance_test.trigger ├── go.mod ├── maintenance_test.html ├── maintenance_test.json ├── .assets └── icon.png ├── Makefile ├── .traefik.yml ├── .golangci.yml ├── README.md ├── main.go └── main_test.go /.gitignore: -------------------------------------------------------------------------------- 1 | /.idea/ 2 | -------------------------------------------------------------------------------- /maintenance_test.trigger: -------------------------------------------------------------------------------- 1 | -------------------------------------------------------------------------------- /go.mod: -------------------------------------------------------------------------------- 1 | module github.com/TRIMM/traefik-maintenance 2 | 3 | go 1.19 4 | -------------------------------------------------------------------------------- /maintenance_test.html: -------------------------------------------------------------------------------- 1 | Maintenance -------------------------------------------------------------------------------- /maintenance_test.json: -------------------------------------------------------------------------------- 1 | {"detail": "This endpoint is currently in maintenance mode"} -------------------------------------------------------------------------------- /.assets/icon.png: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/TRIMM/traefik-maintenance/HEAD/.assets/icon.png -------------------------------------------------------------------------------- /Makefile: -------------------------------------------------------------------------------- 1 | .PHONY: lint test vendor clean 2 | 3 | export GO111MODULE=on 4 | 5 | default: lint test 6 | 7 | lint: 8 | golangci-lint run 9 | 10 | test: 11 | go test -v -cover ./... 12 | 13 | yaegi_test: 14 | yaegi test -v . 15 | 16 | vendor: 17 | go mod vendor 18 | 19 | clean: 20 | rm -rf ./vendor -------------------------------------------------------------------------------- /.traefik.yml: -------------------------------------------------------------------------------- 1 | displayName: Maintenance Page 2 | type: middleware 3 | iconPath: .assets/icon.png 4 | 5 | import: github.com/TRIMM/traefik-maintenance 6 | 7 | summary: 'Maintenance Page' 8 | 9 | testData: 10 | enabled: true 11 | filename: './maintenance_test.html' 12 | triggerFilename: './maintenance_test.trigger' 13 | httpResponseCode: 503 14 | httpContentType: 'text/html; charset=utf-8' 15 | -------------------------------------------------------------------------------- /.golangci.yml: -------------------------------------------------------------------------------- 1 | run: 2 | timeout: 3m 3 | skip-files: [] 4 | skip-dirs: [] 5 | 6 | linters-settings: 7 | govet: 8 | enable-all: true 9 | disable: 10 | - fieldalignment 11 | golint: 12 | min-confidence: 0 13 | gocyclo: 14 | min-complexity: 12 15 | goconst: 16 | min-len: 5 17 | min-occurrences: 4 18 | misspell: 19 | locale: US 20 | funlen: 21 | lines: -1 22 | statements: 50 23 | godox: 24 | keywords: 25 | - FIXME 26 | gofumpt: 27 | extra-rules: true 28 | 29 | linters: 30 | enable-all: true 31 | disable: 32 | - deadcode # deprecated 33 | - exhaustivestruct # deprecated 34 | - golint # deprecated 35 | - ifshort # deprecated 36 | - interfacer # deprecated 37 | - maligned # deprecated 38 | - nosnakecase # deprecated 39 | - scopelint # deprecated 40 | - scopelint # deprecated 41 | - structcheck # deprecated 42 | - varcheck # deprecated 43 | - sqlclosecheck # not relevant (SQL) 44 | - rowserrcheck # not relevant (SQL) 45 | - execinquery # not relevant (SQL) 46 | - cyclop # duplicate of gocyclo 47 | - bodyclose # Too many false positives: https://github.com/timakin/bodyclose/issues/30 48 | - dupl 49 | - testpackage 50 | - tparallel 51 | - paralleltest 52 | - nlreturn 53 | - wsl 54 | - exhaustive 55 | - exhaustruct 56 | - goerr113 57 | - wrapcheck 58 | - ifshort 59 | - noctx 60 | - lll 61 | - gomnd 62 | - forbidigo 63 | - varnamelen 64 | 65 | issues: 66 | exclude-use-default: false 67 | max-per-linter: 0 68 | max-same-issues: 0 69 | exclude: [] 70 | exclude-rules: 71 | - path: (.+)_test.go 72 | linters: 73 | - goconst 74 | - funlen 75 | - godot 76 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Traefik Maintenance Middleware Plugin 2 | 3 | This Traefik middleware plugin allows you to configure maintenance responses for your routers. 4 | You have to declare the experimental block in your traefik static configuration file or add the 5 | required flags. 6 | 7 | Maintenance mode will be triggered if `enabled` is set to `true` and if the file configured for 8 | `triggerFilename` exists. 9 | 10 | It's also possible to provide a JSON (or any other) maintenance response by changing the 11 | `filename` to point to a JSON file and by changing `httpContentType` to `application/json; charset=utf-8`. 12 | 13 | ## Static Configuration 14 | 15 | ### FILE 16 | 17 | ```yaml 18 | experimental: 19 | plugins: 20 | traefik-maintenance: 21 | moduleName: github.com/TRIMM/traefik-maintenance 22 | version: v1.0.1 23 | ``` 24 | 25 | ### CLI 26 | 27 | ```shell 28 | --experimental.plugins.traefik-maintenance.modulename=github.com/TRIMM/traefik-maintenance 29 | --experimental.plugins.traefik-maintenance.version=v1.0.1 30 | ``` 31 | 32 | ## Dynamic Configuration 33 | 34 | ### FILE 35 | 36 | ```yaml 37 | http: 38 | services: 39 | service1: 40 | loadBalancer: 41 | servers: 42 | - url: "http://service1:8080/" 43 | service2: 44 | loadBalancer: 45 | servers: 46 | - url: "http://service2:8081/" 47 | routers: 48 | service1-router: 49 | rule: "Host(`service1`)" 50 | service: "service1" 51 | middlewares: 52 | - maintenance 53 | service2-router: 54 | rule: "Host(`service2`)" 55 | service: "service2" 56 | middlewares: 57 | - maintenance 58 | middlewares: 59 | maintenance: 60 | plugin: 61 | traefik-maintenance: 62 | enabled: true 63 | filename: '/path/to/maintenance.html' 64 | triggerFilename: '/path/to/maintenance.trigger' 65 | httpResponseCode: 503 66 | httpContentType: 'text/html; charset=utf-8' 67 | ``` 68 | -------------------------------------------------------------------------------- /main.go: -------------------------------------------------------------------------------- 1 | // Package traefik_maintenance a maintenance page plugin. 2 | package traefik_maintenance 3 | 4 | import ( 5 | "context" 6 | "fmt" 7 | "log" 8 | "net/http" 9 | "os" 10 | "text/template" 11 | ) 12 | 13 | // Config the plugin configuration. 14 | type Config struct { 15 | Enabled bool `json:"enabled"` 16 | Filename string `json:"filename"` 17 | TriggerFilename string `json:"triggerFilename"` 18 | HttpResponseCode int `json:"httpResponseCode"` 19 | HttpContentType string `json:"httpContentType"` 20 | } 21 | 22 | // CreateConfig creates the default plugin configuration. 23 | func CreateConfig() *Config { 24 | return &Config{ 25 | Enabled: false, 26 | Filename: "", 27 | TriggerFilename: "", 28 | HttpResponseCode: http.StatusServiceUnavailable, 29 | HttpContentType: "text/html; charset=utf-8", 30 | } 31 | } 32 | 33 | // MaintenancePage a maintenance page plugin. 34 | type MaintenancePage struct { 35 | next http.Handler 36 | enabled bool 37 | filename string 38 | triggerFilename string 39 | httpResponseCode int 40 | HttpContentType string 41 | name string 42 | template *template.Template 43 | } 44 | 45 | // New created a new MaintenancePage plugin. 46 | func New(ctx context.Context, next http.Handler, config *Config, name string) (http.Handler, error) { 47 | if len(config.Filename) == 0 { 48 | return nil, fmt.Errorf("filename cannot be empty") 49 | } 50 | 51 | return &MaintenancePage{ 52 | enabled: config.Enabled, 53 | filename: config.Filename, 54 | triggerFilename: config.TriggerFilename, 55 | httpResponseCode: config.HttpResponseCode, 56 | HttpContentType: config.HttpContentType, 57 | next: next, 58 | name: name, 59 | template: template.New("MaintenancePage").Delims("[[", "]]"), 60 | }, nil 61 | } 62 | 63 | func (a *MaintenancePage) ServeHTTP(rw http.ResponseWriter, req *http.Request) { 64 | if a.maintenanceEnabled() { 65 | // Return the maintenance page 66 | bytes, err := os.ReadFile(a.filename) 67 | if err == nil { 68 | rw.Header().Add("Content-Type", a.HttpContentType) 69 | rw.WriteHeader(a.httpResponseCode) 70 | _, err = rw.Write(bytes) 71 | if err == nil { 72 | return 73 | } else { 74 | log.Printf("Could not serve maintenance template %s: %s", a.filename, err) 75 | } 76 | } else { 77 | log.Printf("Could not read maintenance template %s: %s", a.filename, err) 78 | } 79 | } 80 | 81 | a.next.ServeHTTP(rw, req) 82 | } 83 | 84 | // Indicates if maintenance mode has been enabled 85 | func (a *MaintenancePage) maintenanceEnabled() bool { 86 | if !a.enabled { 87 | return false 88 | } 89 | 90 | if a.enabled && len(a.triggerFilename) == 0 { 91 | return true 92 | } 93 | 94 | // Check if the trigger exists 95 | if _, err := os.Stat(a.triggerFilename); err == nil { 96 | return true 97 | } 98 | 99 | return false 100 | } 101 | -------------------------------------------------------------------------------- /main_test.go: -------------------------------------------------------------------------------- 1 | package traefik_maintenance 2 | 3 | import ( 4 | "context" 5 | "net/http" 6 | "net/http/httptest" 7 | "path/filepath" 8 | "testing" 9 | ) 10 | 11 | func TestMaintenancePage(t *testing.T) { 12 | maintenancePage, err := filepath.Abs("./maintenance_test.html") 13 | if err != nil { 14 | t.Fatal(err) 15 | } 16 | 17 | maintenanceTrigger, err := filepath.Abs("./maintenance_test.trigger") 18 | if err != nil { 19 | t.Fatal(err) 20 | } 21 | 22 | cfg := CreateConfig() 23 | cfg.Enabled = true 24 | cfg.Filename = maintenancePage 25 | cfg.TriggerFilename = maintenanceTrigger 26 | cfg.HttpResponseCode = http.StatusServiceUnavailable 27 | cfg.HttpContentType = "text/html; charset=utf-8" 28 | 29 | ctx := context.Background() 30 | next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {}) 31 | 32 | handler, err := New(ctx, next, cfg, "traefik-maintenance") 33 | if err != nil { 34 | t.Fatal(err) 35 | } 36 | 37 | recorder := httptest.NewRecorder() 38 | 39 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 40 | if err != nil { 41 | t.Fatal(err) 42 | } 43 | 44 | handler.ServeHTTP(recorder, req) 45 | 46 | assertResponseStatus(t, recorder, http.StatusServiceUnavailable) 47 | assertResponseHeader(t, recorder, "Content-Type", "text/html; charset=utf-8") 48 | assertResponseBody(t, recorder, "Maintenance") 49 | } 50 | 51 | func TestMaintenancePageWithOtherStatusCodeAndContentType(t *testing.T) { 52 | maintenancePage, err := filepath.Abs("./maintenance_test.json") 53 | if err != nil { 54 | t.Fatal(err) 55 | } 56 | 57 | maintenanceTrigger, err := filepath.Abs("./maintenance_test.trigger") 58 | if err != nil { 59 | t.Fatal(err) 60 | } 61 | 62 | cfg := CreateConfig() 63 | cfg.Enabled = true 64 | cfg.Filename = maintenancePage 65 | cfg.TriggerFilename = maintenanceTrigger 66 | cfg.HttpResponseCode = http.StatusTeapot 67 | cfg.HttpContentType = "application/json; charset=utf-8" 68 | 69 | ctx := context.Background() 70 | next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {}) 71 | 72 | handler, err := New(ctx, next, cfg, "traefik-maintenance") 73 | if err != nil { 74 | t.Fatal(err) 75 | } 76 | 77 | recorder := httptest.NewRecorder() 78 | 79 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 80 | if err != nil { 81 | t.Fatal(err) 82 | } 83 | 84 | handler.ServeHTTP(recorder, req) 85 | 86 | assertResponseStatus(t, recorder, http.StatusTeapot) 87 | assertResponseHeader(t, recorder, "Content-Type", "application/json; charset=utf-8") 88 | assertResponseBody(t, recorder, "{\"detail\": \"This endpoint is currently in maintenance mode\"}") 89 | } 90 | 91 | func TestMaintenancePageWithoutTrigger(t *testing.T) { 92 | maintenancePage, err := filepath.Abs("./maintenance_test.html") 93 | if err != nil { 94 | t.Fatal(err) 95 | } 96 | 97 | cfg := CreateConfig() 98 | cfg.Enabled = true 99 | cfg.Filename = maintenancePage 100 | cfg.TriggerFilename = "./missing.trigger" 101 | cfg.HttpResponseCode = http.StatusServiceUnavailable 102 | cfg.HttpContentType = "text/html; charset=utf-8" 103 | 104 | ctx := context.Background() 105 | next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {}) 106 | 107 | handler, err := New(ctx, next, cfg, "traefik-maintenance") 108 | if err != nil { 109 | t.Fatal(err) 110 | } 111 | 112 | recorder := httptest.NewRecorder() 113 | 114 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 115 | if err != nil { 116 | t.Fatal(err) 117 | } 118 | 119 | handler.ServeHTTP(recorder, req) 120 | 121 | assertEmptyContentTypeHeader(t, recorder) 122 | assertEmptyResponseBody(t, recorder) 123 | } 124 | 125 | func TestMaintenancePageWithMissingTrigger(t *testing.T) { 126 | maintenancePage, err := filepath.Abs("./maintenance_test.html") 127 | if err != nil { 128 | t.Fatal(err) 129 | } 130 | 131 | cfg := CreateConfig() 132 | cfg.Enabled = true 133 | cfg.Filename = maintenancePage 134 | cfg.HttpResponseCode = http.StatusServiceUnavailable 135 | cfg.HttpContentType = "text/html; charset=utf-8" 136 | 137 | ctx := context.Background() 138 | next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {}) 139 | 140 | handler, err := New(ctx, next, cfg, "traefik-maintenance") 141 | if err != nil { 142 | t.Fatal(err) 143 | } 144 | 145 | recorder := httptest.NewRecorder() 146 | 147 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 148 | if err != nil { 149 | t.Fatal(err) 150 | } 151 | 152 | handler.ServeHTTP(recorder, req) 153 | 154 | assertResponseHeader(t, recorder, "Content-Type", "text/html; charset=utf-8") 155 | assertResponseBody(t, recorder, "Maintenance") 156 | } 157 | 158 | func TestDisabledMaintenancePage(t *testing.T) { 159 | maintenancePage, err := filepath.Abs("./maintenance_test.html") 160 | if err != nil { 161 | t.Fatal(err) 162 | } 163 | 164 | maintenanceTrigger, err := filepath.Abs("./maintenance_test.trigger") 165 | if err != nil { 166 | t.Fatal(err) 167 | } 168 | 169 | cfg := CreateConfig() 170 | cfg.Enabled = false 171 | cfg.Filename = maintenancePage 172 | cfg.TriggerFilename = maintenanceTrigger 173 | cfg.HttpResponseCode = http.StatusServiceUnavailable 174 | cfg.HttpContentType = "text/html; charset=utf-8" 175 | 176 | ctx := context.Background() 177 | next := http.HandlerFunc(func(rw http.ResponseWriter, req *http.Request) {}) 178 | 179 | handler, err := New(ctx, next, cfg, "traefik-maintenance") 180 | if err != nil { 181 | t.Fatal(err) 182 | } 183 | 184 | recorder := httptest.NewRecorder() 185 | 186 | req, err := http.NewRequestWithContext(ctx, http.MethodGet, "http://localhost", nil) 187 | if err != nil { 188 | t.Fatal(err) 189 | } 190 | 191 | handler.ServeHTTP(recorder, req) 192 | 193 | assertEmptyContentTypeHeader(t, recorder) 194 | assertEmptyResponseBody(t, recorder) 195 | } 196 | 197 | func assertEmptyResponseBody(t *testing.T, recorder *httptest.ResponseRecorder) { 198 | t.Helper() 199 | 200 | responseBodyValue := recorder.Body.String() 201 | if responseBodyValue != "" { 202 | t.Errorf("unexpected response body value: %s", responseBodyValue) 203 | } 204 | } 205 | 206 | func assertEmptyContentTypeHeader(t *testing.T, recorder *httptest.ResponseRecorder) { 207 | t.Helper() 208 | 209 | contentTypeHeaderValue := recorder.Header().Get("Content-Type") 210 | if contentTypeHeaderValue != "" { 211 | t.Errorf("unexpected header value: %s", contentTypeHeaderValue) 212 | } 213 | } 214 | 215 | func assertResponseStatus(t *testing.T, resp *httptest.ResponseRecorder, expected int) { 216 | t.Helper() 217 | 218 | if resp.Code != expected { 219 | t.Errorf("invalid resonse status [%d] was expecting [%d]", resp.Code, expected) 220 | } 221 | } 222 | 223 | func assertResponseHeader(t *testing.T, resp *httptest.ResponseRecorder, key, expected string) { 224 | t.Helper() 225 | 226 | if resp.Header().Get(key) != expected { 227 | t.Errorf("invalid header value [%s] was expecting [%s]", resp.Header().Get(key), expected) 228 | } 229 | } 230 | 231 | func assertResponseBody(t *testing.T, resp *httptest.ResponseRecorder, expected string) { 232 | t.Helper() 233 | 234 | if resp.Body.String() != expected { 235 | t.Errorf("invalid response value [%s] was expecting [%s]", resp.Body.String(), expected) 236 | } 237 | } 238 | --------------------------------------------------------------------------------