From 6d0325190fcede5b912c20cfb6ffefab90a3f4f4 Mon Sep 17 00:00:00 2001 From: Viacheslav Hletenko Date: Sat, 13 May 2023 12:59:12 +0000 Subject: T5222: Add load-balancing for web traffic --- data/templates/load-balancing/haproxy.cfg.j2 | 154 +++++++++++++ .../load-balancing/override_haproxy.conf.j2 | 14 ++ debian/control | 1 + interface-definitions/include/haproxy/mode.xml.i | 22 ++ interface-definitions/include/haproxy/rule.xml.i | 130 +++++++++++ .../include/haproxy/timeout.xml.i | 45 ++++ .../load-balancing-haproxy.xml.in | 245 +++++++++++++++++++++ src/conf_mode/load-balancing-haproxy.py | 182 +++++++++++++++ 8 files changed, 793 insertions(+) create mode 100644 data/templates/load-balancing/haproxy.cfg.j2 create mode 100644 data/templates/load-balancing/override_haproxy.conf.j2 create mode 100644 interface-definitions/include/haproxy/mode.xml.i create mode 100644 interface-definitions/include/haproxy/rule.xml.i create mode 100644 interface-definitions/include/haproxy/timeout.xml.i create mode 100644 interface-definitions/load-balancing-haproxy.xml.in create mode 100755 src/conf_mode/load-balancing-haproxy.py diff --git a/data/templates/load-balancing/haproxy.cfg.j2 b/data/templates/load-balancing/haproxy.cfg.j2 new file mode 100644 index 000000000..3d98d78b7 --- /dev/null +++ b/data/templates/load-balancing/haproxy.cfg.j2 @@ -0,0 +1,154 @@ +# Generated by ${vyos_conf_scripts_dir}/load-balancing-haproxy.py + +global + log /dev/log local0 + log /dev/log local1 notice + chroot /var/lib/haproxy + stats socket /run/haproxy/admin.sock mode 660 level admin + stats timeout 30s + user haproxy + group haproxy + daemon + +{% if global_parameters is vyos_defined %} +{% if global_parameters.max_connections is vyos_defined %} + maxconn {{ global_parameters.max_connections }} +{% endif %} + + # Default SSL material locations + ca-base /etc/ssl/certs + crt-base /etc/ssl/private + +{% if global_parameters.tls.ssl_bind_ciphers is vyos_defined %} + # https://ssl-config.mozilla.org/#server=haproxy&version=2.6.12-1&config=intermediate&openssl=3.0.8-1&guideline=5.6 + ssl-default-bind-ciphers {{ global_parameters.tls.ssl_bind_ciphers | join(':') | upper }} +{% endif %} + ssl-default-bind-ciphersuites TLS_AES_128_GCM_SHA256:TLS_AES_256_GCM_SHA384:TLS_CHACHA20_POLY1305_SHA256 +{% if global_parameters.tls.tls_version_min is vyos_defined('1.3') %} + ssl-default-bind-options force-tlsv13 +{% else %} + ssl-default-bind-options ssl-min-ver TLSv1.2 no-tls-tickets +{% endif %} +{% endif %} + +defaults + log global + mode http + option dontlognull + timeout connect 10s + timeout client 50s + timeout server 50s + errorfile 400 /etc/haproxy/errors/400.http + errorfile 403 /etc/haproxy/errors/403.http + errorfile 408 /etc/haproxy/errors/408.http + errorfile 500 /etc/haproxy/errors/500.http + errorfile 502 /etc/haproxy/errors/502.http + errorfile 503 /etc/haproxy/errors/503.http + errorfile 504 /etc/haproxy/errors/504.http + +# Frontend +{% if server is vyos_defined %} +{% for front, front_config in server.items() %} +frontend {{ front }} +{% set ssl_front = 'ssl crt /run/haproxy/' ~ front_config.ssl.certificate ~ '.pem' if front_config.ssl.certificate is vyos_defined else '' %} + bind {{ front_config.listen_address if front_config.listen_address if vyos_defined else '*' }}:{{ front_config.port }} {{ ssl_front }} +{% if front_config.redirect_http_to_https is vyos_defined %} + http-request redirect scheme https unless { ssl_fc } +{% endif %} +{% if front_config.mode is vyos_defined %} + mode {{ front_config.mode }} +{% endif %} +{% if front_config.rule is vyos_defined %} +{% for rule, rule_config in front_config.rule.items() %} + # rule {{ rule }} +{% if rule_config.domain_name is vyos_defined and rule_config.set.server is vyos_defined %} +{% set rule_options = 'hdr(host)' %} +{% if rule_config.ssl is vyos_defined %} +{% set ssl_rule_translate = {'req-ssl-sni': 'req_ssl_sni', 'ssl-fc-sni': 'ssl_fc_sni', 'ssl-fc-sni-end': 'ssl_fc_sni_end'} %} +{% set rule_options = ssl_rule_translate[rule_config.ssl] %} +{% endif %} + acl {{ rule }} {{ rule_options }} -i {{ rule_config.domain_name }} + use_backend {{ rule_config.set.server }} if {{ rule }} +{% endif %} +{# path url #} +{% if rule_config.url_path is vyos_defined and rule_config.set.redirect_location is vyos_defined %} +{% set path_mod_translate = {'begin': '-i -m beg', 'end': '-i -m end', 'exact': ''} %} +{% for path, path_config in rule_config.url_path.items() %} +{% for url in path_config %} + acl {{ rule }} path {{ path_mod_translate[path] }} {{ url }} +{% endfor %} +{% endfor %} + http-request redirect location {{ rule_config.set.redirect_location }} code 301 if {{ rule }} +{% endif %} +{# endpath #} +{% endfor %} +{% endif %} +{% if front_config.backend is vyos_defined %} +{% for backend in front_config.backend %} + default_backend {{ backend }} +{% endfor %} +{% endif %} + +{% endfor %} +{% endif %} + +# Backend +{% if backend is vyos_defined %} +{% for back, back_config in backend.items() %} +backend {{ back }} +{% if back_config.balance is vyos_defined %} +{% set balance_translate = {'least-connection': 'leastconn', 'round-robin': 'roundrobin', 'source-address': 'source'} %} + balance {{ balance_translate[back_config.balance] }} +{% endif %} +{# If mode is not TCP skip Forwarded #} +{% if back_config.mode is not vyos_defined('tcp') %} + option forwardfor + http-request set-header X-Forwarded-Port %[dst_port] + http-request add-header X-Forwarded-Proto https if { ssl_fc } +{% endif %} +{% if back_config.mode is vyos_defined %} + mode {{ back_config.mode }} +{% endif %} +{% if back_config.rule is vyos_defined %} +{% for rule, rule_config in back_config.rule.items() %} +{% if rule_config.domain_name is vyos_defined and rule_config.set.server is vyos_defined %} +{% set rule_options = 'hdr(host)' %} +{% if rule_config.ssl is vyos_defined %} +{% set ssl_rule_translate = {'req-ssl-sni': 'req_ssl_sni', 'ssl-fc-sni': 'ssl_fc_sni', 'ssl-fc-sni-end': 'ssl_fc_sni_end'} %} +{% set rule_options = ssl_rule_translate[rule_config.ssl] %} +{% endif %} + acl {{ rule }} {{ rule_options }} -i {{ rule_config.domain_name }} + use-server {{ rule_config.set.server }} if {{ rule }} +{% endif %} +{# path url #} +{% if rule_config.url_path is vyos_defined and rule_config.set.redirect_location is vyos_defined %} +{% set path_mod_translate = {'begin': '-i -m beg', 'end': '-i -m end', 'exact': ''} %} +{% for path, path_config in rule_config.url_path.items() %} +{% for url in path_config %} + acl {{ rule }} path {{ path_mod_translate[path] }} {{ url }} +{% endfor %} +{% endfor %} + http-request redirect location {{ rule_config.set.redirect_location }} code 301 if {{ rule }} +{% endif %} +{# endpath #} +{% endfor %} +{% endif %} +{% if back_config.server is vyos_defined %} +{% set ssl_back = 'ssl ca-file /run/haproxy/' ~ back_config.ssl.ca_certificate ~ '.pem' if back_config.ssl.ca_certificate is vyos_defined else '' %} +{% for server, server_config in back_config.server.items() %} + server {{ server }} {{ server_config.address }}:{{ server_config.port }} {{ 'check' if server_config.check is vyos_defined }} {{ ssl_back }} +{% endfor %} +{% endif %} +{% if back_config.timeout.check is vyos_defined %} + timeout check {{ back_config.timeout.check }} +{% endif %} +{% if back_config.timeout.connect is vyos_defined %} + timeout connect {{ back_config.timeout.connect }} +{% endif %} +{% if back_config.timeout.server is vyos_defined %} + timeout server {{ back_config.timeout.server }} +{% endif %} + +{% endfor %} +{% endif %} + diff --git a/data/templates/load-balancing/override_haproxy.conf.j2 b/data/templates/load-balancing/override_haproxy.conf.j2 new file mode 100644 index 000000000..395b5d279 --- /dev/null +++ b/data/templates/load-balancing/override_haproxy.conf.j2 @@ -0,0 +1,14 @@ +{% set haproxy_command = 'ip vrf exec ' ~ vrf ~ ' ' if vrf is vyos_defined else '' %} +[Unit] +StartLimitIntervalSec=0 +After=vyos-router.service +ConditionPathExists=/run/haproxy/haproxy.cfg + +[Service] +EnvironmentFile= +Environment= +Environment="CONFIG=/run/haproxy/haproxy.cfg" "PIDFILE=/run/haproxy.pid" "EXTRAOPTS=-S /run/haproxy-master.sock" +ExecStart= +ExecStart={{ haproxy_command }}/usr/sbin/haproxy -Ws -f /run/haproxy/haproxy.cfg -p /run/haproxy.pid -S /run/haproxy-master.sock +Restart=always +RestartSec=10 diff --git a/debian/control b/debian/control index 4a2706fc3..ec08968c9 100644 --- a/debian/control +++ b/debian/control @@ -65,6 +65,7 @@ Depends: fuse-overlayfs, libpam-google-authenticator, grc, + haproxy, hostapd, hsflowd, hvinfo, diff --git a/interface-definitions/include/haproxy/mode.xml.i b/interface-definitions/include/haproxy/mode.xml.i new file mode 100644 index 000000000..672ea65b4 --- /dev/null +++ b/interface-definitions/include/haproxy/mode.xml.i @@ -0,0 +1,22 @@ + + + + Proxy mode + + http tcp + + invalid value + + http + HTTP proxy mode + + + tcp + TCP proxy mode + + + (http|tcp) + + + + diff --git a/interface-definitions/include/haproxy/rule.xml.i b/interface-definitions/include/haproxy/rule.xml.i new file mode 100644 index 000000000..9d9f63c9c --- /dev/null +++ b/interface-definitions/include/haproxy/rule.xml.i @@ -0,0 +1,130 @@ + + + + Proxy rule number + + u32:1-10000 + Number for this proxy rule + + + + + Proxy rule number must be between 1 and 10000 + + + + + Domain name to match + + txt + Domain address to match + + + + + + + + + Proxy modifications + + + + + Set URL location + + url + Set URL location + + + ^\/[\w\-.\/]+$ + + Incorrect URL format + + + + + Server name + + [-_a-zA-Z0-9]+ + + Server name must be alphanumeric and can contain hyphen and underscores + + + + + + + SSL match options + + req-ssl-sni ssl-fc-sni + + + req-ssl-sni + SSL Server Name Indication (SNI) request match + + + ssl-fc-sni + SSL frontend connection Server Name Indication match + + + ssl-fc-sni-end + SSL frontend match end of connection Server Name Indication + + + (req-ssl-sni|ssl-fc-sni|ssl-fc-sni-end) + + + + + + URL path match + + + + + Begin URL match + + url + Begin URL + + + ^\/[\w\-.\/]+$ + + Incorrect URL format + + + + + + End URL match + + url + End URL + + + ^\/[\w\-.\/]+$ + + Incorrect URL format + + + + + + Exactly URL match + + url + Exactly URL + + + ^\/[\w\-.\/]+$ + + Incorrect URL format + + + + + + + + diff --git a/interface-definitions/include/haproxy/timeout.xml.i b/interface-definitions/include/haproxy/timeout.xml.i new file mode 100644 index 000000000..250b35683 --- /dev/null +++ b/interface-definitions/include/haproxy/timeout.xml.i @@ -0,0 +1,45 @@ + + + + Tiemout options + + + + + Timeout in seconds for established connections + + u32:1-3600 + Check timeout in seconds + + + + + + + + + Set the maximum time to wait for a connection attempt to a server to succeed + + u32:1-3600 + Connect timeout in seconds + + + + + + + + + Set the maximum inactivity time on the server side + + u32:1-3600 + Server timeout in seconds + + + + + + + + + diff --git a/interface-definitions/load-balancing-haproxy.xml.in b/interface-definitions/load-balancing-haproxy.xml.in new file mode 100644 index 000000000..268f4cd18 --- /dev/null +++ b/interface-definitions/load-balancing-haproxy.xml.in @@ -0,0 +1,245 @@ + + + + + + + Configure reverse-proxy + + + + + Frontend name + + [-_a-zA-Z0-9]+ + + Server name must be alphanumeric and can contain hyphen and underscores + + + + + Backend member + + [-_a-zA-Z0-9]+ + + Backend name must be alphanumeric and can contain hyphen and underscores + + txt + Backend name + + + load-balancing reverse-proxy backend + + + + + #include + #include + #include + #include + #include + + + Redirect HTTP to HTTPS + + + + + + SSL Certificate, SSL Key and CA + + + #include + + + + + + + Backend name + + [-_a-zA-Z0-9]+ + + Backend name must be alphanumeric and can contain hyphen and underscores + + + + + Load-balancing algorithm + + source-address round-robin least-connection + + + source-address + Based on hash of source IP address + + + round-robin + Round robin + + + least-connection + Least connection + + + (source-address|round-robin|least-connection) + + + round-robin + + #include + #include + + + Backend parameters + + + + + HTTP health check + + + + + + #include + + + Backend server name + + + + + Backend server address + + ipv4 + IPv4 unicast peer address + + + ipv6 + IPv6 unicast peer address + + + + + + + + + + Active health check backend server + + + + #include + + + + + SSL Certificate, SSL Key and CA + + + #include + + + #include + + + + + Global perfomance parameters and limits + + + + + Maximum allowed connections + + u32:1-2000000 + Maximum allowed connections + + + + + + + + + Transport Layer Security (TLS) options + + + + + 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 + + + ecdhe-ecdsa-aes128-gcm-sha256 + ecdhe-ecdsa-aes128-gcm-sha256 + + + ecdhe-rsa-aes128-gcm-sha256 + ecdhe-rsa-aes128-gcm-sha256 + + + ecdhe-ecdsa-aes256-gcm-sha384 + ecdhe-ecdsa-aes256-gcm-sha384 + + + ecdhe-rsa-aes256-gcm-sha384 + ecdhe-rsa-aes256-gcm-sha384 + + + ecdhe-ecdsa-chacha20-poly1305 + ecdhe-ecdsa-chacha20-poly1305 + + + ecdhe-rsa-chacha20-poly1305 + ecdhe-rsa-chacha20-poly1305 + + + dhe-rsa-aes128-gcm-sha256 + dhe-rsa-aes128-gcm-sha256 + + + dhe-rsa-aes256-gcm-sha384 + dhe-rsa-aes256-gcm-sha384 + + + (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) + + + + + 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 + + + + Specify the minimum required TLS version + + 1.2 1.3 + + + 1.2 + TLS v1.2 + + + 1.3 + TLS v1.3 + + + (1.2|1.3) + + + 1.3 + + + + + + #include + + + + + diff --git a/src/conf_mode/load-balancing-haproxy.py b/src/conf_mode/load-balancing-haproxy.py new file mode 100755 index 000000000..d2b895fbe --- /dev/null +++ b/src/conf_mode/load-balancing-haproxy.py @@ -0,0 +1,182 @@ +#!/usr/bin/env python3 +# +# Copyright (C) 2023 VyOS maintainers and contributors +# +# This program is free software; you can redistribute it and/or modify +# it under the terms of the GNU General Public License version 2 or later as +# published by the Free Software Foundation. +# +# This program is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with this program. If not, see . + +import os + +from sys import exit +from shutil import rmtree + +from vyos.config import Config +from vyos.configdict import dict_merge +from vyos.util import call +from vyos.util import check_port_availability +from vyos.util import is_listen_port_bind_service +from vyos.pki import wrap_certificate +from vyos.pki import wrap_private_key +from vyos.template import render +from vyos.xml import defaults +from vyos import ConfigError +from vyos import airbag +airbag.enable() + +load_balancing_dir = '/run/haproxy' +load_balancing_conf_file = f'{load_balancing_dir}/haproxy.cfg' +systemd_service = 'haproxy.service' +systemd_override = r'/run/systemd/system/haproxy.service.d/10-override.conf' + + +def get_config(config=None): + if config: + conf = config + else: + conf = Config() + + base = ['load-balancing', 'reverse-proxy'] + lb = conf.get_config_dict(base, + get_first_key=True, + key_mangling=('-', '_'), + no_tag_node_value_mangle=True) + + if lb: + lb['pki'] = conf.get_config_dict(['pki'], key_mangling=('-', '_'), + get_first_key=True, no_tag_node_value_mangle=True) + + # We have gathered the dict representation of the CLI, but there are default + # options which we need to update into the dictionary retrived. + default_values = defaults(base) + if 'backend' in default_values: + del default_values['backend'] + if lb: + lb = dict_merge(default_values, lb) + + if 'backend' in lb: + for backend in lb['backend']: + default_balues_backend = defaults(base + ['backend']) + lb['backend'][backend] = dict_merge(default_balues_backend, lb['backend'][backend]) + + return lb + + +def verify(lb): + if not lb: + return None + + if 'backend' not in lb or 'server' not in lb: + raise ConfigError(f'"server" and "backend" must be configured!') + + for front, front_config in lb['server'].items(): + if 'port' not in front_config: + raise ConfigError(f'"{front} server port" must be configured!') + # We can use redirect to HTTPS without backend section + if 'backend' not in front_config and 'redirect_http_to_https' not in front_config: + raise ConfigError(f'"{front} backend" must be configured!') + + # Check if bind address:port are used by another service + tmp_address = front_config.get('address', '0.0.0.0') + tmp_port = front_config['port'] + if check_port_availability(tmp_address, int(tmp_port), 'tcp') is not True and \ + not is_listen_port_bind_service(int(tmp_port), 'haproxy'): + raise ConfigError(f'"TCP" port "{tmp_port}" is used by another service') + + for back, back_config in lb['backend'].items(): + if 'server' not in back_config: + raise ConfigError(f'"{back} server" must be configured!') + for bk_server, bk_server_conf in back_config['server'].items(): + if 'address' not in bk_server_conf or 'port' not in bk_server_conf: + raise ConfigError(f'"backend {back} server {bk_server} address and port" must be configured!') + + +def generate(lb): + if not lb: + # Delete /run/haproxy/haproxy.cfg + config_files = [load_balancing_conf_file, systemd_override] + for file in config_files: + if os.path.isfile(file): + os.unlink(file) + # Delete old directories + #if os.path.isdir(load_balancing_dir): + # rmtree(load_balancing_dir, ignore_errors=True) + + return None + + # Create load-balance dir + if not os.path.isdir(load_balancing_dir): + os.mkdir(load_balancing_dir) + + # SSL Certificates for frontend + for front, front_config in lb['server'].items(): + if 'ssl' in front_config: + cert_file_path = os.path.join(load_balancing_dir, 'cert.pem') + cert_key_path = os.path.join(load_balancing_dir, 'cert.pem.key') + ca_cert_file_path = os.path.join(load_balancing_dir, 'ca.pem') + + if 'certificate' in front_config['ssl']: + #cert_file_path = os.path.join(load_balancing_dir, 'cert.pem') + #cert_key_path = os.path.join(load_balancing_dir, 'cert.key') + cert_name = front_config['ssl']['certificate'] + pki_cert = lb['pki']['certificate'][cert_name] + + with open(cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_cert['certificate'])) + + if 'private' in pki_cert and 'key' in pki_cert['private']: + with open(cert_key_path, 'w') as f: + f.write(wrap_private_key(pki_cert['private']['key'])) + + if 'ca_certificate' in front_config['ssl']: + ca_name = front_config['ssl']['ca_certificate'] + pki_ca_cert = lb['pki']['ca'][ca_name] + + with open(ca_cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_ca_cert['certificate'])) + + # SSL Certificates for backend + for back, back_config in lb['backend'].items(): + if 'ssl' in back_config: + ca_cert_file_path = os.path.join(load_balancing_dir, 'ca.pem') + + if 'ca_certificate' in back_config['ssl']: + ca_name = back_config['ssl']['ca_certificate'] + pki_ca_cert = lb['pki']['ca'][ca_name] + + with open(ca_cert_file_path, 'w') as f: + f.write(wrap_certificate(pki_ca_cert['certificate'])) + + render(load_balancing_conf_file, 'load-balancing/haproxy.cfg.j2', lb) + render(systemd_override, 'load-balancing/override_haproxy.conf.j2', lb) + + return None + + +def apply(lb): + call('systemctl daemon-reload') + if not lb: + call(f'systemctl stop {systemd_service}') + else: + call(f'systemctl reload-or-restart {systemd_service}') + + return None + + +if __name__ == '__main__': + try: + c = get_config() + verify(c) + generate(c) + apply(c) + except ConfigError as e: + print(e) + exit(1) -- cgit v1.2.3