Compare commits

...

14 Commits

10 changed files with 261 additions and 99 deletions

View File

@@ -48,13 +48,13 @@ label:
- "traefik.http.routers.service-name.entrypoints=websecure" - "traefik.http.routers.service-name.entrypoints=websecure"
``` ```
Besides the entrypoint setup, I add cloudflare proxy (for exposing real ip to access.log for crowdsec to read), crowdsec-firewall-bouncer, compression with brotli middlrewares method in traefik.yml and dynamic.yml Besides the entrypoint setup, I add CrowdSec firewall bouncer plus a compression middleware (brotli/gzip/zstd) defined in `dynamic.yml`. Cloudflares IP ranges are injected directly into `traefik.yml` by a helper script, so no extra plugin middleware is required anymore.
Adding middlewares is also guided by labels: Adding middlewares is also guided by labels:
```yaml ```yaml
label: label:
- "traefik.http.routers.service-name.middlewares=cloudflarewarp@file,crowdsec@file,compress-middleware@file" - "traefik.http.routers.service-name.middlewares=crowdsec@file,compress-middleware@file"
``` ```
The order of middlewares is meaningful. The order of middlewares is meaningful.
@@ -73,7 +73,7 @@ labels:
- "traefik.http.routers.ghost.rule=Host(`blog.gbanyan.net`)" - "traefik.http.routers.ghost.rule=Host(`blog.gbanyan.net`)"
- "traefik.http.services.ghost.loadbalancer.server.port=2368" - "traefik.http.services.ghost.loadbalancer.server.port=2368"
- "traefik.http.routers.ghost.tls.certresolver=letsencrypt" - "traefik.http.routers.ghost.tls.certresolver=letsencrypt"
- "traefik.http.routers.ghost.middlewares=cloudflarewarp@file,crowdsec@file,compress-middleware@file" - "traefik.http.routers.ghost.middlewares=crowdsec@file,compress-middleware@file"
- "com.centurylinklabs.watchtower.enable=true" - "com.centurylinklabs.watchtower.enable=true"
- "traefik.docker.network=traefik_default" - "traefik.docker.network=traefik_default"
``` ```
@@ -81,3 +81,25 @@ labels:
I mount the access.log for crowdsec firewall to read. I mount the access.log for crowdsec firewall to read.
PS: Because I access my traefik dashboard through my local network. I commented out the authetication method for dashboard. PS: Because I access my traefik dashboard through my local network. I commented out the authetication method for dashboard.
## Discussion and Changelog
1. Traefik vs Nginx
- Performance: Nginx is still better at high traffic. After all it is written in C. Traefik 3 though claims it has higher 20% performance than before. The latency still showed a little higher than nginx.
- Docker Deployment Ease: Traefik is easier for docker service deployment. In my environment, I can assign each docker stack with labels and then guides the traefik to add Let's encrypt SSL.
2. ChangeLog:
- 2025.4.21 Add the defaulthost rule for container name for lazy writing. But commented out for precision.
- 2025.4.21 Fix the trusted IP settings; later replaced by an internal updater instead of the traefik-plugin-cloudflare.
- 2025.4.18 Add Souin HTTP Cache Middleware (in feature branch, not merge into main)
- 2025.4.18 Temp disable the compression middleware. It has MIME type bugs.
## Notes on Host Networking
Traefik currently runs with `network_mode: host` so it can bind directly to both `10.0.0.225` (public) and `192.168.50.4` (internal) entrypoints. Moving back to bridge mode would break that dual-IP isolation because Docker cannot publish the same container port on two different host interfaces. Host networking also means:
- Traefik reaches app containers like any other host process, ignoring `traefik.docker.network` labels.
- Linux handles firewalling/routing between the two interfaces; Dockers conntrack optimizations arent used.
If you ever want to switch to bridge networking, youd need either separate Traefik instances (one per subnet) or an external L4 proxy in front of a single Traefik that listens on generic `:80/:443` ports. For now the host-mode trade-off is intentional to keep the internal/external split simple.

View File

@@ -39,3 +39,12 @@ services:
# external: true # external: true
# internal_traefik_default: # internal_traefik_default:
# external: true # external: true
networks:
default:
name: traefik_default
driver: bridge
ipam:
config:
- subnet: 172.19.0.0/16
gateway: 172.19.0.1

View File

@@ -0,0 +1,15 @@
http:
middlewares:
compress-middleware:
compress:
encodings:
- zstd
- br
- gzip
minResponseBodyBytes: 1024
includedContentTypes:
- text/html
- text/css
- application/javascript
- application/json
- text/plain

View File

@@ -0,0 +1,9 @@
http:
middlewares:
crowdsec:
plugin:
bouncer:
enabled: true
crowdsecMode: stream
crowdsecLapiHost: "localhost:8080"
crowdsecLapiKey: gFJjSzdbB0GCe/1Y9HcxMPP1vQmoa4psZOFyleJZJVQ

View File

@@ -0,0 +1,6 @@
http:
middlewares:
retry-fast:
retry:
attempts: 2
initialInterval: 50ms

View File

@@ -0,0 +1,14 @@
http:
routers:
qbit:
rule: Host(`qbit.gbanyan.net`)
service: qbit
entryPoints:
- internal_websecure
tls:
certResolver: letsencrypt
services:
qbit:
loadBalancer:
servers:
- url: "http://192.168.50.4:8083"

View File

@@ -0,0 +1,7 @@
http:
serversTransports:
fast-upstreams:
maxIdleConnsPerHost: 64
forwardingTimeouts:
idleConnTimeout: 30s
responseHeaderTimeout: 15s

View File

@@ -1,54 +0,0 @@
http:
middlewares:
block-ip-access:
headers:
customRequestHeaders:
Host: "" # This will catch requests with no Host header or invalid ones
cloudflarewarp:
plugin:
cloudflare:
trustedCIDRs: []
overwriteRequestHeader: true
debug: true
crowdsec:
plugin:
bouncer:
enabled: true
crowdsecMode: stream
crowdsecLapiHost: "localhost:8080"
crowdsecLapiKey: gFJjSzdbB0GCe/1Y9HcxMPP1vQmoa4psZOFyleJZJVQ
compress-middleware:
compress:
encodings:
- zstd
- br
- gzip
defaultEncoding: zstd
includedContentTypes:
- text/html
- text/css
- application/javascript
- application/json
- text/plain
routers:
block-direct-access:
rule: "HostRegexp(`{host:.+}`)" # Matches any host
service: noop@internal
priority: 1 # Low priority to catch unmatched requests
entryPoints:
- web
- websecure
middlewares:
- block-ip-access
netdata:
rule: Host(`netdata.gbanyan.net`)
service: netdata
entryPoints: ["internal_websecure"]
tls:
certResolver: letsencrypt
services:
netdata:
loadBalancer:
servers:
- url: "http://127.0.0.1:19999"

107
scripts/update_cloudflare_ips.py Executable file
View File

