ansible-taler-exchange

Ansible playbook to deploy a production Taler Exchange
Log | Files | Refs | README | LICENSE

commit 9d13beb291ec28f6905362c0b7a73118fb93dec5
parent 7cb97359e002ac82b7b5ed653d8a7dae7917589d
Author: Florian Dold <dold@taler.net>
Date:   Tue,  8 Sep 2026 00:47:26 +0200

monitoring: support wildcard IPv4 and IPv6 proxy listeners

Accept "*" for all proxy interfaces and default omitted bind addresses to
loopback. Configure Rusty to use the wildcard listener while keeping the
exporter backend on loopback and requiring mTLS at the proxy.

Reject wildcard listener conflicts and restart the dedicated proxy when
its configuration changes so existing sockets are released.

Diffstat:
MREADME | 11+++++++++++
Mcontrib/tests/test_monitoring.py | 19++++++++++++++++---
Acontrib/tests/test_monitoring_listeners.py | 71+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minventories/host_vars/rusty/monitoring-client.yml | 2+-
Mroles/monitoring/files/validate-bundle.py | 29+++++++++++++++++++----------
Mroles/monitoring/tasks/main.yml | 3++-
Mroles/monitoring/templates/nginx.conf.j2 | 7++++++-
Mtest | 1+
8 files changed, 127 insertions(+), 16 deletions(-)

diff --git a/README b/README @@ -145,6 +145,17 @@ The identity inside the export remains the identity enrolled on Sentol; it need not equal that inventory alias. Use the complete exports without renaming their fields or combining them with other host variables. +The proxy bind address defaults to `127.0.0.1` when omitted from the bundle. +Set `monitoring_client.node_exporter.proxy_bind_address` to `"*"` (quoted +in YAML) to listen on all IPv4 and IPv6 interfaces. This requires IPv6 +support on the node. Alternatively, use a literal IPv4 or IPv6 address +assigned to the exchange node and reachable from the monitoring server. +Hostnames and bare wildcard addresses (`0.0.0.0` and `::`) are rejected; +use `"*"` for both address families. With `"*"`, the proxy and loopback +backend must use different ports (normally 9100 and 9101). The proxy +still requires mTLS, and the backend remains loopback-only. Keep the +enrolled certificate identity unchanged. + Commit both files, encrypting the entire secrets file with Ansible Vault before staging it: diff --git a/contrib/tests/test_monitoring.py b/contrib/tests/test_monitoring.py @@ -145,10 +145,13 @@ def main(): invoke(REPO / 'playbooks/setup.yml', failure=True) vault_password.write_text('disposable-monitoring-test-password\n') - for change in ['backend', 'identity', 'purpose', 'key', 'missing', 'injection']: + for change in ['backend', 'wildcard-conflict', 'identity', 'purpose', 'key', 'missing', 'injection']: bad_public, bad_secrets = copy.deepcopy(public), copy.deepcopy(secrets) if change == 'backend': bad_public['monitoring_client']['node_exporter']['backend_listen_address'] = '0.0.0.0:9101' + elif change == 'wildcard-conflict': + bad_public['monitoring_client']['node_exporter']['proxy_bind_address'] = '*' + bad_public['monitoring_client']['node_exporter']['proxy_port'] = 9101 elif change == 'identity': bad_public['monitoring_client']['identity'] = 'wrong-host' elif change == 'purpose': @@ -177,9 +180,9 @@ def main(): container('install', '-d', '-m', '0700', remote) run('podman', 'cp', pki, f'{args.container}:{remote}/pki') - def proxy(kind='sentol', path='/metrics', status=False): + def proxy(kind='sentol', path='/metrics', status=False, address='127.0.0.1'): command = ['curl', '--silent', '--show-error', '--max-time', '10', '--noproxy', '*', - '--cacert', f'{remote}/pki/ca.cert', '--resolve', f'{IDENTITY}:9100:127.0.0.1'] + '--cacert', f'{remote}/pki/ca.cert', '--resolve', f'{IDENTITY}:9100:{address}'] if kind: command += ['--cert', f'{remote}/pki/{kind}.cert', '--key', f'{remote}/pki/{kind}.key'] if status: @@ -208,6 +211,16 @@ def main(): container('systemctl', 'is-active', '--quiet', *SERVICES) verify_metrics() + public['monitoring_client']['node_exporter']['proxy_bind_address'] = '*' + install_bundle(public, secrets) + invoke(role_play) + verify_metrics() + result = proxy(address='[::1]') + assert result.returncode == 0, result.stderr + assert 'node_systemd_unit_state{' in result.stdout + assert proxy(None, status=True, address='[::1]').stdout in ['400', '403'] + assert proxy('client', status=True, address='[::1]').stdout == '403' + print('PASS: wildcard proxy serves mTLS metrics on IPv4 and IPv6', flush=True) for path, expected in [('tls', '700'), ('tls/client.key.pem', '600'), ('tls/server.key.pem', '600')]: assert container('stat', '-c', '%a:%U:%G', f'/etc/taler-monitoring/{path}').stdout.strip() == f'{expected}:root:root' assert container('su', '-s', '/bin/sh', 'nobody', '-c', diff --git a/contrib/tests/test_monitoring_listeners.py b/contrib/tests/test_monitoring_listeners.py @@ -0,0 +1,71 @@ +#!/usr/bin/env python3 +"""Check monitoring listener validation and rendered address families.""" +import importlib.util +import json +from pathlib import Path +import unittest + +from jinja2 import Environment, StrictUndefined + +REPO = Path(__file__).resolve().parents[2] +SPEC = importlib.util.spec_from_file_location( + 'monitoring_validator', REPO / 'roles/monitoring/files/validate-bundle.py') +VALIDATOR = importlib.util.module_from_spec(SPEC) +SPEC.loader.exec_module(VALIDATOR) + + +class MonitoringListenersTest(unittest.TestCase): + def node(self, bind=None, backend='127.0.0.1:9101', port=9100): + node = {'backend_listen_address': backend, 'proxy_port': port, + 'prometheus_client_identity': 'sentol'} + if bind is not None: + node['proxy_bind_address'] = bind + return node + + def render(self, node): + env = Environment(undefined=StrictUndefined) + env.filters['to_json'] = json.dumps + return env.from_string( + (REPO / 'roles/monitoring/templates/nginx.conf.j2').read_text() + ).render(monitoring_public_bundle={'monitoring_client': {'node_exporter': node}}) + + def test_supported_listeners(self): + for bind, expected in [ + (None, ['listen 127.0.0.1:9100 ssl;']), + ('*', ['listen 0.0.0.0:9100 ssl;', + 'listen [::]:9100 ssl ipv6only=on;']), + ('192.0.2.10', ['listen 192.0.2.10:9100 ssl;']), + ('2001:db8::10', ['listen [2001:db8::10]:9100 ssl;']), + ('[::1]', ['listen [::1]:9100 ssl;'])]: + with self.subTest(bind=bind): + node = self.node(bind) + VALIDATOR.validate_listeners(node) + rendered = self.render(node) + self.assertEqual([line.strip() for line in rendered.splitlines() + if line.strip().startswith('listen ')], expected) + self.assertIn('ssl_verify_client on;', rendered) + self.assertIn('proxy_pass http://127.0.0.1:9101;', rendered) + + def test_wildcard_and_default_conflicts_with_either_backend_family(self): + for bind in ['*', None]: + for backend in ['127.0.0.1:9100', '[::1]:9100']: + with self.subTest(bind=bind, backend=backend): + with self.assertRaisesRegex(ValueError, 'must not conflict'): + VALIDATOR.validate_listeners(self.node(bind, backend)) + + def test_wildcard_does_not_allow_public_backend(self): + for backend in ['0.0.0.0:9101', '[::]:9101', '192.0.2.10:9101']: + with self.subTest(backend=backend): + with self.assertRaisesRegex(ValueError, 'backend must use'): + VALIDATOR.validate_listeners(self.node('*', backend)) + + def test_invalid_bind_addresses_remain_rejected(self): + for bind in ['0.0.0.0', '::', '[::]', '224.0.0.1', 'ff02::1', + 'rusty.taler-ops.ch', '*; return 200;', 'fe80::1%eth0', 42]: + with self.subTest(bind=bind): + with self.assertRaises(ValueError): + VALIDATOR.validate_listeners(self.node(bind)) + + +if __name__ == '__main__': + unittest.main() diff --git a/inventories/host_vars/rusty/monitoring-client.yml b/inventories/host_vars/rusty/monitoring-client.yml @@ -51,6 +51,6 @@ monitoring_client: server_name: "monitoring.taler.net" node_exporter: backend_listen_address: "127.0.0.1:9101" - proxy_bind_address: "rusty.taler-ops.ch" + proxy_bind_address: "*" proxy_port: 9100 prometheus_client_identity: "sentol" diff --git a/roles/monitoring/files/validate-bundle.py b/roles/monitoring/files/validate-bundle.py @@ -32,6 +32,24 @@ def address(value): raise ValueError('Monitoring proxy bind address must be an IP address.') from None +def validate_listeners(node): + backend = node['backend_listen_address'] + require(isinstance(backend, str), 'Invalid node_exporter backend address.') + match = re.fullmatch(r'(127\.0\.0\.1|\[::1\]):([0-9]+)', backend) + require(match and 0 < int(match[2]) < 65536, + 'The node_exporter backend must use 127.0.0.1 or [::1] and a valid port.') + bind_value = node.get('proxy_bind_address', '127.0.0.1') + if bind_value == '*': + overlaps_backend = True + else: + bind = address(bind_value) + require(not bind.is_unspecified and not bind.is_multicast, + 'The monitoring proxy must bind a specific unicast address or use "*" for all interfaces.') + overlaps_backend = bind.is_loopback + require(not (overlaps_backend and node['proxy_port'] == int(match[2])), + 'Monitoring proxy and backend listeners must not conflict.') + + def openssl(*args): result = subprocess.run(['openssl', *map(str, args)], capture_output=True) require(result.returncode == 0, 'Monitoring certificate or private key validation failed.') @@ -50,16 +68,7 @@ def validate(bundle): require(isinstance(target, str) and re.fullmatch(r'[A-Za-z0-9_.:-]+', target), 'Invalid RELP server address.') require(port(relp['server_port']) and port(node['proxy_port']), 'Invalid monitoring port.') - backend = node['backend_listen_address'] - require(isinstance(backend, str), 'Invalid node_exporter backend address.') - match = re.fullmatch(r'(127\.0\.0\.1|\[::1\]):([0-9]+)', backend) - require(match and 0 < int(match[2]) < 65536, - 'The node_exporter backend must use 127.0.0.1 or [::1] and a valid port.') - bind = address(node['proxy_bind_address']) - require(not bind.is_unspecified and not bind.is_multicast, - 'The monitoring proxy must bind a specific unicast address.') - require(not (bind.is_loopback and node['proxy_port'] == int(match[2])), - 'Monitoring proxy and backend listeners must not conflict.') + validate_listeners(node) # TemporaryDirectory is private (0700); private files are created as 0600. # Validate the complete pair before Ansible replaces any live material. diff --git a/roles/monitoring/tasks/main.yml b/roles/monitoring/tasks/main.yml @@ -85,7 +85,8 @@ group: root mode: '0644' validate: /usr/sbin/nginx -t -c %s - notify: Reload monitoring proxy + # Changing between a specific and wildcard listener needs the old socket closed. + notify: Restart monitoring proxy - name: Install the dedicated monitoring proxy unit ansible.builtin.copy: diff --git a/roles/monitoring/templates/nginx.conf.j2 b/roles/monitoring/templates/nginx.conf.j2 @@ -1,6 +1,6 @@ # Managed by Ansible. Separate from the exchange webserver configuration. {% set node = monitoring_public_bundle.monitoring_client.node_exporter %} -{% set bind = node.proxy_bind_address | replace('[', '') | replace(']', '') %} +{% set bind = node.proxy_bind_address | default('127.0.0.1') | replace('[', '') | replace(']', '') %} user www-data; worker_processes 1; pid /run/node-exporter-proxy.pid; @@ -20,7 +20,12 @@ http { } server { +{% if bind == '*' %} + listen 0.0.0.0:{{ node.proxy_port }} ssl; + listen [::]:{{ node.proxy_port }} ssl ipv6only=on; +{% else %} listen {{ '[' ~ bind ~ ']' if ':' in bind else bind }}:{{ node.proxy_port }} ssl; +{% endif %} server_name _; ssl_certificate /etc/taler-monitoring/tls/server.cert.pem; ssl_certificate_key /etc/taler-monitoring/tls/server.key.pem; diff --git a/test b/test @@ -24,6 +24,7 @@ ssh-keygen -q -t ed25519 -N "" -f "$test_state_dir/id_ed25519" # Fast isolated regressions run before the complete deployment. python3 contrib/tests/test_backup.py python3 contrib/tests/test_upgrade_policy.py +python3 contrib/tests/test_monitoring_listeners.py # Build our image podman build -f Containerfile -t "$test_image"