Post

Traefik 3, Varnish, fail2ban & modsecurity

Introduction

Ce petit tutoriel vous explique comment installer Traefik en proxy inverse, Varnish comme système de cache de contenu, fail2ban pour ajouter une couche de sécurité ainsi que mod security pour vous protéger des attaques.

Quelques notions

Fail2ban lit des fichiers de log comme /var/log/pwdfail ou /var/log/apache/error_log et bannit les adresses IP qui ont obtenu un trop grand nombre d’échecs lors de l’authentification. Il met à jour les règles du pare-feu pour rejeter cette adresse IP. Ces règles peuvent êtres défines par l’utilisateur. Fail2ban peut lire plusieurs fichiers de log comme ceux de sshd ou du serveur Apache.

fail2ban wiki

Le Core Rule Set (CRS) est un ensemble de règles génériques de détection d’attaques à utiliser avec ModSecurity ou des pare-feu d’applications Web compatibles. ModSecurity est un moteur de pare-feu d’application Web (WAF) open source et multiplateforme pour Apache, IIS et Nginx.

ModSecurity Core Rule Set Docker Image

Prérequis

Comme pour la majorité de mes tutoriels, je considère que vous avez un nom de domaine valide et que vous utilisez Cloudflare.

Fichiers requis

docker-compose.yml

[Fichier]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
version: "3.0"

#
# updated: 2023-04-01
# stack:   traefik
#

services:
  traefik:
    container_name: traefik
    hostname: traefik
    image: traefik:3.0
    restart: always
    stdin_open: true
    tty: true
    networks:
      - proxy
    ports:
      - "80:80"
      - "443:443/tcp"
      - "443:443/udp"
      - "6082:6082"
    depends_on:
      - varnish
      - modsecurity
    environment:
      TZ: "Europe/Paris"
      CF_API_EMAIL: [cloudflare API email account]
      #CF_DNS_API_TOKEN: "[cloudflare dns token]"
      #CF_DNS_API_TOKEN: "[cloudflare api token]"
      CF_API_KEY: "[cloudflare api key]"
    labels:
      - "traefik.enable=true"
    volumes:
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /opt/docker/ssl/:/ssl/:ro
      - /opt/docker/traefik/conf/traefik.yml:/traefik.yml:ro
      - /opt/docker/traefik/conf/config.yml:/config.yml:ro
      - /opt/docker/traefik/datas/acme.json:/acme.json
      - /opt/docker/traefik/datas/log/:/logs/
  varnish:
    container_name: varnish
    hostname: varnish
    image: varnish:latest
    restart: always
    stdin_open: true
    tty: true
    networks:
      - proxy
    ports:
      - "1080:80"
    command: "-a :1080,PROXY -s default,1G -p thread_pools=16 -p tcp_fastopen=on -p thread_pools=2 -p thread_pool_min=500 -p thread_pool_max=5000"
    environment:
      TZ: "Europe/Paris"
      VARNISH_SIZE: 1G
    volumes:
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
      - /opt/docker/traefik/conf/varnish.vcl:/etc/varnish/default.vcl:ro
      - /mnt/varnish:/var/lib/varnish
    tmpfs:
      - /tmp:exec
  fail2ban:
    container_name: fail2ban
    hostname: fail2ban
    image: crazymax/fail2ban:latest
    restart: always
    stdin_open: true
    tty: true
    cap_add:
      - NET_ADMIN
      - NET_RAW
    networks:
      - proxy
    depends_on:
      - traefik
    environment:
      TZ: "Europe/Paris"
      F2B_DB_PURGE_AGE: "14d"
    volumes:
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
      - /opt/docker/traefik/datas/f2b:/data
      - /opt/docker/traefik/datas/log:/var/log/traefik
  modsecurity:
    container_name: modsecurity
    hostname: modsecurity
    image: owasp/modsecurity-crs:apache
    restart: always
    stdin_open: true
    tty: true
    networks:
      - proxy
    ports:
      - "2080:80"
    environment:
      TZ: "Europe/Paris"
      PARANOIA: 1
      ANOMALY_INBOUND: 10
      ANOMALY_OUTBOUND: 5
      BACKEND: http://whoami
    volumes:
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
  whoami:
    container_name: whoami
    hostname: whoami
    image: containous/whoami:latest
    restart: always
    stdin_open: true
    tty: true
    networks:
      - proxy
    environment:
      TZ: "Europe/Paris"
    volumes:
      - /etc/timezone:/etc/timezone:ro
      - /etc/localtime:/etc/localtime:ro
      - /var/run/docker.sock:/var/run/docker.sock:ro
networks:
  proxy:
    external: true

traefik.yml

[Fichier]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
# 2023-04-01

global:
  checkNewVersion: false
  sendAnonymousUsage: false

api:
  dashboard: true
  debug: false
  insecure: false

entryPoints:
  http:
    address: ":80"
    http:
      redirections:
        entryPoint:
          to: https
          scheme: https
          permanent: true
  https:
    address: ":443"
  metrics:
    address: ":6082"
  streaming:
    address: ":1704/udp"

serversTransport:
  # Disables SSL certificate verification
  insecureSkipVerify: true

providers:
  docker:
    endpoint: "unix:///var/run/docker.sock"
    exposedByDefault: false
    network: proxy
    watch: true
  file:
    filename: /config.yml
    watch: true

certificatesResolvers:
  cloudflare:
    acme:
      email: [your email]
      storage: acme.json
      dnsChallenge:
        provider: cloudflare
        resolvers:
          - "1.1.1.1:53"
          - "1.0.0.1:53"

log:
  level: "error"

#accessLog: false
accessLog:
  filePath: "/logs/traefik.log"
  format: json
  filters:
    statusCodes:
      - "200"
      - "400-599"
    retryAttempts: true
    minDuration: "10ms"
  bufferingSize: 0
  fields:
    headers:
      defaultMode: drop # drop all headers per default
      names:
        User-Agent: keep # log user agent strings

experimental:
  plugins:
    modsecurity:
      modulename: github.com/acouvreur/traefik-modsecurity-plugin
      version: v1.3.0

config.yml

[Fichier]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
# 2023-04-01

enabled: true

http:
  routers:
    #traefik-http:
    #  service: api@internal

    #traefik-https:
    #  service: api@internal

    traefik:
      entryPoints:
        - http
      rule: Host(`traefik.home`)
      middlewares:
        - traefik-https-redirect
      service: api@internal

    traefik-secure:
      entryPoints:
        - https
      rule: Host(`traefik.home`)
      middlewares:
        - traefik-auth
      tls: {}
      service: api@internal

    subdomain:
      entryPoints:
        - https
      rule: Host(`subdomain.domain.com`)
      middlewares:
        - default
      tls: {}
      service: subdomain

  services:

    subdomain:
      loadBalancer:
        servers:
          - url: "http://[local ip of varnish]:[local public varnish port]"
        passHostHeader: true

  middlewares:
    traefik-https-redirect:
      redirectScheme:
        scheme: https

    traefik-auth:
      basicAuth:
        users: "[username]:[password]"

    sslheader:
      headers:
        customRequestHeaders:
          X-Forwarded-Proto: https

    wss:
      headers:
        customRequestHeaders:
          X-Forwarded-Proto: https

    https-redirectscheme:
      redirectScheme:
        scheme: https
        permanent: true

    default-headers:
      headers:
        frameDeny: false
        browserXssFilter: false
        forceSTSHeader: true
        stsIncludeSubdomains: true
        stsPreload: true
        stsSeconds: 15552000
        customFrameOptionsValue: SAMEORIGIN

    security-headers:
      headers:
        customResponseHeaders:
          Permissions-Policy: "fullscreen=(*), display-capture=(self), accelerometer=(), battery=(), camera=(), autoplay=(self), vibrate=(self), geolocation=(self), midi=(self), notifications=(*), push=(*), microphone=(self), magnetometer=(self), gyroscope=(self), payment=(self)"
          X-Robots-Tag: "none,noarchive,nosnippet,notranslate,noimageindex"
          server: ""
          via: ""
          X-Forwarded-Proto: https
        sslProxyHeaders:
          X-Forwarded-Proto: https
        referrerPolicy: same-origin
        hostsProxyHeaders:
          - X-Forwarded-Host
        customRequestHeaders:
          X-Forwarded-Proto: https
        contentTypeNosniff: true

    default-csp:
      headers:
        contentsecuritypolicy: "script-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: wss: https:; img-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: wss: https:; font-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: wss: https:; connect-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: wss: https:; frame-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: wss: https:; object-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: wss: https:; media-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: wss: https:; prefetch-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: wss: https:; style-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: wss: https:; child-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: wss: https:; default-src 'self' 'unsafe-inline' 'unsafe-eval' blob: data: wss: https:;"

    hsts-headers:
      headers:
        forceStsHeader: true
        customResponseHeaders:
          X-Permitted-Cross-Domain-Policies: none

    cors-all:
      headers:
        customRequestHeaders:
          Access-Control-Allow-Origin: origin-list-or-null
          Sec-Fetch-Site: cross-site
          X-Forwarded-Proto: https
          Access-Control-Allow-Headers: "*, Authorization"
        customResponseHeaders:
          Access-Control-Allow-Origin: "*"
          Sec-Fetch-Site: cross-site
          X-Forwarded-Proto: https
          Access-Control-Allow-Headers: "*, Authorization"
        accessControlAllowMethods:
          - OPTIONS
          - POST
          - GET
          - PUT
          - DELETE
          - PATCH
        accessControlAllowHeaders:
          - "*, Authorization"
        accessControlExposeHeaders:
          - "*, Authorization"
        accessControlMaxAge: 100
        addVaryHeader: true
        accessControlAllowCredentials: true
        accessControlAllowOriginList:
          - "*"

    inflight-req:
      inFlightReq:
        amount: 64

    rate-limit:
      rateLimit:
        average: 64
        period: 1m
        burst: 128

    retry-attempts:
      retry:
        attempts: 8
        initialInterval: 1000ms

    compress-all:
      compress:
        excludedContentTypes:
          - text/event-stream
        minResponseBodyBytes: 1024

    waf:
      plugin:
        modsecurity:
          modSecurityUrl: http://modsecurity:80
          maxBodySize: 10485760

    default:
      chain:
        middlewares:
          - https-redirectscheme
          - default-headers
          - security-headers
          - default-csp
          - hsts-headers
          - inflight-req
          - rate-limit
          - retry-attempts
          - waf
          - cors-all
          - compress-all