@@ -0,0 +1,107 @@
#!/usr/bin/env python3
"""Refresh Cloudflare IP ranges in Traefik config and restart Traefik if needed."""
from __future__ import annotations
import hashlib
import subprocess
import sys
from pathlib import Path
from typing import Iterable, List
from urllib.request import Request, urlopen
CLOUDFLARE_IPV4_URL = "https://www.cloudflare.com/ips-v4"
CLOUDFLARE_IPV6_URL = "https://www.cloudflare.com/ips-v6"
REPO_ROOT = Path(__file__).resolve().parents[1]
TRAEFIK_CONFIG_PATH = REPO_ROOT / "traefik.yml"
ENTRYPOINTS = [" web:", " websecure:"]
USER_AGENT = "TraefikCloudflareUpdater/1.0 (+https://gbanyan.net)"
def fetch_ip_ranges() -> List[str]:
ips: List[str] = []
for url in (CLOUDFLARE_IPV4_URL, CLOUDFLARE_IPV6_URL):
req = Request(url, headers={"User-Agent": USER_AGENT})
with urlopen(req, timeout=10) as resp: # noqa: S310 (trusted upstream)
for raw_line in resp.read().decode().splitlines():
line = raw_line.strip()
if line and not line.startswith("#"):
ips.append(line)
# Remove duplicates while preserving order
seen = set()
deduped: List[str] = []
for ip in ips:
if ip not in seen:
deduped.append(ip)
seen.add(ip)
return deduped
def update_trusted_ips(original: str, ips: Iterable[str], entrypoint_label: str) -> str:
try:
entry_idx = original.index(entrypoint_label)
except ValueError:
raise RuntimeError(f"EntryPoint {entrypoint_label.strip()} not found in traefik.yml") from None
forwarded_token = "forwardedHeaders:\n"
fwd_idx = original.index(forwarded_token, entry_idx)
line_start = original.rfind("\n", 0, fwd_idx) + 1
indent = original[line_start:fwd_idx]
inner_indent = indent + " "
list_indent = inner_indent + " "
block_start = fwd_idx + len(forwarded_token)
cursor = block_start
while cursor < len(original):
next_newline = original.find("\n", cursor)
if next_newline == -1:
next_newline = len(original)
line = original[cursor:next_newline]
stripped = line.lstrip()
current_indent = len(line) - len(stripped)
if not stripped.startswith("-") and current_indent <= len(indent):
break
cursor = next_newline + 1
block_end = cursor
new_block = forwarded_token
new_block += f"{inner_indent}trustedIPs:\n"
for ip in ips:
new_block += f"{list_indent}- \"{ip}\"\n"
return original[:fwd_idx] + new_block + original[block_end:]
def main() -> None:
ips = fetch_ip_ranges()
config_text = TRAEFIK_CONFIG_PATH.read_text()
before_hash = hashlib.sha256(config_text.encode()).hexdigest()
for entry_label in ENTRYPOINTS:
config_text = update_trusted_ips(config_text, ips, entry_label)
after_hash = hashlib.sha256(config_text.encode()).hexdigest()
if after_hash == before_hash:
print("Traefik trusted IP ranges already up-to-date.")
return
TRAEFIK_CONFIG_PATH.write_text(config_text)
print(f"Updated {TRAEFIK_CONFIG_PATH} with {len(ips)} Cloudflare IP ranges.")
try:
subprocess.run(
["docker", "compose", "up", "-d", "traefik"],
check=True,
cwd=REPO_ROOT,
)
print("Traefik container restarted via docker compose.")
except subprocess.CalledProcessError as exc: # pragma: no cover
print("Failed to restart Traefik via docker compose:", exc, file=sys.stderr)
sys.exit(exc.returncode)
if __name__ == "__main__":
try:
main()
except Exception as exc: # noqa: BLE001
print(f"Error: {exc}", file=sys.stderr)
sys.exit(1)

View File

