diff --git a/ansible/inventories/group_vars/all/main.yml b/ansible/inventories/group_vars/all/main.yml index eb7c495..a1806f2 100644 --- a/ansible/inventories/group_vars/all/main.yml +++ b/ansible/inventories/group_vars/all/main.yml @@ -20,9 +20,203 @@ cloudflare_api_token: "{{ vault_cloudflare_api_token }}" # Pocket ID configuration pocketid_encryption_key: "{{ vault_pocketid_encryption_key }}" -sonarr_host: 10.0.2.25 -sonarr_port: 8989 +auto_configure_traefik: + # arr + sonarr: + subdomain: "sonarr" + host: "10.0.2.25" + port: 8989 + auth_required: true + radarr: + subdomain: "radarr" + host: "10.0.2.25" + port: 7878 + auth_required: true + lidarr: + subdomain: "lidarr" + host: "10.0.2.25" + port: 8686 + auth_required: true + transmission: + subdomain: "transmission" + host: "10.0.2.25" + port: 9091 + auth_required: true + tdarr: + subdomain: "tdarr" + host: "10.0.2.25" + port: 8265 + auth_required: true + bazarr: + subdomain: "bazarr" + host: "10.0.2.25" + port: 6767 + auth_required: true + seerr: + subdomain: "overseerr" + host: "10.0.2.25" + port: 5055 + auth_required: false + prowlarr: + subdomain: "prowlarr" + host: "10.0.2.25" + port: 9696 + auth_required: true + unpackerr: + subdomain: "unpackerr" + host: "10.0.2.25" + port: 5656 + auth_required: true + homeassistant: + subdomain: "homeassistant" + host: "10.0.2.100" + port: 8123 + auth_required: false + + # media + plex: + subdomain: "plex" + host: "10.0.2.10" + port: 32400 + auth_required: false + tracearr: + subdomain: "tracearr" + host: "10.0.2.21" + port: 3000 + auth_required: true + tautulli: + subdomain: "tautulli" + host: "10.0.2.21" + port: 8181 + auth_required: true + + vaultwarden: + subdomain: "vaultwarden" + host: "10.0.2.27" + port: 8004 + auth_required: false + changedetection: + subdomain: "changedetection" + host: "10.0.2.24" + port: 5000 + auth_required: true + nextcloud: + subdomain: "cloud" + host: "10.0.2.30" + port: 8001 + auth_required: false + convertx: + subdomain: "convertx" + host: "10.0.2.43" + port: 3000 + auth_required: true + dawarich: + subdomain: "places" + host: "10.0.2.48" + port: 3000 + auth_required: false + frigate: + subdomain: "frigate" + host: "10.0.2.14" + port: 5000 + auth_required: true + droposs: + subdomain: "games" + host: "10.0.2.46" + port: 3000 + auth_required: false + # geoguessr: + # subdomain: "geoguessr" + # host: "10.0.2.39" + # port: 8080 + # auth_required: true + gitea: + subdomain: "gitea" + host: "10.0.2.28" + port: 3000 + auth_required: true + immich: + subdomain: "immich" + host: "10.0.2.18" + port: 2283 + auth_required: false + mastodon: + subdomain: "social" + host: "10.0.2.20" + port: 80 + auth_required: false + matrix: + subdomain: "chat" + host: "10.0.2.20" + port: 8008 + auth_required: false + mealie: + subdomain: "recipes" + host: "10.0.2.26" + port: 9000 + auth_required: false + truenas: + subdomain: "nas" + host: "10.0.2.200" + port: 80 + auth_required: true + paperless: + subdomain: "paperless" + host: "10.0.2.29" + port: 8003 + auth_required: true + pbs: + subdomain: "pbs" + host: "10.0.2.104" + port: 8007 + auth_required: true + # pinchflat: + # subdomain: "youtube" + # host: "10.0.2.23" + # port: 8081 + # auth_required: true + proxmox: + subdomain: "proxmox" + host: "10.0.2.2" + port: 8006 + auth_required: true + resume: + subdomain: "resume" + host: "10.0.2.53" + port: 3000 + auth_required: true + auth_bypass_paths: + - /lino + - /assets + - /api + speedtest-tracker: + subdomain: "fast" + host: "10.0.2.254" + port: 8765 + auth_required: true + stocks: + subdomain: "stocks" + host: "10.0.2.40" + port: 3333 + auth_required: false + super-productivity: + subdomain: "tasks" + host: "10.0.2.45" + port: 80 + auth_required: true + uptime-kuma: + subdomain: "uptime" + host: "10.0.2.203" + port: 3001 + auth_required: true + wealthfolio: + subdomain: "wealth" + host: "10.0.2.40" + port: 8088 + auth_required: true + +# Auth services configuration pocketid_host: 10.0.4.10 pocketid_port: 8001 diff --git a/ansible/roles/fail2ban/handlers/main.yml b/ansible/roles/fail2ban/handlers/main.yml new file mode 100644 index 0000000..46a2f6f --- /dev/null +++ b/ansible/roles/fail2ban/handlers/main.yml @@ -0,0 +1,6 @@ +--- +- name: Restart fail2ban + systemd: + name: fail2ban + state: restarted + enabled: yes diff --git a/ansible/roles/fail2ban/tasks/main.yml b/ansible/roles/fail2ban/tasks/main.yml new file mode 100644 index 0000000..180abd5 --- /dev/null +++ b/ansible/roles/fail2ban/tasks/main.yml @@ -0,0 +1,51 @@ +--- +- name: Install fail2ban + apt: + name: fail2ban + state: present + update_cache: yes + +- name: Ensure fail2ban filter directory exists + file: + path: /etc/fail2ban/filter.d + state: directory + mode: '0755' + +- name: Ensure fail2ban jail directory exists + file: + path: /etc/fail2ban/jail.d + state: directory + mode: '0755' + +- name: Ensure traefik log directory exists + file: + path: /var/log/traefik + state: directory + mode: '0755' + owner: root + group: root + +- name: Deploy Traefik fail2ban filters + template: + src: "{{ item }}" + dest: "/etc/fail2ban/filter.d/{{ item | basename | regex_replace('\\.j2$', '') }}" + mode: '0644' + loop: + - traefik-auth.conf.j2 + - traefik-404.conf.j2 + - traefik-ratelimit.conf.j2 + - traefik-badreq.conf.j2 + notify: Restart fail2ban + +- name: Deploy fail2ban jail configuration + template: + src: jail.local.j2 + dest: /etc/fail2ban/jail.d/traefik.local + mode: '0644' + notify: Restart fail2ban + +- name: Ensure fail2ban is enabled and started + systemd: + name: fail2ban + state: started + enabled: yes diff --git a/ansible/roles/fail2ban/templates/jail.local.j2 b/ansible/roles/fail2ban/templates/jail.local.j2 new file mode 100644 index 0000000..c06db1e --- /dev/null +++ b/ansible/roles/fail2ban/templates/jail.local.j2 @@ -0,0 +1,51 @@ +# Fail2ban jails for Traefik +# Each jail monitors different attack patterns + +[DEFAULT] +bantime = 3600 +findtime = 600 +maxretry = 5 + +# Authentication failures - strict rules +[traefik-auth] +enabled = true +port = http,https +filter = traefik-auth +logpath = /var/log/traefik/access.log +maxretry = 3 +findtime = 300 +bantime = 7200 +action = iptables-allports[name=traefik-auth] + +# 404 scanning/probing - moderate rules +[traefik-404] +enabled = true +port = http,https +filter = traefik-404 +logpath = /var/log/traefik/access.log +maxretry = 20 +findtime = 300 +bantime = 3600 +action = iptables-allports[name=traefik-404] + +# Rate limiting violations - strict rules +[traefik-ratelimit] +enabled = true +port = http,https +filter = traefik-ratelimit +logpath = /var/log/traefik/access.log +maxretry = 5 +findtime = 60 +bantime = 1800 +action = iptables-allports[name=traefik-ratelimit] + +# Bad requests - lenient rules +[traefik-badreq] +enabled = true +port = http,https +filter = traefik-badreq +logpath = /var/log/traefik/access.log +maxretry = 10 +findtime = 300 +bantime = 1800 +action = iptables-allports[name=traefik-badreq] diff --git a/ansible/roles/fail2ban/templates/traefik-404.conf.j2 b/ansible/roles/fail2ban/templates/traefik-404.conf.j2 new file mode 100644 index 0000000..560751a --- /dev/null +++ b/ansible/roles/fail2ban/templates/traefik-404.conf.j2 @@ -0,0 +1,9 @@ +# Fail2ban filter for Traefik 404 scanning/probing +# Blocks IPs that generate excessive 404 errors (scanning for vulnerabilities) + +[Definition] +failregex = ^.*"ClientAddr":":\d+".*"RequestMethod":"(GET|POST|PUT|DELETE|PATCH)".*"DownstreamStatus":404.*$ +ignoreregex = + +# Example log line (JSON): +# {"ClientAddr":"192.168.1.100:54321","DownstreamStatus":404,"RequestMethod":"GET",...} diff --git a/ansible/roles/fail2ban/templates/traefik-auth.conf.j2 b/ansible/roles/fail2ban/templates/traefik-auth.conf.j2 new file mode 100644 index 0000000..5a62f86 --- /dev/null +++ b/ansible/roles/fail2ban/templates/traefik-auth.conf.j2 @@ -0,0 +1,9 @@ +# Fail2ban filter for Traefik authentication failures +# Blocks IPs that repeatedly fail authentication (401 Unauthorized) + +[Definition] +failregex = ^.*"ClientAddr":":\d+".*"RequestMethod":"(GET|POST|PUT|DELETE|PATCH)".*"DownstreamStatus":401.*$ +ignoreregex = + +# Example log line (JSON): +# {"ClientAddr":"192.168.1.100:54321","DownstreamStatus":401,"RequestMethod":"GET",...} diff --git a/ansible/roles/fail2ban/templates/traefik-badreq.conf.j2 b/ansible/roles/fail2ban/templates/traefik-badreq.conf.j2 new file mode 100644 index 0000000..3791dc6 --- /dev/null +++ b/ansible/roles/fail2ban/templates/traefik-badreq.conf.j2 @@ -0,0 +1,9 @@ +# Fail2ban filter for Traefik bad requests +# Blocks IPs that generate excessive 4xx errors (bad requests, forbidden, etc.) + +[Definition] +failregex = ^.*"ClientAddr":":\d+".*"RequestMethod":"(GET|POST|PUT|DELETE|PATCH)".*"DownstreamStatus":4\d{2}.*$ +ignoreregex = ^.*"DownstreamStatus":(401|404|429).*$ + +# Catches all 4xx errors except 401, 404, 429 (handled by specific filters) +# Example: 400 Bad Request, 403 Forbidden, etc. diff --git a/ansible/roles/fail2ban/templates/traefik-ratelimit.conf.j2 b/ansible/roles/fail2ban/templates/traefik-ratelimit.conf.j2 new file mode 100644 index 0000000..883044c --- /dev/null +++ b/ansible/roles/fail2ban/templates/traefik-ratelimit.conf.j2 @@ -0,0 +1,9 @@ +# Fail2ban filter for Traefik rate limiting +# Blocks IPs that trigger rate limit responses (429 Too Many Requests) + +[Definition] +failregex = ^.*"ClientAddr":":\d+".*"RequestMethod":"(GET|POST|PUT|DELETE|PATCH)".*"DownstreamStatus":429.*$ +ignoreregex = + +# Example log line (JSON): +# {"ClientAddr":"192.168.1.100:54321","DownstreamStatus":429,"RequestMethod":"POST",...} diff --git a/ansible/roles/traefik/templates/docker-compose.yml.j2 b/ansible/roles/traefik/templates/docker-compose.yml.j2 index b2096d4..3c1f9c9 100644 --- a/ansible/roles/traefik/templates/docker-compose.yml.j2 +++ b/ansible/roles/traefik/templates/docker-compose.yml.j2 @@ -19,6 +19,7 @@ services: - ./data/traefik.yml:/traefik.yml:ro - ./data/dynamic:/etc/traefik/dynamic:ro - ./data/acme.json:/acme.json + - /var/log/traefik:/var/log/traefik labels: - "traefik.enable=true" diff --git a/ansible/roles/traefik/templates/remote-services.yml.j2 b/ansible/roles/traefik/templates/remote-services.yml.j2 index 6393038..40a2a9f 100644 --- a/ansible/roles/traefik/templates/remote-services.yml.j2 +++ b/ansible/roles/traefik/templates/remote-services.yml.j2 @@ -20,6 +20,7 @@ http: permanent: true routers: + # Static services - HTTPS traefik-secure: rule: "Host(`traefik.{{ domain }}`)" entryPoints: @@ -30,16 +31,6 @@ http: tls: certResolver: cloudflare - sonarr: - rule: "Host(`sonarr.{{ domain }}`)" - entryPoints: - - https - middlewares: - - pocketid-auth - service: sonarr - tls: - certResolver: cloudflare - pocketid: rule: "Host(`auth.{{ domain }}`)" entryPoints: @@ -55,22 +46,117 @@ http: service: tinyauth tls: certResolver: cloudflare + + # Static services - HTTP to HTTPS redirect + traefik-redirect: + rule: "Host(`traefik.{{ domain }}`)" + entryPoints: + - http + middlewares: + - traefik-https-redirect + service: api@internal + + pocketid-redirect: + rule: "Host(`auth.{{ domain }}`)" + entryPoints: + - http + middlewares: + - traefik-https-redirect + service: pocketid + + tinyauth-redirect: + rule: "Host(`auth-proxy.{{ domain }}`)" + entryPoints: + - http + middlewares: + - traefik-https-redirect + service: tinyauth + + # Auto-configured services - HTTPS +{% for service_name, config in auto_configure_traefik.items() %} +{% if config.auth_bypass_paths is defined %} + # {{ service_name }} - bypass paths (no auth) +{% for path in config.auth_bypass_paths %} + {{ service_name }}-bypass-{{ loop.index }}: + rule: "Host(`{{ config.subdomain }}.{{ domain }}`) && PathPrefix(`{{ path }}`)" + entryPoints: + - https + priority: 100 + service: {{ service_name }} + tls: + certResolver: cloudflare +{% endfor %} + # {{ service_name }} - default path (with auth if required) + {{ service_name }}: + rule: "Host(`{{ config.subdomain }}.{{ domain }}`)" + entryPoints: + - https + priority: 1 +{% if config.auth_required | default(true) %} + middlewares: + - pocketid-auth +{% endif %} + service: {{ service_name }} + tls: + certResolver: cloudflare +{% else %} + {{ service_name }}: + rule: "Host(`{{ config.subdomain }}.{{ domain }}`)" + entryPoints: + - https +{% if config.auth_required | default(true) %} + middlewares: + - pocketid-auth +{% endif %} + service: {{ service_name }} + tls: + certResolver: cloudflare +{% endif %} +{% endfor %} + + # Auto-configured services - HTTP to HTTPS redirect +{% for service_name, config in auto_configure_traefik.items() %} +{% if config.auth_bypass_paths is defined %} + # {{ service_name }} - bypass paths redirects +{% for path in config.auth_bypass_paths %} + {{ service_name }}-bypass-{{ loop.index }}-redirect: + rule: "Host(`{{ config.subdomain }}.{{ domain }}`) && PathPrefix(`{{ path }}`)" + entryPoints: + - http + priority: 100 + middlewares: + - traefik-https-redirect + service: {{ service_name }} +{% endfor %} +{% endif %} + # {{ service_name }} - default redirect + {{ service_name }}-redirect: + rule: "Host(`{{ config.subdomain }}.{{ domain }}`)" + entryPoints: + - http + middlewares: + - traefik-https-redirect + service: {{ service_name }} +{% endfor %} services: - sonarr: - loadBalancer: - passHostHeader: true - servers: - - url: "http://{{ sonarr_host }}:{{ sonarr_port }}" - pocketid: loadBalancer: passHostHeader: true servers: - - url: "http://{{ pocketid_host }}:{{ pocketid_port }}" + - url: "http://{{ pocketid_host }}:{{ pocketid_port }}" tinyauth: loadBalancer: passHostHeader: true servers: - url: "http://{{ tinyauth_host }}:{{ tinyauth_port }}" + + # Auto-configured services +{% for service_name, config in auto_configure_traefik.items() %} + {{ service_name }}: + loadBalancer: + passHostHeader: true + servers: + - url: "http://{{ config.host }}:{{ config.port }}" +{% endfor %} diff --git a/ansible/roles/traefik/templates/traefik.yml.j2 b/ansible/roles/traefik/templates/traefik.yml.j2 index 2824369..c61ede9 100644 --- a/ansible/roles/traefik/templates/traefik.yml.j2 +++ b/ansible/roles/traefik/templates/traefik.yml.j2 @@ -3,6 +3,13 @@ api: debug: true insecure: true +log: + level: INFO + +accessLog: + filePath: /var/log/traefik/access.log + format: json + entryPoints: http: address: ":80"