varnish.vcl

[Fichier]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
# 2023-04-01

vcl 4.1;
import std;


# --------------------------------------------------
# Backends
# --------------------------------------------------

backend default none;

backend subdomain {
    .host = "[service ip]";
    .port = "[service port]";
}



# --------------------------------------------------
# VCL
# --------------------------------------------------

sub vcl_recv {

    # Normalize host header
    set req.http.host = std.tolower(req.http.host);

    # Normalize url
    set req.url = std.tolower(req.url);

    # Remove empty query string parameters
    # e.g.: www.example.com/index.html?
    if (req.url ~ "\?$") {
        set req.url = regsub(req.url, "\?$", "");
    }

    # Remove port number from host header
    set req.http.Host = regsub(req.http.Host, ":[0-9]+", "");

    # Sorts query string parameters alphabetically for cache normalization purposes
    set req.url = std.querysort(req.url);

    # Remove the proxy header to mitigate the httpoxy vulnerability
    # See https://httpoxy.org/
    unset req.http.proxy;

    # Add X-Forwarded-Proto header when using https
    if (!req.http.X-Forwarded-Proto && (std.port(server.ip) == 443)) {
        set req.http.X-Forwarded-Proto = "https";
    }

    # Remove cookies except for these url:
    #  /admin
    #  /ghost
    if (
           !(req.url ~ "^/admin/")
        && !(req.url ~ "^/ghost/")
       ) {
        unset req.http.Cookie;
    }

    # Remove has_js and Google Analytics __* cookies.
    set req.http.Cookie = regsuball(req.http.Cookie, "(^|;\s*)(_[_a-z]+|has_js)=[^;]*", "");

    # Remove a ";" prefix, if present.
    set req.http.Cookie = regsub(req.http.Cookie, "^;\s*", "");


    #
    # Setup backend depending on 'host' name
    #
        if (req.http.host ~ "subdomain.domain.com")         { set req.backend_hint = subdomain;            }


    # Pipe (direct connection on to the backend) for websocket
    if (req.http.upgrade ~ "(?i)websocket") {
        return (pipe);
    }

    # Non-RFC2616 or CONNECT which is weird
    if (req.method != "GET" &&
        req.method != "HEAD" &&
        req.method != "PUT" &&
        req.method != "POST" &&
        req.method != "TRACE" &&
        req.method != "OPTIONS" &&
        req.method != "DELETE") {
            return (pipe);
    }

    # Don't cache for these url:
    #  /api
    #  /admin
    #  /ghost
    #  /p
    if (req.url ~ "/(api|admin|p|ghost)/") {
           return (pass);
    }

    # Mark static files with the X-Static-File header, and remove any cookies
    if (
        req.url ~ "^[^?]*\.(7z|avi|avif|bmp|bz2|css|csv|doc|docx|eot|flac|flv|gif|gz|ico|jpeg|jpg|js|json|less|mka|mkv|mov|mp3|mp4|mpeg|mpg|odt|ogg|ogm|opus|otf|pdf|png|ppt|pptx|rar|rtf|svg|svgz|swf|tar|tbz|tgz|ttf|txt|txz|wav|webm|webp|woff|woff2|xls|xlsx|xml|xz|zip)(\?.*)?$"
        ) {
        set req.http.X-Static-File = "true";
        unset req.http.Cookie;
    }

    return (hash);
}

sub vcl_hash {

    # Normalize url
    set req.url = std.tolower(req.url);

    hash_data(req.url);

    if (req.http.host) {
        hash_data(req.http.host);
    } else {
        hash_data(server.ip);
    }

    return (lookup);
}

sub vcl_backend_response {

    # Define grace
    set beresp.grace = 2m;
    set beresp.keep = 8m;

    # Here you clean the response headers, removing silly Set-Cookie headers and other mistakes your backend does
    # Inject URL & Host header into the object for asynchronous banning purposes
    set beresp.http.x-url = bereq.url;
    set beresp.http.x-host = bereq.http.host;

    # Default TTL
    set beresp.ttl = 60s;

    if (bereq.url ~ "^/static/") {
        set beresp.ttl = 1d;
    }

    # Keep the response in cache for 4 hours if the response has validating headers
    if (beresp.http.ETag || beresp.http.Last-Modified) {
        set beresp.keep = 4h;
    }

    # Allow GZIP compression on all JavaScript/CSS files and all text-based content
    # Allow caching extension
    if (beresp.http.content-type ~
        "text/plain|text/css|application/json|application/x-javascript|text/xml|application/xml|application/xml+rss|text/javascript"
        ) {
        set beresp.do_gzip = true;
        set beresp.http.cache-control = "public, max-age=1209600";
    }

    # Remove the Set-Cookie header for cacheable content
    # Only for HTTP GET & HTTP HEAD requests
    if (beresp.ttl > 0s && (bereq.method == "GET" || bereq.method == "HEAD")) {
        unset beresp.http.set-cookie;
    }

    # Don't cache content with a negative TTL
    # Don't cache content for no-cache or no-store content
    # Don't cache content where all headers are varied
    if (    beresp.ttl <= 0s
        ||  beresp.http.Surrogate-control ~ "no-store"
        ||  (!beresp.http.Surrogate-Control && beresp.http.Cache-Control ~ "no-cache|no-store")
        ||  beresp.http.Vary == "*")
       {
            # Mark as Hit-For-Pass for the next 2 minutes
            set beresp.ttl = 120s;
            set beresp.uncacheable = true;
    }

    # Cache only successfully responses
    if (
           beresp.status != 200
        && beresp.status != 410
        && beresp.status != 301
        && beresp.status != 302
        && beresp.status != 304
        && beresp.status != 307
        ) {
        set beresp.http.X-Cacheable = "NO:UNCACHEABLE";
        set beresp.ttl = 10s;
        set beresp.uncacheable = true;
    }
    else {
        # If we dont get a Cache-Control header from the backend we default to cache for all objects
        if (!beresp.http.Cache-Control) {
            set beresp.ttl = 1h;
            set beresp.http.X-Cacheable = "YES:FORCED";
        }

        # If the file is marked as static we cache it
        if (bereq.http.X-Static-File == "true") {
            unset beresp.http.Set-Cookie;
            set beresp.http.X-Cacheable = "YES:FORCED:STATIC";
            set beresp.ttl = 1h;
        }

        if (beresp.http.Set-Cookie) {
            set beresp.http.X-Cacheable = "NO:GOTCOOKIES";
        }
        elseif (beresp.http.Cache-Control ~ "private") {

            if (beresp.http.Cache-Control ~ "public" && bereq.http.X-Static-File == "true" ) {
                set beresp.http.Cache-Control = regsub(beresp.http.Cache-Control, "private,", "");
                set beresp.http.Cache-Control = regsub(beresp.http.Cache-Control, "private", "");
                set beresp.http.X-Cacheable = "YES";
            }
            elseif (bereq.http.X-Static-File == "true" && (beresp.http.Content-type ~ "image\/webp" || beresp.http.Content-type ~ "image\/avif") )
            {
                set beresp.http.Cache-Control = regsub(beresp.http.Cache-Control, "private,", "");
                set beresp.http.Cache-Control = regsub(beresp.http.Cache-Control, "private", "");
                set beresp.http.X-Cacheable = "YES";
            }
            else {
                set beresp.http.X-Cacheable = "NO:CACHE-CONTROL=PRIVATE";
            }
        }
    }

    return (deliver);
}

