Commit 6e735808 authored by Jérome Perrin's avatar Jérome Perrin

stack/erp5: implement Zope's rewrite rules in ERP5 balancer partition

The strategy for compatibility is that:
 - haproxy still listen on the same port as before, without rewrite rule.
   This is called "legacy" port.
 - for each frontend from request parameters, we introduce an haproxy
   frontend with a rewrite for the corresponding `internal-path`
   parameter.
 - the shared frontend instance is updated to use this new frontend
   entry from haproxy. This will cause a small downtime until the shared
   frontend is updated to the new URL on ERP5, but since this feature
   was not used, it's OK.

Technical details are that we:
 - split haproxy config to have frontends and backends.
 - introduce one frontend in haproxy for each frontend from request
   parameters.
 - routing-rule-list argument is still honored the same way, globally
   and after path from frontend.
 - change the shared frontend requests to use "" type, no longer "zope"
   type.
 - we don't do automatic detection of /VirtualHostRoot in URL but always
   add it, because it could be used to trick zope into thinking it
   serves requests for an arbitrary host and do open redirects
 - before using the request's host header in virtualhost path, we check
   that it does not contain /, to prevent injection of virutalhost path
   elements through the host header.
 - we don't use the "path" parameter from shared frontend, because we
   want the frontend to be simple, so we don't want it to rewrite the
   request path (which is also the reason why we deprecated "zope" type)
 - the tests have changed a lot, because they were using what's now the
   "legacy" URL types, so we updated it to use the new URL types with
   all the /VirtualHostRoot/../ in path and also because they use IPv6
   URL, no longer IPv4