@@ -1,14 +1,27 @@
## STATIC CONFIGURATION ## STATIC CONFIGURATION
log: log:
level: "DEBUG" level: "INFO"
filePath: "/var/log/traefik/traefik.log" filePath: "/var/log/traefik/traefik.log"
accessLog: accessLog:
filePath: "/var/log/traefik/access.log" filePath: "/var/log/traefik/access.log"
bufferingSize: 256
filters: filters:
statusCodes: statusCodes:
- "200-299" # log successful http requests - "400-599" # focus on failed http requests
- "400-599" # log failed http requests fields:
defaultMode: drop
names:
ClientHost: keep
ClientPort: keep
Duration: keep
RequestMethod: keep
RequestPath: keep
RequestProtocol: keep
RouterName: keep
ServiceName: keep
ServiceURL: keep
Status: keep
api: api:
insecure: false insecure: false
@@ -18,31 +31,29 @@ entryPoints:
web: web:
address: "10.0.0.225:80" address: "10.0.0.225:80"
forwardedHeaders: forwardedHeaders:
trustedIPs: &trustedIps trustedIPs:
# Start of Cloudlare's public IP list - "173.245.48.0/20"
- 103.21.244.0/22 - "103.21.244.0/22"
- 103.22.200.0/22 - "103.22.200.0/22"
- 103.31.4.0/22 - "103.31.4.0/22"
- 104.16.0.0/13 - "141.101.64.0/18"
- 104.24.0.0/14 - "108.162.192.0/18"
- 108.162.192.0/18 - "190.93.240.0/20"
- 131.0.72.0/22 - "188.114.96.0/20"
- 141.101.64.0/18 - "197.234.240.0/22"
- 162.158.0.0/15 - "198.41.128.0/17"
- 172.64.0.0/13 - "162.158.0.0/15"
- 173.245.48.0/20 - "104.16.0.0/13"
- 188.114.96.0/20 - "104.24.0.0/14"
- 190.93.240.0/20 - "172.64.0.0/13"
- 197.234.240.0/22 - "131.0.72.0/22"
- 198.41.128.0/17 - "2400:cb00::/32"
- 2400:cb00::/32 - "2606:4700::/32"
- 2606:4700::/32 - "2803:f800::/32"
- 2803:f800::/32 - "2405:b500::/32"
- 2405:b500::/32 - "2405:8100::/32"
- 2405:8100::/32 - "2a06:98c0::/29"
- 2a06:98c0::/29 - "2c0f:f248::/32"
- 2c0f:f248::/32
# End of Cloudlare's public IP list
http: http:
redirections: # HTTPS redirection (80 to 443) redirections: # HTTPS redirection (80 to 443)
entryPoint: entryPoint:
@@ -51,9 +62,29 @@ entryPoints:
websecure: websecure:
address: "10.0.0.225:443" address: "10.0.0.225:443"
forwardedHeaders: forwardedHeaders:
# Reuse the list of Cloudflare's public IPs from above trustedIPs:
trustedIPs: *trustedIps - "173.245.48.0/20"
http3: {} - "103.21.244.0/22"
- "103.22.200.0/22"
- "103.31.4.0/22"
- "141.101.64.0/18"
- "108.162.192.0/18"
- "190.93.240.0/20"
- "188.114.96.0/20"
- "197.234.240.0/22"
- "198.41.128.0/17"
- "162.158.0.0/15"
- "104.16.0.0/13"
- "104.24.0.0/14"
- "172.64.0.0/13"
- "131.0.72.0/22"
- "2400:cb00::/32"
- "2606:4700::/32"
- "2803:f800::/32"
- "2405:b500::/32"
- "2405:8100::/32"
- "2a06:98c0::/29"
- "2c0f:f248::/32"
internal_web: internal_web:
address: "192.168.50.4:80" address: "192.168.50.4:80"
http: http:
@@ -63,9 +94,8 @@ entryPoints:
scheme: "https" scheme: "https"
internal_websecure: internal_websecure:
address: "192.168.50.4:443" address: "192.168.50.4:443"
http3: {}
metrics: metrics:
address: "127.0.0.1:8082" address: ":8082"
dashboard: dashboard:
address: "127.0.0.1:9090" address: "127.0.0.1:9090"
@@ -76,9 +106,9 @@ global:
providers: providers:
docker: docker:
exposedByDefault: false exposedByDefault: false
# network: traefik_default # Ensure this matches the Docker network # defaultRule: "Host(`{{ .ContainerName }}.gbanyan.net`)"
file: file:
filename: "/dynamic.yml" # Enable dynamic configuration file directory: "/dynamic.d"
certificatesResolvers: certificatesResolvers:
letsencrypt: letsencrypt:
acme: acme:
@@ -97,9 +127,6 @@ metrics:
experimental: experimental:
plugins: plugins:
cloudflare:
moduleName: github.com/agence-gaya/traefik-plugin-cloudflare
version: v1.2.0
bouncer: bouncer:
moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin moduleName: github.com/maxlerebourg/crowdsec-bouncer-traefik-plugin
version: v1.4.2 version: v1.4.2