sub vcl_hit {

    if (obj.ttl >= 0s) {
        return (deliver);
    }

    if (std.healthy(req.backend_hint)) {
        if (obj.ttl + 300s > 0s) {
            # Hit after TTL expiration, but within grace period
            set req.http.grace = "normal (healthy server)";
            return (deliver);
        } else {
            # Hit after TTL and grace expiration
            return (restart);
        }
    } else {
        # Server is not healthy, retrieve from cache
        set req.http.grace = "unlimited (unhealthy server)";
        return (deliver);
    }

    return (restart);
}

sub vcl_deliver {

    # Debug header
    if (req.http.X-Cacheable) {
        set resp.http.X-Cacheable = req.http.X-Cacheable;
    }
    elseif (obj.uncacheable) {
        if (!resp.http.X-Cacheable) {
            set resp.http.X-Cacheable = "NO:UNCACHEABLE";
        }
    }
    elseif (!resp.http.X-Cacheable) {
        set resp.http.X-Cacheable = "YES";
    }
    # End Debug Header

    if (resp.http.X-Varnish ~ "[0-9]+ +[0-9]+") {
        set resp.http.X-Cache = "HIT";
      } else {
        set resp.http.X-Cache = "MISS";
    }
    set resp.http.X-Cache-Hits = obj.hits;

    return (deliver);
}

sub vcl_pipe {

    if (req.http.upgrade) {
        set bereq.http.upgrade = req.http.upgrade;
        set bereq.http.connection = req.http.connection;
    }
}

datas/f2b/action.d/ban-cloudflare.conf

[Fichier]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
#
# Author: Mike Rushton
#
# IMPORTANT
#
# Please set jail.local's permission to 640 because it contains your CF API key.
#
# This action depends on curl (and optionally jq).
# Referenced from http://www.normyee.net/blog/2012/02/02/adding-cloudflare-support-to-fail2ban by NORM YEE
#
# To get your CloudFlare API Key: https://www.cloudflare.com/a/account/my-account
#
# CloudFlare API error codes: https://www.cloudflare.com/docs/host-api.html#s4.2

[Definition]

# Option:  actionstart
# Notes.:  command executed on demand at the first ban (or at the start of Fail2Ban if actionstart_on_demand is set to false).
# Values:  CMD
#
actionstart = bash /data/action.d/pushover.sh -a start

# Option:  actionstop
# Notes.:  command executed at the stop of jail (or at the end of Fail2Ban)
# Values:  CMD
#
actionstop = bash /data/action.d/pushover.sh -a stop

# Option:  actioncheck
# Notes.:  command executed once before each actionban command
# Values:  CMD
#
actioncheck =

# Option:  actionban
# Notes.:  command executed when banning an IP. Take care that the
#          command is executed with Fail2Ban user rights.
# Tags:    <ip>  IP address
#          <failures>  number of failures
#          <time>  unix timestamp of the ban time
# Values:  CMD
#
# API v1
#actionban = curl -s -o /dev/null https://www.cloudflare.com/api_json.html -d 'a=ban' -d 'tkn=<cftoken>' -d 'email=<cfuser>' -d 'key=<ip>'
# API v4
actionban = curl -s -o /dev/null -X POST <_cf_api_prms> \
            -d '{"mode":"block","configuration":{"target":"<cftarget>","value":"<ip>"},"notes":"Fail2Ban <name>"}' \
            <_cf_api_url>
            bash /data/action.d/pushover.sh -b <ip> -r "above reasons on Cloudflare - <name>"

# Option:  actionunban
# Notes.:  command executed when unbanning an IP. Take care that the
#          command is executed with Fail2Ban user rights.
# Tags:    <ip>  IP address
#          <failures>  number of failures
#          <time>  unix timestamp of the ban time
# Values:  CMD
#
# API v4
actionunban = id=$(curl -s -X GET <_cf_api_prms> \
                   "<_cf_api_url>?mode=block&configuration_target=<cftarget>&configuration_value=<ip>&page=1&per_page=1&notes=Fail2Ban%%20<name>" \
                   | { jq -r '.result[0].id' 2>/dev/null || tr -d '\n' | sed -nE 's/^.*"result"\s*:\s*\[\s*\{\s*"id"\s*:\s*"([^"]+)".*$/\1/p'; })
              if [ -z "$id" ]; then echo "<name>: id for <ip> cannot be found"; exit 0; fi;
              curl -s -o /dev/null -X DELETE <_cf_api_prms> "<_cf_api_url>/$id"
              bash /data/action.d/pushover.sh -u <ip> -r "above reasons on Cloudflare - <name>"

