haproxy template: add support for JWT auth/authz, CORS, robots.txt, bots mitigation.

This commit is contained in:
Andrea Dell'Amico 2026-08-18 17:33:59 +02:00
parent 8ac379a553
commit 019e66b120
Signed by untrusted user: adellam
GPG Key ID: 147ABE6CEB9E20FF
3 changed files with 403 additions and 40 deletions

View File

@ -51,6 +51,51 @@ haproxy_docker_swarm_plain_http_port: 8080
haproxy_docker_swarm_plain_http_services: []
```
Optional HAProxy features
-------------------------
The generated `haproxy.cfg` can serve `robots.txt`, deny blacklisted user agents,
apply a per-source flood control with a browser-validation challenge, handle CORS
and enforce OIDC/JWT authentication. Everything is off by default; see
`defaults/main.yml` for the variables and the per-service opt-in fields.
**These features need files, and on Swarm HAProxy runs in a container.** The role
only writes the configuration: getting the files in place is a deployment
decision, and there are three ways to do it.
1. **Through the directory that is already mounted.** With
`haproxy_docker_mount_conf_file: true` the `haproxy` role bind-mounts
`/etc/haproxy` on the manager node to `/usr/local/etc/haproxy` inside the
container. Anything rendered under `/etc/haproxy/static/` and
`/etc/haproxy/lua/` is therefore visible to HAProxy with no additional volume,
and an in-place reload picks it up without recreating the service. The path
defaults in this role assume exactly this layout. It covers `robots.txt`, the
user-agent blacklists and `cors.lua`.
2. **Docker configs / secrets.** Cleaner distribution (the manager pushes them,
nothing to manage per node) but config objects are immutable: changing one
means creating a new object and updating the service, i.e. a rolling restart
instead of a reload. Reasonable for files that never change, awkward for
blacklists. Use a secret for the stats password if the config file is moved to
a config object, since config objects are not secret; HAProxy expands
`${VARIABLE}` from its environment, so a small entrypoint wrapper that exports
the secret file's content is enough.
3. **A custom image.** Required — not merely convenient — for the OIDC scripts:
they `require` cjson, socket, ssl and ltn12, and the stock
`haproxytech/haproxy-debian` image ships HAProxy built with Lua support but not
a single Lua module, which cannot be installed at run time. The build itself is
trivial, since that image is Debian based and the Debian/Ubuntu module packages
(`lua-cjson`, `lua-socket`, `lua-sec`) install for every Lua ABI from 5.1 to
5.4, so they cover whatever the image links. What it costs is owning an image
build and publish pipeline tied to HAProxy version bumps. `cors.lua` needs no
modules and works in the stock image.
Recommended split: option 1 for the static files and CORS, and either a custom
image or a separate `oauth2-proxy` service for OIDC. `oauth2-proxy` avoids the
image pipeline altogether and does not depend on the Lua code path, which has
never run in production.
Dependencies
------------

View File

@ -42,3 +42,97 @@ docker_swarm_keepalived_instance_name: 'VI_HAPROXY_1'
docker_swarm_haproxy_loglevel: '{{ haproxy_loglevel }}'
docker_swarm_haproxy_http2_enabled: true
docker_swarm_haproxy_backends_redirect_to_https: true
# NOTE ON PATHS. On Swarm, HAProxy runs in a container, so every path below is a
# path *inside that container*, not on the manager node. In the D4Science setup
# `haproxy_docker_mount_conf_file: true` already bind-mounts
# /etc/haproxy -> /usr/local/etc/haproxy:ro, so anything Ansible renders under
# /etc/haproxy/ on the managers is visible to HAProxy with no extra volume, and
# an in-place reload still picks it up. The defaults below assume that layout.
#
# Directory of the files served directly by HAProxy (robots.txt, user agent
# blacklists): render them into /etc/haproxy/static/ on the manager nodes.
docker_swarm_haproxy_static_dir: '/usr/local/etc/haproxy/static'
# HTTP basic authentication. Add one entry per userlist and reference it from a
# service with "basic_auth: <name>". Passwords are hashes, as produced by
# `mkpasswd -m sha-512` (do not commit them in clear text).
# docker_swarm_haproxy_userlists:
# - name: 'admins'
# users:
# - name: 'ops'
# password_hash: '$6$...'
docker_swarm_haproxy_userlists: []
docker_swarm_haproxy_basic_auth_realm: 'Restricted area'
# ---------------------------------------------------------------------------
# CORS, via https://github.com/haproxytech/haproxy-lua-cors
# Enable globally, then opt in per service with "cors: true" or with a dict
# overriding methods/origins/headers.
# ---------------------------------------------------------------------------
# cors.lua is plain Lua with no external module dependencies, so the stock
# haproxytech/haproxy-debian image runs it as soon as the file is reachable:
# render it into /etc/haproxy/lua/ on the manager nodes.
docker_swarm_haproxy_cors_enabled: false
docker_swarm_haproxy_cors_lua_path: '/usr/local/etc/haproxy/lua/cors.lua'
docker_swarm_haproxy_cors_methods: 'GET, POST, OPTIONS, PUT, DELETE'
docker_swarm_haproxy_cors_origins: '*'
docker_swarm_haproxy_cors_headers: 'Accept,Authorization,Cache-Control,Content-Type,DNT,If-Modified-Since,Keep-Alive,Origin,User-Agent,X-Mx-ReqToken,X-Requested-With,Accept-Language,X-CustomHeader,Content-Range,Range'
# ---------------------------------------------------------------------------
# Bot mitigation: user agent blacklist, per-source flood control and a
# browser-validation challenge. Enable globally, then opt in per service with
# "bot_mitigation: true" or with a dict overriding rate/bypass_networks/
# exempt_paths/validate_path.
#
# The counters live in a single table-holder backend shared by every service
# that opts in, so a client flooding one service is throttled everywhere. Set
# a per-service table only if you need independent counters.
# ---------------------------------------------------------------------------
docker_swarm_haproxy_bot_mitigation_enabled: false
docker_swarm_haproxy_bot_agents_blacklist_file: '/usr/local/etc/haproxy/static/agents_blacklist.txt'
docker_swarm_haproxy_bot_flood_table: 'bot_flood_st'
docker_swarm_haproxy_bot_flood_table_size: '100k'
docker_swarm_haproxy_bot_flood_table_expire: '10m'
docker_swarm_haproxy_bot_flood_rate_period: '30s'
docker_swarm_haproxy_bot_flood_rate: 3
docker_swarm_haproxy_bot_validate_path: '/validate-browser'
docker_swarm_haproxy_bot_validate_cookie: 'SERVER_VALIDATED'
docker_swarm_haproxy_bot_validate_cookie_max_age: 86400
docker_swarm_haproxy_bot_bypass_networks: []
docker_swarm_haproxy_bot_exempt_paths: []
# ---------------------------------------------------------------------------
# OIDC authentication and authorization, via the Lua scripts deployed by the
# haproxy_setup role (jwks_cache, jwt_oidc_auth, oidc_callback and optionally
# the accounting pair). Enable globally, then opt in per service:
#
# oidc:
# client_id: '{{ some_vaulted_client_id }}'
# service_key: 'api_d4science' # key in the Lua config.json "services"
# mode: 'authorize' # validate a token when presented
# # mode: 'authenticate' # no credentials, no entry: browsers are
# # # redirected to Keycloak, API clients 401
# protected_paths: ['/rest/']
# exempt_paths: ['/rest/public']
# ---------------------------------------------------------------------------
# WARNING: unlike cors.lua, these scripts require external Lua modules (cjson,
# socket, ssl, ltn12). The stock haproxytech/haproxy-debian image ships HAProxy
# built with Lua support but NO Lua module at all, and they cannot be installed
# at run time, so enabling this needs a custom image. Building one is easy (the
# image is Debian based: apt-get install lua-cjson lua-socket lua-sec, which ship
# modules for every Lua ABI including the 5.4 the image links). The real
# questions are whether we want to own an image build pipeline and whether we
# trust the Lua code: see the role README before turning it on.
docker_swarm_haproxy_oidc_enabled: false
docker_swarm_haproxy_oidc_lua_dir: '/usr/local/etc/haproxy/lua'
docker_swarm_haproxy_oidc_callback_path: '/oidc/callback'
docker_swarm_haproxy_oidc_realm: 'd4science'
docker_swarm_haproxy_oidc_accounting_enabled: false
docker_swarm_haproxy_oidc_exempt_paths:
- '/health'
- '/status'
- '/metrics'
- '/robots.txt'
- '/.well-known'
- '/favicon.ico'

View File

@ -1,6 +1,117 @@
#
# https://www.haproxy.com/blog/haproxy-on-docker-swarm-load-balancing-and-dns-service-discovery/
#
# This file is managed by Ansible. Optional features, all disabled by default:
#
# docker_swarm_haproxy_cors_enabled CORS handling via haproxy-lua-cors
# docker_swarm_haproxy_bot_mitigation_enabled user-agent blacklist + flood control
# + browser-validation challenge
# docker_swarm_haproxy_oidc_enabled JWT validation / OIDC login via Lua
# docker_swarm_haproxy_userlists HTTP basic auth for admin UIs
#
# All four need files to be reachable *inside the HAProxy container*: see
# "Optional HAProxy features" in the role README. In short: render them under
# /etc/haproxy/ on the manager nodes, which is already bind-mounted to
# /usr/local/etc/haproxy. The OIDC scripts additionally need Lua modules that the
# stock image does not carry.
#
# Per-service opt-in fields (in docker_swarm_haproxy_additional_services and
# docker_swarm_haproxy_plain_http_services), all optional:
#
# robots_txt: 'ckan_robots.txt' serve this file on /robots.txt
# cors: true | {methods: '', origins: '', headers: ''}
# bot_mitigation: true | {rate: 5, bypass_networks: [], exempt_paths: []}
# basic_auth: 'userlist_name'
# oidc: {client_id: '', service_key: '', mode: authorize|authenticate,
# protected_paths: [], exempt_paths: []}
#
{#
############################################################################
Macros
############################################################################
#}
{% macro bot_mitigation(srv) %}
{% set bm = srv.bot_mitigation if srv.bot_mitigation is mapping else {} %}
{% set p = srv.acl_name %}
{% set bypass_nets = bm.bypass_networks | default(docker_swarm_haproxy_bot_bypass_networks) %}
{% set exempt_paths = bm.exempt_paths | default(docker_swarm_haproxy_bot_exempt_paths) %}
{% set validate_path = bm.validate_path | default(docker_swarm_haproxy_bot_validate_path) %}
{% set flood_rate = bm.rate | default(docker_swarm_haproxy_bot_flood_rate) %}
{% set cookie_name = docker_swarm_haproxy_bot_validate_cookie %}
{% set table = docker_swarm_haproxy_bot_flood_table %}
{# Conditions shared by the tracking, the deny and the challenge redirect.
Everything that is already identified, exempt or trusted is left alone. #}
{% set skip = '!' ~ p ~ '_has_validated_cookie !' ~ p ~ '_is_validate_path !' ~ p ~ '_has_auth_header !' ~ p ~ '_has_gcube_token !' ~ p ~ '_is_options'
~ (' !' ~ p ~ '_exempt_paths' if exempt_paths else '')
~ (' !' ~ p ~ '_bypass_nets' if bypass_nets else '') %}
# Bot mitigation. Counters live in the "{{ table }}" table holder below.
acl {{ p }}_has_validated_cookie hdr_sub(cookie) {{ cookie_name }}=true
acl {{ p }}_is_validate_path path_beg {{ validate_path }}
acl {{ p }}_has_auth_header req.hdr(Authorization) -m found
acl {{ p }}_has_gcube_token req.hdr(gcube-token) -m found
# A CORS preflight carries no cookie and no credentials: challenging it
# would break every cross-origin call.
acl {{ p }}_is_options method OPTIONS
{% if exempt_paths %}
acl {{ p }}_exempt_paths path_beg{% for path in exempt_paths %} {{ path }}{% endfor %}
{% endif %}
{% if bypass_nets %}
acl {{ p }}_bypass_nets src{% for net in bypass_nets %} {{ net }}{% endfor %}
{% endif %}
http-request track-sc1 src table {{ table }} if {{ skip }}
http-request sc-inc-gpc0(1) if {{ skip }}
acl {{ p }}_is_rate_flooding sc_gpc0_rate(1) ge {{ flood_rate }}
acl {{ p }}_is_blocked sc_get_gpc1(1) gt 0
http-request sc-inc-gpc1(1) if {{ p }}_is_rate_flooding !{{ p }}_is_blocked
http-request deny deny_status 429 if {{ p }}_is_blocked
http-request deny deny_status 429 if {{ p }}_is_rate_flooding
# Browser validation challenge: bounce once through {{ validate_path }} and
# hand out the cookie, so that only clients that follow redirects get in.
http-request redirect code 302 location {{ validate_path }}?url=%[url,url_enc] if {{ skip }}
acl {{ p }}_url_param_is_relative url_param(url),url_dec -m reg ^/[^/]
http-request return status 302 hdr Location %[url_param(url),url_dec] hdr Set-Cookie "{{ cookie_name }}=true; Max-Age={{ docker_swarm_haproxy_bot_validate_cookie_max_age }}; Path=/; HttpOnly; Secure" if {{ p }}_is_validate_path {{ p }}_url_param_is_relative
http-request return status 302 hdr Location / hdr Set-Cookie "{{ cookie_name }}=true; Max-Age={{ docker_swarm_haproxy_bot_validate_cookie_max_age }}; Path=/; HttpOnly; Secure" if {{ p }}_is_validate_path !{{ p }}_url_param_is_relative
{% endmacro %}
{% macro user_agent_blacklist(srv) %}
acl {{ srv.acl_name }}_blocked_user_agent hdr_sub(user-agent) -i -f {{ docker_swarm_haproxy_bot_agents_blacklist_file }}
http-request deny if {{ srv.acl_name }}_blocked_user_agent
{% endmacro %}
{% macro cors_request(srv) %}
{% set c = srv.cors if srv.cors is mapping else {} %}
# See https://github.com/haproxytech/haproxy-lua-cors/blob/master/README.md
http-request lua.cors "{{ c.methods | default(docker_swarm_haproxy_cors_methods) }}" "{{ c.origins | default(docker_swarm_haproxy_cors_origins) }}" "{{ c.headers | default(docker_swarm_haproxy_cors_headers) }}"
{% endmacro %}
{% macro global_acl_rules(rules) %}
{% for rule in rules %}
acl {{ rule.acl_name }} {{ rule.acl_args }}
{{ rule.http_action }}
{% endfor %}
{% endmacro %}
{% macro service_acls(srv) %}
acl {{ srv.acl_name }} {{ srv.acl_rule }}
{% if srv.acl_path_rule is defined %}
acl {{ srv.acl_name }}_path {{ srv.acl_path_rule }}
{% endif %}
{% if srv.allowed_networks is defined %}
acl {{ srv.acl_name }}_nets src{% for net in srv.allowed_networks %} {{ net }}{% endfor %}
http-request deny if {{ srv.acl_name }} !{{ srv.acl_name }}_nets
{% endif %}
{% if srv.basic_auth is defined %}
http-request auth realm "{{ docker_swarm_haproxy_basic_auth_realm }}" if {{ srv.acl_name }} !{ http_auth({{ srv.basic_auth }}) }
{% endif %}
{% endmacro %}
{% macro service_use_backend(srv) %}
use_backend {{ srv.acl_name }}_bck if {{ srv.acl_name }}{% if srv.acl_path_rule is defined %} {{ srv.acl_name }}_path{% endif %}
{% endmacro %}
{#
############################################################################
Global
############################################################################
#}
global
log fd@2 local2 {{ docker_swarm_haproxy_loglevel }}
chroot /var/lib/haproxy
@ -12,7 +123,7 @@ global
master-worker
ca-base /etc/ssl/certs
crt-base /etc/ssl/private
# https://ssl-config.mozilla.org/#server=haproxy&version=2.2&config=intermediate&openssl=1.1.1d&guideline=5.6
# https://ssl-config.mozilla.org/#server=haproxy&version=2.2&config=intermediate&openssl=1.1.1d&guideline=5.6
tune.ssl.default-dh-param 2048
ssl-default-bind-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
@ -20,6 +131,29 @@ global
ssl-default-server-ciphers ECDHE-ECDSA-AES128-GCM-SHA256:ECDHE-RSA-AES128-GCM-SHA256:ECDHE-ECDSA-AES256-GCM-SHA384:ECDHE-RSA-AES256-GCM-SHA384:ECDHE-ECDSA-CHACHA20-POLY1305:ECDHE-RSA-CHACHA20-POLY1305:DHE-RSA-AES128-GCM-SHA256:DHE-RSA-AES256-GCM-SHA384
ssl-default-server-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256
ssl-default-server-options no-sslv3 no-tlsv10 no-tlsv11 no-tls-tickets
{% if docker_swarm_haproxy_cors_enabled or docker_swarm_haproxy_oidc_enabled %}
{% if haproxy_version is defined and haproxy_version is version('3.1', '>=') %}
# HAProxy 3.1+: proper boolean handling in Lua fetch methods. This MUST come
# before any lua-load directive, or HAProxy ignores it and warns.
tune.lua.bool-sample-conversion normal
{% endif %}
{% endif %}
{% if docker_swarm_haproxy_cors_enabled %}
# CORS handling
lua-load {{ docker_swarm_haproxy_cors_lua_path }}
{% endif %}
{% if docker_swarm_haproxy_oidc_enabled %}
# OIDC/JWT authentication. Load order matters: jwks_cache first.
lua-load {{ docker_swarm_haproxy_oidc_lua_dir }}/jwks_cache.lua
{% if docker_swarm_haproxy_oidc_accounting_enabled %}
lua-load {{ docker_swarm_haproxy_oidc_lua_dir }}/accounting.lua
{% endif %}
lua-load {{ docker_swarm_haproxy_oidc_lua_dir }}/jwt_oidc_auth.lua
lua-load {{ docker_swarm_haproxy_oidc_lua_dir }}/oidc_callback.lua
{% if docker_swarm_haproxy_oidc_accounting_enabled %}
lua-load {{ docker_swarm_haproxy_oidc_lua_dir }}/accounting_response.lua
{% endif %}
{% endif %}
resolvers docker
nameserver dns1 127.0.0.11:53
@ -49,6 +183,13 @@ defaults
# Needed to preserve the stick tables
peers mypeers
peer local_haproxy 127.0.0.1:1024
{% for ul in docker_swarm_haproxy_userlists %}
userlist {{ ul.name }}
{% for user in ul.users %}
user {{ user.name }} password {{ user.password_hash }}
{% endfor %}
{% endfor %}
listen stats
{% if docker_swarm_haproxy_ipv4_only %}
@ -82,36 +223,35 @@ listen local_stats
frontend http_{{ docker_swarm_haproxy_plain_http_port }}
{% if docker_swarm_haproxy_ipv4_only %}
bind 0.0.0.0:{{ docker_swarm_haproxy_plain_http_port }} {% if docker_swarm_haproxy_accept_proxy %}accept-proxy{% endif %}
{% elif docker_swarm_haproxy_ipv6_only %}
bind :::{{ docker_swarm_haproxy_plain_http_port }} {% if docker_swarm_haproxy_accept_proxy %}accept-proxy{% endif %}
{% else %}
bind *:{{ docker_swarm_haproxy_plain_http_port }} {% if docker_swarm_haproxy_accept_proxy %}accept-proxy{% endif %}
{% endif %}
mode http
option http-keep-alive
option httplog
option forwardfor
{% for srv in docker_swarm_haproxy_plain_http_services %}
acl {{ srv.acl_name }} {{ srv.acl_rule }}
{%if srv.acl_path_rule is defined %}acl {{ srv.acl_name }}_path {{ srv.acl_path_rule }}{% endif %}
{# Rules that apply to the whole frontend: rendered ONCE, not once per service. #}
{% if docker_swarm_haproxy_plain_http_global_acl_rules is defined %}
{% for rule in docker_swarm_haproxy_plain_http_global_acl_rules %}
acl {{rule.acl_name }} {{ rule.acl_args }}
{{ rule.http_action }}
{{ global_acl_rules(docker_swarm_haproxy_plain_http_global_acl_rules) }}
{% endif %}
{% for srv in docker_swarm_haproxy_plain_http_services %}
{{ service_acls(srv) }}
{% endfor %}
{% set plain_robots = docker_swarm_haproxy_plain_http_services | selectattr('robots_txt', 'defined') | list %}
{% if plain_robots %}
acl is_robots_txt path /robots.txt
{% for srv in plain_robots %}
http-request return status 200 content-type "text/plain" file "{{ docker_swarm_haproxy_static_dir }}/{{ srv.robots_txt }}" hdr "cache-control" "no-cache" if is_robots_txt {{ srv.acl_name }}
{% endfor %}
{% endif %}
{% if srv.allowed_networks is defined %}
acl {{ srv.acl_name }}_nets src {% for net in srv.allowed_networks %} {{ net }}{% endfor %}
http-request deny if {{ srv.acl_name }} !{{ srv.acl_name }}_nets
{% endif %}
use_backend {{ srv.acl_name }}_bck if {{ srv.acl_name }} {%if srv.acl_path_rule is defined %}{{ srv.acl_name }}_path {% endif %}
{% for srv in docker_swarm_haproxy_plain_http_services %}
{{ service_use_backend(srv) }}
{% endfor %}
{% endif %}
@ -142,38 +282,78 @@ frontend http
http-request set-header X-Forwarded-Proto https if { ssl_fc }
# Remove the port from the host endpoint.
http-request replace-value Host (.*):.* \1
{% if docker_swarm_haproxy_cors_enabled %}
# Needed by the CORS Lua code to echo the request origin back.
capture request header origin len 128
{% endif %}
{# Rules that apply to the whole frontend: rendered ONCE, not once per service.
Anything stateful (sc-inc-gpc, track-sc) MUST stay out of the per-service
loop, or it would be executed once per rendered copy. #}
{% if docker_swarm_haproxy_global_acl_rules is defined %}
{{ global_acl_rules(docker_swarm_haproxy_global_acl_rules) }}
{% endif %}
{% if docker_swarm_cluster_portainer_install %}
acl portainer_srv hdr(host) -i {{ docker_swarm_portainer_hostname }}
{% endif %}
{% for srv in docker_swarm_haproxy_additional_services %}
{% if srv.mode is defined and srv.mode == 'tcp' %}
{% else %}
acl {{ srv.acl_name }} {{ srv.acl_rule }}
{%if srv.acl_path_rule is defined %}acl {{ srv.acl_name }}_path {{ srv.acl_path_rule }}{% endif %}
{% if docker_swarm_haproxy_global_acl_rules is defined %}
{% for rule in docker_swarm_haproxy_global_acl_rules %}
acl {{rule.acl_name }} {{ rule.acl_args }}
{{ rule.http_action }}
{% set http_services = docker_swarm_haproxy_additional_services | rejectattr('mode', 'defined') | list
+ docker_swarm_haproxy_additional_services | selectattr('mode', 'defined') | rejectattr('mode', 'equalto', 'tcp') | list %}
{% for srv in http_services %}
{{ service_acls(srv) }}
{% endfor %}
{% set robots_services = http_services | selectattr('robots_txt', 'defined') | list %}
{% if robots_services %}
# robots.txt is served here and not in the backends, because a service that
# is selected by a path rule would never route /robots.txt to its backend.
acl is_robots_txt path /robots.txt
{% for srv in robots_services %}
http-request return status 200 content-type "text/plain" file "{{ docker_swarm_haproxy_static_dir }}/{{ srv.robots_txt }}" hdr "cache-control" "no-cache" if is_robots_txt {{ srv.acl_name }}
{% endfor %}
{% endif %}
{% if docker_swarm_haproxy_oidc_enabled %}
{% set oidc_services = http_services | selectattr('oidc', 'defined') | list %}
# OIDC callback endpoint, handled in-process by the Lua service.
acl oidc_callback_path path_beg {{ docker_swarm_haproxy_oidc_callback_path }}
{% if docker_swarm_haproxy_oidc_exempt_paths %}
acl oidc_exempt_paths path_beg{% for path in docker_swarm_haproxy_oidc_exempt_paths %} {{ path }}{% endfor %}
{% if srv.allowed_networks is defined %}
acl {{ srv.acl_name }}_nets src {% for net in srv.allowed_networks %} {{ net }}{% endfor %}
http-request deny if {{ srv.acl_name }} !{{ srv.acl_name }}_nets
{% endif %}
# CORS preflight must never be challenged.
acl oidc_exempt_options method OPTIONS
{% for srv in oidc_services %}
{% if srv.oidc.protected_paths is defined and srv.oidc.protected_paths %}
acl {{ srv.acl_name }}_oidc_paths path_beg{% for path in srv.oidc.protected_paths %} {{ path }}{% endfor %}
{% endif %}
{% if srv.oidc.exempt_paths is defined and srv.oidc.exempt_paths %}
acl {{ srv.acl_name }}_oidc_exempt path_beg{% for path in srv.oidc.exempt_paths %} {{ path }}{% endfor %}
{% endif %}
{% endfor %}
{% for srv in oidc_services %}
{# mode "authorize": validate a token when one is presented.
mode "authenticate": no token, no entry — browsers get bounced to Keycloak.
Both are the same Lua call; the Lua itself 401s API clients and redirects
browsers. The difference is which requests we hand to it. #}
http-request lua.jwt_oidc_auth {{ srv.oidc.client_id }} {{ srv.oidc.service_key | default(srv.acl_name) }} if {{ srv.acl_name }}{% if srv.oidc.protected_paths is defined and srv.oidc.protected_paths %} {{ srv.acl_name }}_oidc_paths{% endif %}{% if srv.oidc.exempt_paths is defined and srv.oidc.exempt_paths %} !{{ srv.acl_name }}_oidc_exempt{% endif %}{% if docker_swarm_haproxy_oidc_exempt_paths %} !oidc_exempt_paths{% endif %} !oidc_exempt_options{% if srv.oidc.mode | default('authorize') == 'authorize' %} { req.hdr(Authorization) -m found }{% endif %}
{% endfor %}
{% if oidc_services %}
# Turn what the Lua decided into an actual response. "return" and not
# "deny deny_status", because deny ignores a content-type and would answer
# with the HTML error file instead of JSON.
http-request return status 401 content-type "application/json" string "{\"error\":\"unauthorized\"}" hdr WWW-Authenticate "Bearer realm=\"{{ docker_swarm_haproxy_oidc_realm }}\"" if { var(txn.auth_status) -m int 401 }
http-request redirect location %[var(txn.auth_redirect)] code 302 if { var(txn.auth_status) -m int 302 }
{% if docker_swarm_haproxy_oidc_accounting_enabled %}
http-response lua.accounting_response if { var(txn.acct_enabled) -m str 1 }
{% endif %}
{% endif %}
use_backend oidc_callback_bck if oidc_callback_path
{% endif %}
{% if docker_swarm_cluster_portainer_install %}
use_backend portainer_bck if portainer_srv
{% endif %}
{% for srv in docker_swarm_haproxy_additional_services %}
{% if srv.mode is defined and srv.mode == 'tcp' %}
{% else %}
use_backend {{ srv.acl_name }}_bck if {{ srv.acl_name }} {%if srv.acl_path_rule is defined %}{{ srv.acl_name }}_path{% endif %}
{% endif %}
{% for srv in http_services %}
{{ service_use_backend(srv) }}
{% endfor %}
{% if docker_swarm_expose_api_via_haproxy %}
@ -241,9 +421,10 @@ frontend {{ srv.acl_name }}
bind :{{ srv.service_port }}
mode {{ srv.mode }}
{% if srv.allowed_networks is defined %}
acl {{ srv.acl_name }}_nets src {% for net in srv.allowed_networks %} {{ net }}{% endfor %}
acl {{ srv.acl_name }}_nets src{% for net in srv.allowed_networks %} {{ net }}{% endfor %}
tcp-request connection reject if {{ srv.acl_name }} !{{ srv.acl_name }}_nets
# This frontend serves one service only, so there is no host ACL to combine.
tcp-request connection reject if !{{ srv.acl_name }}_nets
{% endif %}
use_backend {{ srv.acl_name }}_bck
@ -266,6 +447,23 @@ backend swarm_api_bck
server {{ docker_swarm_api_backend }}
{% endif %}
{% if docker_swarm_haproxy_oidc_enabled %}
# OIDC authorization code callback, served by the Lua script itself.
backend oidc_callback_bck
mode http
http-request use-service lua.oidc_callback
{% endif %}
{% if docker_swarm_haproxy_bot_mitigation_enabled %}
{% set mitigated = docker_swarm_haproxy_additional_services | selectattr('bot_mitigation', 'defined') | list %}
{% if mitigated %}
# Table holder for the flood counters. It has no server on purpose: it exists
# only to own the stick-table that the backends track into.
backend {{ docker_swarm_haproxy_bot_flood_table }}
stick-table type ip size {{ docker_swarm_haproxy_bot_flood_table_size }} expire {{ docker_swarm_haproxy_bot_flood_table_expire }} store gpc0_rate({{ docker_swarm_haproxy_bot_flood_rate_period }}),gpc1 peers mypeers
{% endif %}
{% endif %}
{% if docker_swarm_cluster_portainer_install %}
backend portainer_bck
mode http
@ -299,6 +497,16 @@ backend {{ srv.acl_name }}_bck
{{ bck_opt }}
{% endfor %}
{% endif %}
{% if docker_swarm_haproxy_bot_mitigation_enabled and srv.bot_mitigation is defined %}
{{ user_agent_blacklist(srv) }}
{% endif %}
{% if docker_swarm_haproxy_cors_enabled and srv.cors is defined %}
{{ cors_request(srv) }}
http-response lua.cors
{% endif %}
{% if docker_swarm_haproxy_bot_mitigation_enabled and srv.bot_mitigation is defined %}
{{ bot_mitigation(srv) }}
{% endif %}
{% if srv.http_check_enabled is defined and srv.http_check_enabled %}
http-check send {{ srv.http_check }}
@ -329,6 +537,22 @@ backend {{ srv.acl_name }}_bck
mode http
option httpchk
balance {{ srv.balance_type | default('roundrobin') }}
{% if srv.backend_additional_options is defined %}
{% for bck_opt in srv.backend_additional_options %}
{{ bck_opt }}
{% endfor %}
{% endif %}
{% if docker_swarm_haproxy_bot_mitigation_enabled and srv.bot_mitigation is defined %}
{{ user_agent_blacklist(srv) }}
{% endif %}
{% if docker_swarm_haproxy_cors_enabled and srv.cors is defined %}
{{ cors_request(srv) }}
http-response lua.cors
{% endif %}
{% if docker_swarm_haproxy_bot_mitigation_enabled and srv.bot_mitigation is defined %}
{{ bot_mitigation(srv) }}
{% endif %}
{% if srv.http_check_enabled is defined and srv.http_check_enabled %}
http-check send {{ srv.http_check }}
http-check expect {{ srv.http_check_expect }}