diff --git a/README.md b/README.md index d6bcca7..25f0725 100644 --- a/README.md +++ b/README.md @@ -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 ------------ diff --git a/defaults/main.yml b/defaults/main.yml index 4227ccf..167db2d 100644 --- a/defaults/main.yml +++ b/defaults/main.yml @@ -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: ". 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' diff --git a/templates/haproxy.cfg.j2 b/templates/haproxy.cfg.j2 index f23481f..bd33a18 100644 --- a/templates/haproxy.cfg.j2 +++ b/templates/haproxy.cfg.j2 @@ -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 }}