_cf_api_url = https://api.cloudflare.com/client/v4/user/firewall/access_rules/rules
_cf_api_prms = -H 'X-Auth-Email: <cfuser>' -H 'X-Auth-Key: <cftoken>' -H 'Content-Type: application/json'

[Init]

cftoken = [cloudflare api key]
cfuser = [cloudflare email]

cftarget = ip

[Init?family=inet6]
cftarget = ip6

datas/f2b/action.d/ban-traefik.conf

[Fichier]

1
2
3
[Definition]
actionban = iptables -I DOCKER-USER -m string --algo bm --string 'X-Forwarded-For: <ip>' -j DROP
actionunban = iptables -D DOCKER-USER -m string --algo bm --string 'X-Forwarded-For: <ip>' -j DROP

datas/f2b/action.d/notifications.conf

[Fichier]

1
2
3
4
5
6
7
8
9
10
[Definition]

actionstart = bash /data/action.d/pushover.sh -a start
actionstop = bash /data/action.d/pushover.sh -a stop

actionban = iptables -I DOCKER-USER -m string --algo bm --string 'X-Forwarded-For: <ip>' -j DROP
			bash /data/action.d/pushover.sh -b <ip> -r "forceful browsing on <F-CONTAINER> (<name>)"

actionunban = iptables -D DOCKER-USER -m string --algo bm --string 'X-Forwarded-For: <ip>' -j DROP
			  bash /data/action.d/pushover.sh -u <ip>

datas/f2b/action.d/pushover.sh

[Fichier]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
#!/bin/bash
# 2022-02-24
#
# Send Fail2ban notifications
#

function talkToBot() {
	TITLE="Traefik/fail2Ban"
	MESSAGE="$1"
	curl -X POST -d "urls=pover://[pushover user key]@[pushover application key]&title=${TITLE}&body=${MESSAGE}" http://[apprise ip]:[apprise port]/notify > /dev/null 2>&1
}

if [ $# -eq 0 ]; then
    echo "Usage $0 -a ( start || stop ) || -b $IP || -u $IP || -r $REASON"
    exit 1;
fi

while getopts "a:b:u:r:" opt; do
    case "$opt" in
        a)
            action=$OPTARG
        ;;
        b)
            ban=y
            ip_add_ban=$OPTARG
        ;;
        u)
            unban=y
            ip_add_unban=$OPTARG
        ;;
        r)
            reason=$OPTARG
        ;;
        ?)
            echo "Invalid option. -$OPTARG"
            exit 1
        ;;
    esac
done

if [[ ! -z ${action} ]]; then
    case "${action}" in
        start)
            talkToBot "Fail2ban has been started"
        ;;
        stop)
            talkToBot "Fail2ban has been stopped"
        ;;
        *)
            echo "Incorrect option"
            exit 1;
        ;;
    esac
elif [[ ${ban} == "y" ]]; then
    talkToBot "The IP ${ip_add_ban} has been banned due to ${reason}. https://ipinfo.io/${ip_add_ban}"
    exit 0;
elif [[ ${unban} == "y" ]]; then
    talkToBot "The IP: ${ip_add_unban} has been unbanned."
    exit 0;
else
    info
fi

datas/f2b/filter.d/traefik.conf

[Fichier]

1
2
3
4
5
6
7
8
9
10
11
12
13
[INCLUDES]

[Definition]