parent 5b3fc1f2
......@@ -5,7 +5,7 @@
"additionalProperties": false,
"definitions": {
"routing-rule-list": {
"description": "Maps the path received in requests to given zope path. Rules are applied in the order they are given. This requires the path received from the outside world (typically: frontend) to have its root correspond to Zope's root (for frontend: 'path' parameter must be empty), with the customary VirtualHostMonster construct (for frontend: 'type' must be 'zope').",
"description": "Maps the path received in requests to given zope path. Rules are applied in the order they are given, after 'internal-path' from 'frontend' parameter. This also supports legacy frontends, using Rapid CDN with \"zope\" type.",
"type": "array",
"default": [
[
......
......@@ -314,7 +314,7 @@ class CaucaseCertificate(ManagedResource):
)
return os.path.join(software_release_root_path, 'bin', 'caucase')
def request(self, common_name: str, caucase: CaucaseService) -> None:
def request(self, common_name: str, caucase: CaucaseService, san: x509.SubjectAlternativeName=None) -> None:
"""Generate certificate and request signature to the caucase service.
This overwrite any previously requested certificate for this instance.
......@@ -345,11 +345,10 @@ class CaucaseCertificate(ManagedResource):
NameOID.COMMON_NAME,
common_name,
),
])).sign(
key,
hashes.SHA256(),
default_backend(),
)
]))
if san:
csr = csr.add_extension(san, critical=True)
csr = csr.sign(key, hashes.SHA256(), default_backend())
with open(self.csr_file, 'wb') as f:
f.write(csr.public_bytes(serialization.Encoding.PEM))
......
......@@ -113,6 +113,12 @@ class BalancerTestCase(ERP5InstanceTestCase):
'ssl-authentication-dict': {'default': False},
'ssl': {},
'timeout-dict': {'default': None},
'frontend-parameter-dict': {
'default': {
'internal-path': '',
'zope-family': 'default',
},
},
'family-path-routing-dict': {},
'path-routing-list': [],
}
......@@ -122,24 +128,72 @@ class BalancerTestCase(ERP5InstanceTestCase):
return {'_': json.dumps(cls._getInstanceParameterDict())}
def setUp(self) -> None:
self.default_balancer_url = json.loads(
self.default_balancer_direct_url = json.loads(
self.computer_partition.getConnectionParameterDict()['_'])['default']
self.default_balancer_zope_url = json.loads(
self.computer_partition.getConnectionParameterDict()['_'])['url-backend-default']
class TestURLRewrite(BalancerTestCase):
__partition_reference__ = 'ur'
def test_direct(self):
self.assertEqual(requests.get(self.default_balancer_direct_url, verify=False).json()['Path'], '/')
self.assertEqual(
requests.get(
urllib.parse.urljoin(
self.default_balancer_direct_url,
'/VirtualHostBase/https/example.com:443/VirtualHostRoot/path'),
verify=False
).json()['Path'],
'/VirtualHostBase/https/example.com:443/VirtualHostRoot/path')
def test_zope(self):
netloc = urllib.parse.urlparse(self.default_balancer_zope_url).netloc
self.assertEqual(
requests.get(self.default_balancer_zope_url, verify=False).json()['Path'],
f'/VirtualHostBase/https/{netloc}/VirtualHostRoot/')
self.assertEqual(
requests.get(urllib.parse.urljoin(
self.default_balancer_zope_url, 'path'), verify=False).json()['Path'],
f'/VirtualHostBase/https/{netloc}/VirtualHostRoot/path')
self.assertEqual(
requests.get(
urllib.parse.urljoin(
self.default_balancer_zope_url,
'/VirtualHostBase/https/example.com:443/VirtualHostRoot/path'),
verify=False
).json()['Path'],
f'/VirtualHostBase/https/{netloc}/VirtualHostRoot/VirtualHostBase/https/example.com:443/VirtualHostRoot/path')
def test_bad_host(self):
self.assertEqual(
requests.get(self.default_balancer_zope_url, headers={'Host': 'a/b'}, verify=False).status_code,
requests.codes.bad_request)
class SlowHTTPServer(ManagedHTTPServer):
"""An HTTP Server which reply after a timeout.
Timeout is 2 seconds by default, and can be specified in the path of the URL
Timeout is 2 seconds by default, and can be specified in the path of the URL:
GET /{timeout}
but because balancer rewrites the URL, the actual URL used by this server is:
GET /VirtualHostBase/https/{host}/VirtualHostRoot/{timeout}
"""
class RequestHandler(BaseHTTPRequestHandler):
def do_GET(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/plain")
timeout = 2
if self.path == '/': # for health checks
timeout = 0
try:
timeout = int(self.path[1:])
except ValueError:
timeout = int(self.path.split('/')[5])
except (ValueError, IndexError):
pass
self.send_response(200)
self.send_header("Content-Type", "text/plain")
time.sleep(timeout)
self.end_headers()
self.wfile.write(b"OK\n")
......@@ -161,12 +215,12 @@ class TestTimeout(BalancerTestCase, CrontabMixin):
def test_timeout(self) -> None:
self.assertEqual(
requests.get(
urllib.parse.urljoin(self.default_balancer_url, '/1'),
urllib.parse.urljoin(self.default_balancer_zope_url, '/1'),
verify=False).status_code,
requests.codes.ok)
self.assertEqual(
requests.get(
urllib.parse.urljoin(self.default_balancer_url, '/5'),
urllib.parse.urljoin(self.default_balancer_zope_url, '/5'),
verify=False).status_code,
requests.codes.gateway_timeout)
......@@ -178,13 +232,13 @@ class TestLog(BalancerTestCase, CrontabMixin):
@classmethod
def _getInstanceParameterDict(cls) -> dict:
parameter_dict = super()._getInstanceParameterDict()
# use a slow server instead
# use a slow server instead, so that we can test logs with slow requests
parameter_dict['dummy_http_server'] = [[cls.getManagedResource("slow_web_server", SlowHTTPServer).netloc, 1, False]]
return parameter_dict
def test_access_log_format(self) -> None:
requests.get(
urllib.parse.urljoin(self.default_balancer_url, '/url_path'),
urllib.parse.urljoin(self.default_balancer_zope_url, '/url_path'),
verify=False,
)
time.sleep(.5) # wait a bit more until access is logged
......@@ -197,7 +251,7 @@ class TestLog(BalancerTestCase, CrontabMixin):
# the request - but our test machines can be slow sometimes, so we tolerate
# it can take up to 20 seconds.
match = re.match(
r'([(\d\.)]+) - - \[(.*?)\] "(.*?)" (\d+) (\d+) "(.*?)" "(.*?)" (\d+)',
r'([(\da-fA-F:\.)]+) - - \[(.*?)\] "(.*?)" (\d+) (\d+) "(.*?)" "(.*?)" (\d+)',
access_line
)
self.assertTrue(match)
......@@ -208,7 +262,7 @@ class TestLog(BalancerTestCase, CrontabMixin):
def test_access_log_apachedex_report(self) -> None:
# make a request so that we have something in the logs
requests.get(self.default_balancer_url, verify=False)
requests.get(self.default_balancer_zope_url, verify=False)
# crontab for apachedex is executed
self._executeCrontabAtDate('generate-apachedex-report', '23:59')
......@@ -233,7 +287,7 @@ class TestLog(BalancerTestCase, CrontabMixin):
self._executeCrontabAtDate('logrotate', '2000-01-01')
# make a request so that we have something in the logs
requests.get(self.default_balancer_url, verify=False).raise_for_status()
requests.get(self.default_balancer_zope_url, verify=False).raise_for_status()
# slow query crontab depends on crontab for log rotation
# to be executed first.
......@@ -248,7 +302,7 @@ class TestLog(BalancerTestCase, CrontabMixin):
)
self.assertTrue(os.path.exists(rotated_log_file))
requests.get(self.default_balancer_url, verify=False).raise_for_status()
requests.get(self.default_balancer_zope_url, verify=False).raise_for_status()
# on next day execution of logrotate, log files are compressed
self._executeCrontabAtDate('logrotate', '2050-01-02')
self.assertTrue(os.path.exists(rotated_log_file + '.xz'))
......@@ -262,11 +316,11 @@ class TestLog(BalancerTestCase, CrontabMixin):
# after a while, balancer should detect and log this event in error log
time.sleep(5)
self.assertEqual(
requests.get(self.default_balancer_url, verify=False).status_code,
requests.get(self.default_balancer_zope_url, verify=False).status_code,
requests.codes.service_unavailable)
with open(os.path.join(self.computer_partition_root_path, 'var', 'log', 'apache-error.log')) as error_log_file:
error_line = error_log_file.read().splitlines()[-1]
self.assertIn('proxy family_default has no server available!', error_line)
self.assertIn('backend default has no server available!', error_line)
# this log also include a timestamp
self.assertRegex(error_line, r'\d{4}-\d{2}-\d{2}T\d{2}:\d{2}:\d{2}')
......@@ -274,7 +328,9 @@ class TestLog(BalancerTestCase, CrontabMixin):
class BalancerCookieHTTPServer(ManagedHTTPServer):
"""An HTTP Server which can set balancer cookie.
This server set cookie when requested /set-cookie path.
This server set cookie when requested /set-cookie path (actually
/VirtualHostBase/https/{host}/VirtualHostRoot/set-cookie , which is
added by balancer proxy)
The reply body is the name used when registering this resource
using getManagedResource. This way we can assert which
......@@ -288,7 +344,8 @@ class BalancerCookieHTTPServer(ManagedHTTPServer):
def do_GET(self) -> None:
self.send_response(200)
self.send_header("Content-Type", "text/plain")
if self.path == '/set_cookie':
if self.path != '/' and self.path.split('/')[5] == 'set_cookie':
# the balancer tells the backend what's the name of the balancer cookie with
# the X-Balancer-Current-Cookie header.
self.send_header('Set-Cookie', '%s=anything' % self.headers['X-Balancer-Current-Cookie'])
......@@ -319,7 +376,7 @@ class TestBalancer(BalancerTestCase):
def test_balancer_round_robin(self) -> None:
# requests are by default balanced to both servers
self.assertEqual(
{requests.get(self.default_balancer_url, verify=False).text for _ in range(10)},
{requests.get(self.default_balancer_zope_url, verify=False).text for _ in range(10)},
{'backend_web_server1', 'backend_web_server2'}
)
......@@ -328,7 +385,7 @@ class TestBalancer(BalancerTestCase):
self.getManagedResource("backend_web_server2", BalancerCookieHTTPServer).close()
self.addCleanup(self.getManagedResource("backend_web_server2", BalancerCookieHTTPServer).open)
self.assertEqual(
{requests.get(self.default_balancer_url, verify=False).text for _ in range(10)},
{requests.get(self.default_balancer_zope_url, verify=False).text for _ in range(10)},
{'backend_web_server1',}
)
......@@ -336,7 +393,7 @@ class TestBalancer(BalancerTestCase):
# if backend provides a "SERVERID" cookie, balancer will overwrite it with the
# backend selected by balancing algorithm
self.assertIn(
requests.get(urllib.parse.urljoin(self.default_balancer_url, '/set_cookie'), verify=False).cookies['SERVERID'],
requests.get(urllib.parse.urljoin(self.default_balancer_zope_url, '/set_cookie'), verify=False).cookies['SERVERID'],
('default-0', 'default-1'),
)
......@@ -344,7 +401,7 @@ class TestBalancer(BalancerTestCase):
# if request is made with the sticky cookie, the client stick on one balancer
cookies = dict(SERVERID='default-1')
self.assertEqual(
{requests.get(self.default_balancer_url, verify=False, cookies=cookies).text for _ in range(10)},
{requests.get(self.default_balancer_zope_url, verify=False, cookies=cookies).text for _ in range(10)},
{'backend_web_server2',}
)
......@@ -352,7 +409,7 @@ class TestBalancer(BalancerTestCase):
self.getManagedResource("backend_web_server2", BalancerCookieHTTPServer).close()
self.addCleanup(self.getManagedResource("backend_web_server2", BalancerCookieHTTPServer).open)
self.assertEqual(
requests.get(self.default_balancer_url, verify=False, cookies=cookies).text,
requests.get(self.default_balancer_zope_url, verify=False, cookies=cookies).text,
'backend_web_server1')
def test_balancer_stats_socket(self) -> None:
......@@ -372,7 +429,7 @@ class TestBalancer(BalancerTestCase):
raise
self.assertEqual(socat_process.poll(), 0)
# output is a csv
self.assertIn(b'family_default,FRONTEND,', output)
self.assertIn(b'\ndefault,BACKEND,', output)
class TestTestRunnerEntryPoints(BalancerTestCase):
......@@ -471,7 +528,7 @@ class TestHTTP(BalancerTestCase):
'--insecure',
'--write-out',
'%{http_version}',
self.default_balancer_url,
self.default_balancer_zope_url,
]),
b'2',
)
......@@ -482,16 +539,16 @@ class TestHTTP(BalancerTestCase):
session.verify = False
# do a first request, which establish a first connection
session.get(self.default_balancer_url).raise_for_status()
session.get(self.default_balancer_zope_url).raise_for_status()
# "break" new connection method and check we can make another request
with mock.patch(
"requests.packages.urllib3.connectionpool.HTTPSConnectionPool._new_conn",
) as new_conn:
session.get(self.default_balancer_url).raise_for_status()
session.get(self.default_balancer_zope_url).raise_for_status()
new_conn.assert_not_called()
parsed_url = urllib.parse.urlparse(self.default_balancer_url)
parsed_url = urllib.parse.urlparse(self.default_balancer_zope_url)
# check that we have an open file for the ip connection
self.assertTrue([
......@@ -538,10 +595,10 @@ class TestServerTLSEmbeddedCaucase(BalancerTestCase):
return crypto_cert
def test_certificate_validates_with_caucase_ca(self) -> None:
requests.get(self.default_balancer_url, verify=self._getCaucaseCACertificatePath())
requests.get(self.default_balancer_zope_url, verify=self._getCaucaseCACertificatePath())
def test_certificate_renewal(self) -> None:
balancer_parsed_url = urllib.parse.urlparse(self.default_balancer_url)
balancer_parsed_url = urllib.parse.urlparse(self.default_balancer_zope_url)
certificate_before_renewal = self._getServerCertificate(
balancer_parsed_url.hostname,
balancer_parsed_url.port)
......@@ -640,7 +697,7 @@ class TestServerTLSCSRTemplateParameter(TestServerTLSExternalCaucase):
def test_certificate_validates_with_caucase_ca(self) -> None:
super().test_certificate_validates_with_caucase_ca()
balancer_parsed_url = urllib.parse.urlparse(self.default_balancer_url)
balancer_parsed_url = urllib.parse.urlparse(self.default_balancer_zope_url)
cert = self._getServerCertificate(
balancer_parsed_url.hostname,
balancer_parsed_url.port,
......@@ -655,6 +712,8 @@ class ContentTypeHTTPServer(ManagedHTTPServer):
For example when requested http://host/text/plain it will reply
with Content-Type: text/plain header.
This actually uses a URL like this to support zope style virtual host:
GET /VirtualHostBase/https/{host}/VirtualHostRoot/text/plain
The body is always "OK"
"""
......@@ -665,7 +724,7 @@ class ContentTypeHTTPServer(ManagedHTTPServer):
if self.path == '/':
self.send_header("Content-Length", '0')
return self.end_headers()
content_type = self.path[1:]
content_type = '/'.join(self.path.split('/')[5:])
body = b"OK"
self.send_header("Content-Type", content_type)
self.send_header("Content-Length", str(len(body)))
......@@ -707,7 +766,7 @@ class TestContentEncoding(BalancerTestCase):
'application/font-woff2',
'application/x-font-opentype',
'application/wasm',):
resp = requests.get(urllib.parse.urljoin(self.default_balancer_url, content_type), verify=False)
resp = requests.get(urllib.parse.urljoin(self.default_balancer_zope_url, content_type), verify=False)
self.assertEqual(resp.headers['Content-Type'], content_type)
self.assertEqual(
resp.headers.get('Content-Encoding'),
......@@ -716,7 +775,7 @@ class TestContentEncoding(BalancerTestCase):
self.assertEqual(resp.text, 'OK')
def test_no_gzip_encoding(self) -> None:
resp = requests.get(urllib.parse.urljoin(self.default_balancer_url, '/image/png'), verify=False)
resp = requests.get(urllib.parse.urljoin(self.default_balancer_zope_url, '/image/png'), verify=False)
self.assertNotIn('Content-Encoding', resp.headers)
self.assertEqual(resp.text, 'OK')
......@@ -806,7 +865,19 @@ class TestServerTLSProvidedCertificate(BalancerTestCase):
def _getInstanceParameterDict(cls) -> dict:
server_caucase = cls.getManagedResource('server_caucase', CaucaseService)
server_certificate = cls.getManagedResource('server_certificate', CaucaseCertificate)
server_certificate.request(cls._ipv4_address, server_caucase)
# Add all IPs of the computer in SubjectAlternativeName, we don't
# know what will be the IP of the balancer partition.
with sqlite3.connect(cls.slap._proxy_database) as db:
ip_address_list = [
x509.IPAddress(ipaddress.ip_address(r)) for (r, ) in db.execute(
f"SELECT address FROM partition_network{DB_VERSION}").fetchall()
]
assert ip_address_list
server_certificate.request(
cls.__name__,
server_caucase,
x509.SubjectAlternativeName(ip_address_list))
parameter_dict = super()._getInstanceParameterDict()
with open(server_certificate.cert_file) as f:
parameter_dict['ssl']['cert'] = f.read()
......@@ -816,7 +887,7 @@ class TestServerTLSProvidedCertificate(BalancerTestCase):
def test_certificate_validates_with_provided_ca(self) -> None:
server_certificate = self.getManagedResource("server_certificate", CaucaseCertificate)
requests.get(self.default_balancer_url, verify=server_certificate.ca_crt_file)
requests.get(self.default_balancer_zope_url, verify=server_certificate.ca_crt_file)
class TestClientTLS(BalancerTestCase):
......@@ -862,7 +933,7 @@ class TestClientTLS(BalancerTestCase):
# the client certificate in "remote-user" header
def _make_request() -> dict:
return requests.get(
self.default_balancer_url,
self.default_balancer_zope_url,
cert=(client_certificate.cert_file, client_certificate.key_file),
verify=False,
).json()
......
......@@ -60,15 +60,8 @@ class TestPublishedURLIsReachableMixin:
"""Mixin that checks that default page of ERP5 is reachable.
"""
def _checkERP5IsReachable(self, base_url, site_id, verify):
# We access ERP5 trough a "virtual host", which should make
# ERP5 produce URLs using https://virtual-host-name:1234/virtual_host_root
# as base.
virtual_host_url = urllib.parse.urljoin(
base_url,
'/VirtualHostBase/https/virtual-host-name:1234/{}/VirtualHostRoot/_vh_virtual_host_root/'
.format(site_id))
@contextlib.contextmanager
def requestSession(self, base_url):
# What happens is that instantiation just create the services, but does not
# wait for ERP5 to be initialized. When this test run ERP5 instance is
# instantiated, but zope is still busy creating the site and haproxy replies
......@@ -84,7 +77,32 @@ class TestPublishedURLIsReachableMixin:
total=20,
backoff_factor=.5,
status_forcelist=(404, 500, 503))))
yield session
def _checkERP5IsReachableWithVirtualHost(self, url, verify):
with self.requestSession(urllib.parse.urljoin(url, '/')) as session:
r = session.get(url, verify=verify, allow_redirects=True)
# access on / are redirected to login form
self.assertTrue(r.url.endswith('/login_form'))
self.assertEqual(r.status_code, requests.codes.ok)
self.assertIn("ERP5", r.text)
# host header is used in redirected URL. The URL is always https
r = session.get(url, verify=verify, allow_redirects=False, headers={'Host': 'www.example.com'})
self.assertEqual(r.headers.get('Location'), 'https://www.example.com/login_form')
r = session.get(url, verify=verify, allow_redirects=False, headers={'Host': 'www.example.com:1234'})
self.assertEqual(r.headers.get('Location'), 'https://www.example.com:1234/login_form')
def _checkERP5IsReachableWithoutVirtualHost(self, base_url, site_id, verify):
# We access ERP5 trough a "virtual host", which should make
# ERP5 produce URLs using https://virtual-host-name:1234/virtual_host_root
# as base.
virtual_host_url = urllib.parse.urljoin(
base_url,
'/VirtualHostBase/https/virtual-host-name:1234/{}/VirtualHostRoot/_vh_virtual_host_root/'
.format(site_id))
with self.requestSession(base_url) as session:
r = session.get(virtual_host_url, verify=verify, allow_redirects=False)
self.assertEqual(r.status_code, requests.codes.found)
# access on / are redirected to login form, with virtual host preserved
......@@ -119,7 +137,7 @@ class TestPublishedURLIsReachableMixin:
"""Tests the IPv6 URL published by the root partition is reachable.
"""
param_dict = self.getRootPartitionConnectionParameterDict()
self._checkERP5IsReachable(
self._checkERP5IsReachableWithoutVirtualHost(
param_dict['family-default-v6'],
param_dict['site-id'],
self._getCaucaseServiceCACertificate(),
......@@ -129,7 +147,7 @@ class TestPublishedURLIsReachableMixin:
"""Tests the IPv4 URL published by the root partition is reachable.
"""
param_dict = self.getRootPartitionConnectionParameterDict()
self._checkERP5IsReachable(
self._checkERP5IsReachableWithoutVirtualHost(
param_dict['family-default'],
param_dict['site-id'],
self._getCaucaseServiceCACertificate(),
......@@ -139,9 +157,8 @@ class TestPublishedURLIsReachableMixin:
"""Tests the frontend URL published by the root partition is reachable.
"""
param_dict = self.getRootPartitionConnectionParameterDict()
self._checkERP5IsReachable(
self._checkERP5IsReachableWithVirtualHost(
param_dict['url-frontend-default'],
param_dict['site-id'],
self._getCaucaseServiceCACertificate(),
)
......@@ -157,9 +174,8 @@ class TestDefaultParameters(ERP5InstanceTestCase, TestPublishedURLIsReachableMix
'.installed-switch-softwaretype.cfg')) as f:
installed = zc.buildout.configparser.parse(f, 'installed')
self.assertEqual(
installed['request-frontend-default']['config-type'], 'zope')
self.assertEqual(
installed['request-frontend-default']['config-path'], '/erp5')
installed['request-frontend-default']['config-type'], '')
self.assertNotIn('config-path', installed['request-frontend-default'])
self.assertEqual(
installed['request-frontend-default']['config-authenticate-to-backend'], 'true')
self.assertEqual(installed['request-frontend-default']['shared'], 'true')
......@@ -310,8 +326,8 @@ class TestBalancerPorts(ERP5InstanceTestCase):
# frontend pointing to this port.
self.assertEqual(urllib.parse.urlparse(param_dict['family-family1']).port, 2152)
self.assertEqual(urllib.parse.urlparse(param_dict['family-family1-v6']).port, 2152)
self.assertEqual(urllib.parse.urlparse(param_dict['family-family2']).port, 2154)
self.assertEqual(urllib.parse.urlparse(param_dict['family-family2-v6']).port, 2154)
self.assertEqual(urllib.parse.urlparse(param_dict['family-family2']).port, 2155)
self.assertEqual(urllib.parse.urlparse(param_dict['family-family2-v6']).port, 2155)
def test_published_test_runner_url(self):
# each family's also a list of test test runner URLs, by default 3 per family
......@@ -390,6 +406,8 @@ class TestBalancerPortsStable(ERP5InstanceTestCase):
def test_same_balancer_ports_when_adding_zopes_or_frontends(self):
param_dict_before = self.getRootPartitionConnectionParameterDict()
balancer_param_dict_before = json.loads(
self.getComputerPartition('balancer').getConnectionParameter('_'))
# re-request with one more frontend and one more backend, that are before
# the existing ones when sorting alphabetically
......@@ -410,10 +428,15 @@ class TestBalancerPortsStable(ERP5InstanceTestCase):
rerequest()
self.slap.waitForInstance(max_retry=10)
param_dict_after = json.loads(rerequest().getConnectionParameterDict()['_'])
balancer_param_dict_after = json.loads(
self.getComputerPartition('balancer').getConnectionParameter('_'))
self.assertEqual(param_dict_before['family-zzz-v6'], param_dict_after['family-zzz-v6'])
self.assertEqual(param_dict_before['url-frontend-zzz'], param_dict_after['url-frontend-zzz'])
self.assertEqual(balancer_param_dict_before['url-backend-zzz'], balancer_param_dict_after['url-backend-zzz'])
self.assertNotEqual(param_dict_before['family-zzz-v6'], param_dict_after['family-aaa-v6'])
self.assertNotEqual(param_dict_before['url-frontend-zzz'], param_dict_after['url-frontend-aaa'])
self.assertNotEqual(balancer_param_dict_before['url-backend-zzz'], balancer_param_dict_after['url-backend-aaa'])
class TestSeleniumTestRunner(ERP5InstanceTestCase, TestPublishedURLIsReachableMixin):
......@@ -476,14 +499,15 @@ class TestDisableTestRunner(ERP5InstanceTestCase, TestPublishedURLIsReachableMix
self.assertNotIn('runTestSuite', bin_programs)
def test_no_haproxy_testrunner_port(self):
# Haproxy only listen on two ports, there is no haproxy ports allocated for test runner
# Haproxy only listen on two ports for frontend, two ports for legacy entry points
# and there is no haproxy ports allocated for test runner
with self.slap.instance_supervisor_rpc as supervisor:
all_process_info = supervisor.getAllProcessInfo()
process_info, = (p for p in all_process_info if p['name'].startswith('haproxy'))
haproxy_master_process = psutil.Process(process_info['pid'])
haproxy_worker_process, = haproxy_master_process.children()
self.assertEqual(
sorted([socket.AF_INET, socket.AF_INET6]),
sorted([socket.AF_INET, socket.AF_INET6, socket.AF_INET, socket.AF_INET6]),
sorted(
c.family
for c in haproxy_worker_process.connections()
......@@ -1356,10 +1380,13 @@ class TestFrontend(ERP5InstanceTestCase):
},
"web": {
"family": "web",
"port-base": 2300,
},
"activities": {
# this family will not have frontend
"family": "activities"
"family": "activities",
"port-base": 2400,
},
},
"frontend": {
......@@ -1368,7 +1395,7 @@ class TestFrontend(ERP5InstanceTestCase):
},
"website": {
"zope-family": "web",
"internal-path": "/%(site-id)s/web_site_module/my_website/",
"internal-path": "/%(site-id)s/web_site_module/my_website",
"instance-parameters": {
# some extra frontend parameters
"enable_cache": "true",
......@@ -1396,19 +1423,20 @@ class TestFrontend(ERP5InstanceTestCase):
def test_request_parameters(self):
param_dict = self.getRootPartitionConnectionParameterDict()
balancer_param_dict = json.loads(
self.getComputerPartition('balancer').getConnectionParameter('_'))
with open(os.path.join(self.computer_partition_root_path,
'.installed-switch-softwaretype.cfg')) as f:
installed = zc.buildout.configparser.parse(f, 'installed')
self.assertEqual(
installed['request-frontend-backoffice']['config-type'], 'zope')
installed['request-frontend-backoffice']['config-type'], '')
self.assertEqual(
installed['request-frontend-backoffice']['shared'], 'true')
self.assertEqual(
installed['request-frontend-backoffice']['config-url'],
param_dict['family-default-v6'])
self.assertEqual(
installed['request-frontend-backoffice']['config-path'], '/erp5')
balancer_param_dict['url-backend-backoffice'])
self.assertNotIn('config-path', installed['request-frontend-backoffice'])
self.assertEqual(
installed['request-frontend-backoffice']['sla-computer_guid'],
'COMP-1234')
......@@ -1421,7 +1449,7 @@ class TestFrontend(ERP5InstanceTestCase):
param_dict['url-frontend-backoffice'])
self.assertEqual(
installed['request-frontend-website']['config-type'], 'zope')
installed['request-frontend-website']['config-type'], '')
# no SLA by default
self.assertFalse([k for k in installed['request-frontend-website'] if k.startswith('sla-')])
# instance parameters are propagated
......@@ -1429,10 +1457,8 @@ class TestFrontend(ERP5InstanceTestCase):
installed['request-frontend-website']['config-enable_cache'], 'true')
self.assertEqual(
installed['request-frontend-website']['config-url'],
param_dict['family-web-v6'])
self.assertEqual(
installed['request-frontend-website']['config-path'],
'/erp5/web_site_module/my_website/')
balancer_param_dict['url-backend-website'])
self.assertNotIn('config-path', installed['request-frontend-website'])
self.assertEqual(
installed['request-frontend-website']['connection-secure_access'],
param_dict['url-frontend-website'])
......@@ -1440,6 +1466,32 @@ class TestFrontend(ERP5InstanceTestCase):
# no frontend was requested for activities family
self.assertNotIn('request-frontend-activities', installed)
self.assertNotIn('url-frontend-activities', param_dict)
self.assertNotIn('url-backend-activities', balancer_param_dict)
def test_path_virtualhost(self):
balancer_param_dict = json.loads(
self.getComputerPartition('balancer').getConnectionParameter('_'))
found_line = False
retries = 10
while retries:
requests.get(balancer_param_dict['url-backend-website'], verify=False)
for logfile in glob.glob(os.path.join(self.getComputerPartitionPath('zope-web'), 'var/log/*Z2.log')):
with open(logfile) as f:
for line in f:
if 'GET /VirtualHost' in line:
found_line = True
break
if found_line:
break
time.sleep(1)
retries = retries - 1
self.assertTrue(found_line)
percent_encoded_netloc = urllib.parse.quote(
urllib.parse.urlparse(
balancer_param_dict['url-backend-website']).netloc)
self.assertIn(
f'/VirtualHostBase/https/{percent_encoded_netloc}/erp5/web_site_module/my_website/VirtualHostRoot/ HTTP', line)
class TestDefaultFrontendWithZopePartitionDict(ERP5InstanceTestCase):
......@@ -1468,12 +1520,15 @@ class TestDefaultFrontendWithZopePartitionDict(ERP5InstanceTestCase):
def test_frontend_requested(self):
param_dict = self.getRootPartitionConnectionParameterDict()
balancer_param_dict = json.loads(
self.getComputerPartition('balancer').getConnectionParameter('_'))
with open(os.path.join(self.computer_partition_root_path,
'.installed-switch-softwaretype.cfg')) as f:
installed = zc.buildout.configparser.parse(f, 'installed')
self.assertEqual(
installed['request-frontend-default']['config-url'],
param_dict['family-backoffice-v6'])
balancer_param_dict['url-backend-default'])
requests.get(
param_dict['url-frontend-default'],
......
......@@ -14,7 +14,7 @@
# not need these here).
[template-erp5]
filename = instance-erp5.cfg.in
md5sum = ba46a66da1c834df14a80a20b21e4a96
md5sum = 6db19ee819a960a34012308e29c5bbfb
[template-balancer]
filename = instance-balancer.cfg.in
......
......@@ -435,6 +435,7 @@ return =
{% endfor -%}
{% do monitor_base_url_dict.__setitem__('request-balancer', '${' ~ 'request-balancer' ~ ':connection-monitor-base-url}') -%}
config-zope-family-dict = {{ dumps(zope_family_parameter_dict) }}
config-frontend-parameter-dict = {{ dumps({}) }}
config-tcpv4-port = {{ dumps(balancer_dict.get('tcpv4-port', 2150)) }}
{% for zope_section_id, name in zope_address_list_id_dict.items() -%}
config-{{ name }} = {{ ' ${' ~ zope_section_id ~ ':connection-zope-address-list}' }}
......
......@@ -74,7 +74,7 @@ md5sum = 55463b0abdbe0118ef1c27e6b71c3324
[template-erp5]
filename = instance-erp5.cfg.in
md5sum = 855fdc8ed0b2671f9e2c74d127cc6858
md5sum = ae9c380ae04dde4f20e139c66ef7c22a
[template-zeo]
filename = instance-zeo.cfg.in
......@@ -90,11 +90,11 @@ md5sum = 6178ba7b42848f9e2412ab898a7b026c
[template-balancer]
filename = instance-balancer.cfg.in
md5sum = 42cb68905f92e7df38cc5c64b94be3de
md5sum = 0fad9497da12ed0186dca5236c23f3a7
[template-haproxy-cfg]
filename = haproxy.cfg.in
md5sum = 9988a14c4108e3bce3f871e34673cdd5
md5sum = 2cd76971b64b0bf7771978ad07bfc2e5
[template-rsyslogd-cfg]
filename = rsyslogd.cfg.in
......
......@@ -18,11 +18,11 @@
# "stats-socket": "<file_path>",
#
# # IPv4 to listen on
# # All backends from `backend-dict` will listen on this IP.
# # All frontends from `frontend-dict` will listen on this IP.
# "ipv4": "0.0.0.0",
#
# # IPv6 to listen on
# # All backends from `backend-dict` will listen on this IP.
# # All frontends from `frontend-dict` will listen on this IP.
# "ipv6": "::1",
#
# # Certificate and key in PEM format. All ports will serve TLS using
......@@ -41,34 +41,59 @@
# # Path to use for HTTP health check on backends from `backend-dict`.
# "server-check-path": "/",
#
# # The mapping of frontend, keyed by frontend name
# "frontend-dict": {
# "frontend-default": {
# "port": 8080,
# "client-cert-required": False,
# "backend-name": "family-default",
# "request-path-prepend": "/erp5",
# }
# "legacy-frontend-family-secure": {
# "port": 8000,
# "client-cert-required": False,
# "backend-name": "family-secure",
# "request-path-prepend": None, # None means do not rewrite the request path
# }
# "legacy-frontend-family-default": {
# "port": 8002,
# "client-cert-required": False,
# "backend-name": "family-default",
# "request-path-prepend": None, # None means do not rewrite the request path
# }
# }
# # The mapping of backends, keyed by family name
# "backend-dict": {
# "family-secure": {
# ( 8000, # port int
# True, # ssl_required bool
# None, # timeout (in seconds) int | None
# [ # backends
# '10.0.0.10:8001', # netloc str
# 1, # max_connection_count int
# False, # is_web_dav bool
# ],
# ),
# },
# "family-default": {
# ( 8002, # port int
# False, # ssl_required bool
# None, # timeout (in seconds) int | None
# [ # backends
# '10.0.0.10:8003', # netloc str
# 1, # max_connection_count int
# False, # is_web_dav bool
# ],
# ),
# "family-secure": {
# "timeout": None, # in seconds
# "backend-list": [
# [
# '10.0.0.10:8001', # netloc str
# 1, # max_connection_count int
# False, # is_web_dav bool
# ]
# ]
# },
# "family-default": {
# "timeout": None, # in seconds
# "backend-list": [
# [
# '10.0.0.10:8003', # netloc str
# 1, # max_connection_count int
# False, # is_web_dav bool
# ],
# [
# '10.0.0.10:8004', # netloc str
# 1, # max_connection_count int
# False, # is_web_dav bool
# ],
# ]
# },
#
# # The mapping of zope paths.
# # This is a Zope specific feature.
# # `enable_authentication` has same meaning as for `backend-list`.
# # This is a Zope specific feature used only to provide https while running
# # ERP5 "unit test" suite.
# # `enable_authentication` has same meaning as for `backend-dict`.
# "zope-virtualhost-monster-backend-dict": {
# # {(ip, port): ( enable_authentication, {frontend_path: ( internal_url ) }, ) }
# ('[::1]', 8004): (
......@@ -81,15 +106,20 @@
# }
#
# This sample of `parameter_dict` will make haproxy listening to :
# From to `backend-list`:
# For "family-secure":
# For "frontend-default":
# - 0.0.0.0:8080 redirecting internaly to http://10.0.0.10:8003 or http://10.0.0.10:8004
# - [::1]:8080 redirecting internaly to http://10.0.0.10:8003 or http://10.0.0.10:8004
# accepting requests from any client and rewriting the path to add a Zope rewrite rule
# so that the a request on https://0.0.0.0:8080/path is rewritten to serve a Zope object at
# path /erp5/path , visible as /path.
# For "legacy-frontend-family-secure":
# - 0.0.0.0:8000 redirecting internaly to http://10.0.0.10:8001 and
# - [::1]:8000 redirecting internaly to http://10.0.0.10:8001
# only accepting requests from clients providing a verified TLS certificate
# emitted by a CA from `ca-cert` and not revoked in `crl`.
# For "family-default":
# - 0.0.0.0:8002 redirecting internaly to http://10.0.0.10:8003
# - [::1]:8002 redirecting internaly to http://10.0.0.10:8003
# For "legacy-frontend-family-default":
# - 0.0.0.0:8002 redirecting internaly to http://10.0.0.10:8003 or http://10.0.0.10:8004
# - [::1]:8002 redirecting internaly to http://10.0.0.10:8003 or http://10.0.0.10:8004
# accepting requests from any client.
#
# For both families, X-Forwarded-For header will be stripped unless
......@@ -102,7 +132,7 @@
# with some VirtualHostMonster rewrite rules so zope writes URLs with
# [::1]:8004 as server name.
# For more details, refer to
# https://docs.zope.org/zope2/zope2book/VirtualHosting.html#using-virtualhostroot-and-virtualhostbase-together
# https://zope.readthedocs.io/en/latest/zopebook/VirtualHosting.html#using-virtualhostroot-and-virtualhostbase-together
-#}
{% set server_check_path = parameter_dict['server-check-path'] -%}
......@@ -148,26 +178,17 @@ defaults
{% set family_path_routing_dict = parameter_dict['family-path-routing-dict'] %}
{% set path_routing_list = parameter_dict['path-routing-list'] %}
{% for name, (port, certificate_authentication, timeout, backend_list) in sorted(six.iteritems(parameter_dict['backend-dict'])) -%}
listen family_{{ name }}
{% for name, frontend in sorted(six.iteritems(parameter_dict['frontend-dict'])) %}
listen {{ name }}
{%- if parameter_dict.get('ca-cert') -%}
{%- set ssl_auth = ' ca-file ' ~ parameter_dict['ca-cert'] ~ ' verify' ~ ( ' required' if certificate_authentication else ' optional crt-ignore-err all' ) ~ ' crl-file ' ~ parameter_dict['crl'] %}
{%- set ssl_auth = ' ca-file ' ~ parameter_dict['ca-cert'] ~ ' verify' ~ ( ' required' if frontend['client-cert-required'] else ' optional crt-ignore-err all' ) ~ ' crl-file ' ~ parameter_dict['crl'] %}
{%- else %}
{%- set ssl_auth = '' %}
{%- endif %}
bind {{ parameter_dict['ipv4'] }}:{{ port }} {{ bind_ssl_crt }} {{ ssl_auth }}
bind {{ parameter_dict['ipv6'] }}:{{ port }} {{ bind_ssl_crt }} {{ ssl_auth }}
cookie SERVERID rewrite
http-request set-header X-Balancer-Current-Cookie SERVERID
{% if timeout %}
{#
Apply a slightly longer timeout than the zope timeout so that clients can see the
TimeoutReachedError from zope, that is a bit more informative than the 504 error
page from haproxy.
#}
timeout server {{ timeout + 3 }}s
{%- endif %}
bind {{ parameter_dict['ipv4'] }}:{{ frontend['port'] }} {{ bind_ssl_crt }} {{ ssl_auth }}
bind {{ parameter_dict['ipv6'] }}:{{ frontend['port'] }} {{ bind_ssl_crt }} {{ ssl_auth }}
# remove X-Forwarded-For unless client presented a verified certificate
http-request del-header X-Forwarded-For unless { ssl_c_verify 0 } { ssl_c_used 1 }
......@@ -175,18 +196,43 @@ listen family_{{ name }}
http-request del-header Remote-User
http-request set-header Remote-User %{+Q}[ssl_c_s_dn(cn)] if { ssl_c_verify 0 } { ssl_c_used 1 }
# reject invalid host header before using it in path
http-request deny deny_status 400 if { req.hdr(host) -m sub / }
# logs
capture request header Referer len 512
capture request header User-Agent len 512
log-format "%{+Q}o %{-Q}ci - - [%trg] %r %ST %B %{+Q}[capture.req.hdr(0)] %{+Q}[capture.req.hdr(1)] %Ta"
{% for outer_prefix, inner_prefix in family_path_routing_dict.get(name, []) + path_routing_list %}
{% if frontend['request-path-prepend'] is not none %}
http-request replace-path ^/(.*) /VirtualHostBase/https/%[req.hdr(Host)]{{ frontend['request-path-prepend'] }}/VirtualHostRoot/\1
{% endif %}
{% for outer_prefix, inner_prefix in family_path_routing_dict.get(frontend['backend-name'], []) + path_routing_list %}
{% set outer_prefix = outer_prefix.strip('/') -%}
http-request replace-path ^(/+VirtualHostBase/+[^/]+/+[^/]+)/+VirtualHostRoot/+{% if outer_prefix %}{{ outer_prefix }}($|/.*){% else %}(.*){% endif %} \1/{{ inner_prefix.strip('/') }}/VirtualHostRoot/{% if outer_prefix %}_vh_{{ outer_prefix.replace('/', '/_vh_') }}{% endif %}\2
{% endfor %}
use_backend {{ frontend['backend-name'] }}
{% endfor %}
{% for name, backend in sorted(six.iteritems(parameter_dict['backend-dict'])) %}
backend {{ name }}
cookie SERVERID rewrite
http-request set-header X-Balancer-Current-Cookie SERVERID
{% if backend['timeout'] %}
{#
Apply a slightly longer timeout than the zope timeout so that clients can see the
TimeoutReachedError from zope, that is a bit more informative than the 504 error
page from haproxy.
#}
timeout server {{ backend['timeout'] + 3 }}s
{%- endif %}
{% set has_webdav = [] -%}
{% for address, connection_count, webdav in backend_list -%}
{% for address, connection_count, webdav in backend['backend-list'] -%}
{% if webdav %}{% do has_webdav.append(None) %}{% endif -%}
{% set server_name = name ~ '-' ~ loop.index0 %}
server {{ server_name }} {{ address }} cookie {{ server_name }} check inter 3s rise 1 fall 2 maxqueue 5 maxconn {{ connection_count }}
......
......@@ -257,7 +257,8 @@ init =
port_dict[name] = port
return port
haproxy_dict = {}
backend_dict = {}
frontend_dict = {}
zope_virtualhost_monster_backend_dict = {}
for family_name, parameter_id_list in sorted(
six.iteritems(slapparameter_dict['zope-family-dict'])):
......@@ -290,17 +291,33 @@ init =
# a port for monitoring promise (which port is not important, the promise checks
# that haproxy is healthy enough to listen on a port)
options['haproxy-promise-port'] = legacy_port
haproxy_dict[family_name] = (
legacy_port,
ssl_authentication,
slapparameter_dict['timeout-dict'][family_name],
zope_family_address_list,
)
frontend_dict['legacy-frontend-' + family_name] = {
'port': legacy_port,
'client-cert-required': ssl_authentication,
'backend-name': family_name,
'request-path-prepend': None,
}
backend_dict[family_name] = {
'timeout': slapparameter_dict['timeout-dict'][family_name],
'backend-list': zope_family_address_list,
}
external_scheme = 'webdavs' if any(a[2] for a in zope_family_address_list) else 'https'
self.buildout['publish'][family_name] = "{external_scheme}://{ipv4}:{legacy_port}".format(**locals())
self.buildout['publish'][family_name + "-v6"] = "{external_scheme}://[{ipv6}]:{legacy_port}".format(**locals())
options['backend-dict'] = haproxy_dict
for frontend_name, frontend in six.iteritems(slapparameter_dict['frontend-parameter-dict']):
frontend_port = get_port('frontend-' + frontend_name)
family_name = frontend['zope-family']
frontend_dict['frontend-' + frontend_name] = {
'port': frontend_port,
'client-cert-required': slapparameter_dict['ssl-authentication-dict'][family_name],
'backend-name': family_name,
'request-path-prepend': frontend['internal-path'],
}
self.buildout['publish']['url-backend-' + frontend_name] = "https://[{ipv6}]:{frontend_port}".format(**locals())
options['backend-dict'] = backend_dict
options['frontend-dict'] = frontend_dict
options['zope-virtualhost-monster-backend-dict'] = zope_virtualhost_monster_backend_dict
if port_dict != previous_port_dict:
......
......@@ -388,13 +388,13 @@ return =
{% set request_frontend_name = 'request-frontend-' ~ frontend_name -%}
{% set frontend_software_url = frontend_parameters.get('software-url', 'http://git.erp5.org/gitweb/slapos.git/blob_plain/HEAD:/software/apache-frontend/software.cfg') -%}
{% set frontend_software_type = frontend_parameters.get('software-type', '') -%}
{% do frontend_parameters.__setitem__('internal-path', frontend_parameters.get('internal-path', '/%(site-id)s') % {'site-id': site_id}) %}
{% set frontend_instance_parameters = frontend_parameters.get('instance-parameters', {}) -%}
{% if frontend_instance_parameters.setdefault('type', 'zope') == 'zope' -%}
{% if frontend_instance_parameters.setdefault('type', '') == '' -%}
{% do frontend_instance_parameters.setdefault('authenticate-to-backend', 'true') -%}
{% set zope_family_name = frontend_parameters['zope-family'] -%}
{% do assert(zope_family_name in zope_family_dict, 'Unknown family %s for frontend %s' % (zope_family_name, frontend_name)) -%}
{% do frontend_instance_parameters.setdefault('url', '${request-balancer:connection-' ~ zope_family_name ~ '-v6}') -%}
{% do frontend_instance_parameters.setdefault('path', frontend_parameters.get('internal-path', '/%(site-id)s') % {'site-id': site_id}) -%}
{% do frontend_instance_parameters.setdefault('url', '${request-balancer:connection-url-backend-' ~ frontend_name ~ '}') -%}
{% endif %}
[{{ request_frontend_name }}]
<= request-frontend-base
......@@ -446,6 +446,9 @@ config-allow-redirects = 0
{% do balancer_ret_dict.__setitem__(family + '-test-runner-url-list', False) -%}
{% endif -%}
{% endfor -%}
{% for frontend_name in frontend_parameter_dict -%}
{% do balancer_ret_dict.__setitem__('url-backend-' ~ frontend_name, False) -%}
{% endfor -%}
{% set balancer_key_config_dict = {
'monitor-passwd': 'monitor-htpasswd:passwd',
} -%}
......@@ -467,6 +470,7 @@ config-allow-redirects = 0
config_key='balancer',
config={
'zope-family-dict': zope_family_parameter_dict,
'frontend-parameter-dict': frontend_parameter_dict,
'ssl-authentication-dict': ssl_authentication_dict,
'timeout-dict': balancer_timeout_dict,
'apachedex-promise-threshold': monitor_dict.get('apachedex-promise-threshold', 70),
......
Markdown is supported
0%
or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment