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.
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]
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]
# 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]
# 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]
# 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]
#
# 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¬es=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\*\"\([^\"]+)\".\*\$//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]
[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]
[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]
#!/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]
[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]
[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 !