# fail regex based on traefik JSON access logs with enabled user agent logging
failregex = ^{"ClientAddr":"<F-CLIENTADDR>.*</F-CLIENTADDR>","ClientHost":"<HOST>","ClientPort":"<F-CLIENTPORT>.*</F-CLIENTPORT>","ClientUsername":"<F-CLIENTUSERNAME>.*</F-CLIENTUSERNAME>","DownstreamContentSize":<F-DOWNSTREAMCONTENTSIZE>.*</F-DOWNSTREAMCONTENTSIZE>,"DownstreamStatus":<F-DOWNSTREAMSTATUS>.*</F-DOWNSTREAMSTATUS>,"Duration":<F-DURATION>.*</F-DURATION>,"OriginContentSize":<F-ORIGINCONTENTSIZE>.*</F-ORIGINCONTENTSIZE>,"OriginDuration":<F-ORIGINDURATION>.*</F-ORIGINDURATION>,"OriginStatus":(405|404|403|402|401),"Overhead":<F-OVERHEAD>.*</F-OVERHEAD>,"RequestAddr":"<F-REQUESTADDR>.*</F-REQUESTADDR>","RequestContentSize":<F-REQUESTCONTENTSIZE>.*</F-REQUESTCONTENTSIZE>,"RequestCount":<F-REQUESTCOUNT>.*</F-REQUESTCOUNT>,"RequestHost":"<F-CONTAINER>.*</F-CONTAINER>","RequestMethod":"<F-REQUESTMETHOD>.*</F-REQUESTMETHOD>","RequestPath":"<F-REQUESTPATH>.*</F-REQUESTPATH>","RequestPort":"<F-REQUESTPORT>.*</F-REQUESTPORT>","RequestProtocol":"<F-REQUESTPROTOCOL>.*</F-REQUESTPROTOCOL>","RequestScheme":"<F-REQUESTSCHEME>.*</F-REQUESTSCHEME>","RetryAttempts":<F-RETRYATTEMPTS>.*</F-RETRYATTEMPTS>,.*"StartLocal":"<F-STARTLOCAL>.*</F-STARTLOCAL>","StartUTC":"<F-STARTUTC>.*</F-STARTUTC>","TLSCipher":"<F-TLSCIPHER>.*</F-TLSCIPHER>","TLSVersion":"<F-TLSVERSION>.*</F-TLSVERSION>","entryPointName":"<F-ENTRYPOINTNAME>.*</F-ENTRYPOINTNAME>","level":"<F-LEVEL>.*</F-LEVEL>","msg":"<F-MSG>.*</F-MSG>",("request_User-Agent":"<F-USERAGENT>.*</F-USERAGENT>",){0,1}?"time":"<F-TIME>.*</F-TIME>"}$

# custom date pattern for traefik JSON access logs
# based on https://github.com/fail2ban/fail2ban/issues/2558#issuecomment-546738270
datepattern = "StartLocal"\s*:\s*"%%Y-%%m-%%d[T]%%H:%%M:%%S\.%%f\d*(%%z)?",

# ignore common errors like missing media files or JS/CSS/TXT/ICO stuff
ignoreregex = ^{"ClientAddr":"<F-CLIENTADDR>.*</F-CLIENTADDR>","ClientHost":"<HOST>","ClientPort":"<F-CLIENTPORT>.*</F-CLIENTPORT>","ClientUsername":"<F-CLIENTUSERNAME>.*</F-CLIENTUSERNAME>","DownstreamContentSize":<F-DOWNSTREAMCONTENTSIZE>.*</F-DOWNSTREAMCONTENTSIZE>,"DownstreamStatus":<F-DOWNSTREAMSTATUS>.*</F-DOWNSTREAMSTATUS>,"Duration":<F-DURATION>.*</F-DURATION>,"OriginContentSize":<F-ORIGINCONTENTSIZE>.*</F-ORIGINCONTENTSIZE>,"OriginDuration":<F-ORIGINDURATION>.*</F-ORIGINDURATION>,"OriginStatus":(405|404|403|402|401),"Overhead":<F-OVERHEAD>.*</F-OVERHEAD>,"RequestAddr":"<F-REQUESTADDR>.*</F-REQUESTADDR>","RequestContentSize":<F-REQUESTCONTENTSIZE>.*</F-REQUESTCONTENTSIZE>,"RequestCount":<F-REQUESTCOUNT>.*</F-REQUESTCOUNT>,"RequestHost":"<F-REQUESTHOST>.*</F-REQUESTHOST>","RequestMethod":"<F-REQUESTMETHOD>.*</F-REQUESTMETHOD>","RequestPath":"<F-REQUESTPATH>.*(\.webp|\.txt|\.webp|\.ico|\.js|\.css|\.ttf|\.woff|\.woff2)(/)*?</F-REQUESTPATH>","RequestPort":"<F-REQUESTPORT>.*</F-REQUESTPORT>","RequestProtocol":"<F-REQUESTPROTOCOL>.*</F-REQUESTPROTOCOL>","RequestScheme":"<F-REQUESTSCHEME>.*</F-REQUESTSCHEME>","RetryAttempts":<F-RETRYATTEMPTS>.*</F-RETRYATTEMPTS>,.*"StartLocal":"<F-STARTLOCAL>.*</F-STARTLOCAL>","StartUTC":"<F-STARTUTC>.*</F-STARTUTC>","TLSCipher":"<F-TLSCIPHER>.*</F-TLSCIPHER>","TLSVersion":"<F-TLSVERSION>.*</F-TLSVERSION>","entryPointName":"<F-ENTRYPOINTNAME>.*</F-ENTRYPOINTNAME>","level":"<F-LEVEL>.*</F-LEVEL>","msg":"<F-MSG>.*</F-MSG>",("request_User-Agent":"<F-USERAGENT>.*</F-USERAGENT>",){0,1}?"time":"<F-TIME>.*</F-TIME>"}$

datas/f2b/fail.d/jail.local

[Fichier]

1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
[DEFAULT]
# "bantime.increment" allows to use database for searching of previously banned ip's to increase a
# default ban time using special formula, default it is banTime * 1, 2, 4, 8, 16, 32...
bantime.increment = true

# "bantime.rndtime" is the max number of seconds using for mixing with random time
# to prevent "clever" botnets calculate exact time IP can be unbanned again:
bantime.rndtime = 2048

# following example can be used for small initial ban time (bantime=60) - it grows more aggressive at begin,
# for bantime=60 the multipliers are minutes and equal: 5 min, 30 min, 1 hour, 5 hour, 12 hour, 1 day, 2 day
bantime.multipliers = 1 5 30 60 300 720 1440 2880

[traefik]
# bots that trigger too many error codes like 404, 403 etc.
enabled = true
# ignore cloudflare cdn and private ip addresses
ignoreip = 103.21.244.0/22 103.22.200.0/22 103.31.4.0/22 104.16.0.0/13 104.24.0.0/14 108.162.192.0/18 131.0.72.0/22 141.101.64.0/18 162.158.0.0/15 172.64.0.0/13 173.245.48.0/20 188.114.96.0/20 190.93.240.0/20 197.234.240.0/22 198.41.128.0/17 2400:cb00::/32 2606:4700::/32 2803:f800::/32 2405:b500::/32 2405:8100::/32 2a06:98c0::/29 2c0f:f248::/32 127.0.0.0/8 10.0.0.0/8 172.27.0.0/16 192.168.0.0/16
filter = traefik
logpath = /var/log/traefik/traefik.log
# this action bans every IP via DOCKER-USER chain. So the IP won't be able to access docker containers!
# Note: This only works for containers that don't use the dockernet MACVLAN interface
chain = DOCKER-USER
action = ban-traefik
		 ban-cloudflare
maxretry = 15
findtime = 60
bantime = 600

Mise en place

Je partirai du principe que vous utilisez Portainer pour gérer vos stack Docker.

Il vous faudra créer les répertoires suivants (ou adapter au besoin) :

  • /mnt/varnish
  • /opt/docker/traefik/conf/
  • /opt/docker/traefik/datas/
  • /opt/docker/traefik/datas/log/
  • /opt/docker/traefik/datas/f2b/

Et une fois ceci fait, vous copiez les fichiers présentés ci-dessus dans les chemins tels qu’indiqué dans le docker-compose.yml.

Lorsque vous aurez mis en place les fichiers et adapter, éventuellement, les chemins ou les noms, il vous faudra modifier les informations que j’ai mises entre crochets dans les différents fichiers.

Ces informations doivent refléter votre propre besoin.

Lorsque c’est fait, vous créez une nouvelle stack sous Portainer et copier/coller le contenu du fichier docker-compose.yml dans l’éditeur et vous lancez sa création.

Précisions

Avec cette méthode, vous disposerez des fonctionnalités suivantes :

  • Équilibrage distribué de charge
  • Cache de contenu
  • SSL (HTTPS) automatique
  • Certificats SSL génériques (wildcard certificates)
  • Compatibilité HTTP/3
  • Protection CORS
  • Limitation des pics de charge
  • Prévention des attaques de sécurités grâce à ModSecurity
  • Prévention des attaques par brute force grâce à Fail2Ban

NB :

Le middleware Traefik cors-all est à utiliser avec parcimonie, car il est relativement laxiste…

Conclusion

Il s’agit d’une mise à jour de mon article précédent sur Traefik (et Varnish) qui lui ajoute une couche de protection et de sécurité simple, mais importante.

Dans le cadre d’un HomeLab, il permet de mettre des services web à disposition, tout en disposant d’une bonne sécurité de base (ssl/http, gestion de la charge).

À vous de l’adapter en fonction de vos besoins !

Cet article est sous licence CC BY 4.0 par l'auteur.

© 2022- Olivier. Certains droits réservés.

Propulsé par τζ avec le thème Χ