ansible-taler-exchange

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

commit ff04e066f6ac8a802a6b39a017d20fc10b7bf5c4
parent 515575073e194e3182776d1ca75a1d27f5c6c893
Author: Florian Dold <dold@taler.net>
Date:   Mon,  7 Sep 2026 15:19:55 +0200

monitoring: add opt-in external node monitoring

Consume the tsys-infra monitoring onboarding exports from host variables,
requiring Ansible Vault encryption for the private keys. Validate the
bundle before stopping applications, then configure loopback node metrics,
a dedicated nginx mTLS proxy and durable RELP log forwarding.

Keep installed monitoring running when enable_monitoring is false, and
preserve its exporter and textfiles during legacy monitoring removal.

Diffstat:
MREADME | 98+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++--
Acontrib/tests/test_monitoring.py | 288+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Minventories/group_vars/all/defaults.yml | 4++++
Mplaybooks/setup.yml | 11+++++++++++
Aroles/monitoring/files/node-exporter-proxy.service | 30++++++++++++++++++++++++++++++
Aroles/monitoring/files/validate-bundle.py | 122+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mroles/monitoring/handlers/main.yml | 42++++++++++++++++++++++++++++++++++++++++++
Mroles/monitoring/tasks/disable.yml | 9++++++++-
Aroles/monitoring/tasks/main.yml | 141+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Aroles/monitoring/tasks/preflight.yml | 68++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
Mroles/monitoring/tasks/remove.yml | 31++++++++++++++++++++++++-------
Aroles/monitoring/templates/nginx.conf.j2 | 47+++++++++++++++++++++++++++++++++++++++++++++++
Aroles/monitoring/templates/node-exporter-default.j2 | 2++
Aroles/monitoring/templates/rsyslog-forward.conf.j2 | 23+++++++++++++++++++++++
Mtest | 2++
15 files changed, 908 insertions(+), 10 deletions(-)

diff --git a/README b/README @@ -125,6 +125,93 @@ host and referenced from the exchange configuration. NOTE: this should still be further automated. +### External node monitoring + +To monitor an exchange node through `tsys-infra/monitoring`, first enroll it +as an external monitoring client using that repository's +`monitoring/README-external.md`. Retrieve both exported YAML files through +an authenticated, encrypted channel. This repository configures only the +exchange node; it does not connect to Sentol or enroll hosts itself. + +Place the exports in the exchange inventory host directory: + +``` +inventories/host_vars/$HOST/monitoring-client.yml +inventories/host_vars/$HOST/monitoring-client-secrets.yml +``` + +The directory uses this repository's inventory hostname, such as `spec`. +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. + +Commit both files, encrypting the entire secrets file with Ansible Vault +before staging it: + +``` +$ chmod 0600 inventories/host_vars/$HOST/monitoring-client-secrets.yml +$ ansible-vault encrypt inventories/host_vars/$HOST/monitoring-client-secrets.yml +``` + +Ansible uses the existing `vault_pass.txt` configuration. The committed +secrets file must begin with `$ANSIBLE_VAULT;`; plaintext secrets are +rejected when monitoring is enabled. Do not commit the plaintext export +or the vault password. The Ansible controller needs Python 3 and OpenSSL +for bundle validation. As with other vaulted host variables, Ansible may +need the vault password even when monitoring management is disabled. + +Set this in the host's public inventory configuration, then deploy normally: + +```yaml +enable_monitoring: true +``` + +``` +$ ./deploy "$HOST" +``` + +The `monitoring` role installs Debian's node_exporter with a loopback-only +listener and systemd metrics, a dedicated `node-exporter-proxy` nginx +service requiring Sentol's mTLS client identity, and durable rsyslog +RELP/TLS forwarding. All connection settings and certificate identities +come from the bundle. TLS material lives in `/etc/taler-monitoring/tls`; +private keys and the enclosing directory are accessible only to root. +The role expects Debian's root-running rsyslog service to read those keys. + +Allow Sentol to reach the exported metrics proxy address and port +(normally TCP 9100), and allow the node to reach the exported RELP server +(normally TCP 2514). Firewall and routing configuration is external to +this role. The dedicated proxy leaves exchange webserver sites intact. + +Verify the local services with: + +``` +$ systemctl is-active prometheus-node-exporter node-exporter-proxy rsyslog +$ curl --fail http://127.0.0.1:9101/metrics >/dev/null +$ logger --tag external-monitoring-enrollment 'Exchange monitoring test' +``` + +Use the exported backend address if it differs. Follow the external-client +runbook to perform an authenticated scrape from Sentol, confirm anonymous +scrapes fail, and find the test message in the remote log archive and +VictoriaLogs. + +For renewal, retrieve both refreshed exports after `make deploy-sentol`, +replace the local files, encrypt the complete new secrets file before +staging it, commit both, and run `deploy` again. Do not reuse an old key +with a renewed certificate. The role validates both pairs before deployment +and reloads or restarts the affected services when their material changes. + +`enable_monitoring` defaults to false. False skips management and leaves +installed monitoring running. To offboard, follow the Sentol runbook and, +on the exchange node, stop and disable `node-exporter-proxy` and +`prometheus-node-exporter`, remove `/etc/rsyslog.d/60-sentol-forward.conf`, +validate with `rsyslogd -N1` and restart rsyslog, then remove the proxy unit +and `/etc/taler-monitoring` (including its private keys) and run +`systemctl daemon-reload`. Set the flag false and remove both inventory +exports. This does not revoke previously issued certificates; complete +the Sentol offboarding steps as well. + ### remove-monitoring The legacy monitoring stack is no longer deployed by this repository. To @@ -136,6 +223,10 @@ $ ./remove-monitoring $DEPLOYMENT ``` This is destructive. It does not back up the local Prometheus or Alloy data. +When the external monitoring proxy unit is installed, cleanup preserves +its exporter service, package, configuration, and exporter textfile directory, +independently of `enable_monitoring`. Other legacy Prometheus data is removed. +It does not offboard the external monitoring client. ### Setting up backups (TOPS-only for now) @@ -319,7 +410,8 @@ Deploys libeufin-nexus which connects us to the bank. ### monitoring -Contains the cleanup tasks used by the remove-monitoring playbook. +Provisions external node monitoring when `enable_monitoring` is true. +Also contains the legacy cleanup tasks used by the remove-monitoring playbook. ### pixel_borg @@ -402,4 +494,6 @@ The extended ./test also exercises real systemd shutdown, package-start suppression, a pre-2.20 Ansible dependency bootstrap, secret permissions, devtesting revocation, preservation of unrelated configuration, failed deployment and recovery. All fixtures run in the disposable container, never an inventory -production host. +production host. Monitoring regressions also cover Vault-encrypted onboarding +bundles, preflight failures before application shutdown, authenticated metrics, +RELP delivery, certificate renewal, idempotence, check mode, and legacy cleanup. diff --git a/contrib/tests/test_monitoring.py b/contrib/tests/test_monitoring.py @@ -0,0 +1,288 @@ +#!/usr/bin/env python3 +"""Exercise external monitoring in the disposable deployment test container.""" +import argparse +import copy +import json +import os +from pathlib import Path +import re +import shutil +import subprocess +import tempfile +import time + +REPO = Path(__file__).resolve().parents[2] +IDENTITY = 'enrolled-exchange.example' +SERVICES = ['prometheus-node-exporter', 'node-exporter-proxy', 'rsyslog'] + + +def run(*args, **kwargs): + return subprocess.run(list(map(str, args)), capture_output=True, text=True, + check=kwargs.pop('check', True), **kwargs) + + +def main(): + parser = argparse.ArgumentParser() + parser.add_argument('container') + parser.add_argument('private_key') + args = parser.parse_args() + + def container(*cmd, **kwargs): + return run('podman', 'exec', '-i', args.container, *cmd, **kwargs) + + with tempfile.TemporaryDirectory(prefix='taler-monitoring-test-') as directory: + work = Path(directory) + inventory = work / 'inventory' + host_vars = inventory / 'host_vars/podman-localhost' + shutil.copytree(REPO / 'inventories/host_vars/podman-localhost', host_vars) + shutil.copytree(REPO / 'inventories/group_vars', inventory / 'group_vars') + shutil.copyfile(REPO / 'inventories/default', inventory / 'hosts') + vault_password = work / 'vault-password' + vault_password.write_text('disposable-monitoring-test-password\n') + vault_password.chmod(0o600) + pki = work / 'pki' + pki.mkdir(mode=0o700) + + def openssl(*cmd): + return run('openssl', *cmd) + + openssl('req', '-x509', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', + '-nodes', '-keyout', pki / 'ca.key', '-out', pki / 'ca.cert', '-days', '2', + '-subj', '/CN=Disposable monitoring CA', '-addext', 'basicConstraints=critical,CA:TRUE') + + def leaf(name, common_name, purpose): + openssl('req', '-new', '-newkey', 'ec', '-pkeyopt', 'ec_paramgen_curve:P-256', + '-nodes', '-keyout', pki / f'{name}.key', '-out', pki / f'{name}.csr', + '-subj', f'/CN={common_name}') + ext = pki / f'{name}.ext' + ext.write_text(f'basicConstraints=critical,CA:FALSE\nkeyUsage=critical,digitalSignature\n' + f'extendedKeyUsage={purpose}\nsubjectAltName=DNS:{common_name}\n') + openssl('x509', '-req', '-in', pki / f'{name}.csr', '-CA', pki / 'ca.cert', + '-CAkey', pki / 'ca.key', '-CAcreateserial', '-days', '1', + '-extfile', ext, '-out', pki / f'{name}.cert') + + leaf('client', IDENTITY, 'clientAuth') + leaf('server', IDENTITY, 'serverAuth') + leaf('sentol', 'sentol', 'clientAuth') + leaf('receiver', 'receiver.example', 'serverAuth') + secret_values = [] + + def bundle(): + public = {'monitoring_client': { + 'identity': IDENTITY, + 'monitoring_ca_certificate': (pki / 'ca.cert').read_text(), + 'client_certificate': (pki / 'client.cert').read_text(), + 'server_certificate': (pki / 'server.cert').read_text(), + 'relp': {'server_address': '127.0.0.1', 'server_port': 12514, + 'server_name': 'receiver.example'}, + 'node_exporter': {'backend_listen_address': '127.0.0.1:9101', + 'proxy_bind_address': '127.0.0.1', 'proxy_port': 9100, + 'prometheus_client_identity': 'sentol'}}} + secrets = {'monitoring_client_secrets': { + 'client_private_key': (pki / 'client.key').read_text(), + 'server_private_key': (pki / 'server.key').read_text()}} + secret_values.extend(secrets['monitoring_client_secrets'].values()) + return public, secrets + + def install_bundle(public, secrets, encrypt=True): + (host_vars / 'monitoring-client.yml').write_text(json.dumps(public)) + path = host_vars / 'monitoring-client-secrets.yml' + path.write_text(json.dumps(secrets)) + path.chmod(0o600) + if encrypt: + run('ansible-vault', 'encrypt', '--vault-password-file', vault_password, path, + env=dict(os.environ, ANSIBLE_CONFIG=str(REPO / 'test-ansible.cfg'))) + + env = dict(os.environ, ANSIBLE_CONFIG=str(REPO / 'test-ansible.cfg'), ANSIBLE_NOCOWS='1') + ansible = ['ansible-playbook', '-i', str(inventory / 'hosts'), '-l', 'podman-localhost', + '--user', 'root', '--private-key', args.private_key, '--vault-password-file', + str(vault_password), '-vvv', '--diff'] + + def invoke(playbook, enabled=True, check=False, failure=False): + command = ansible + ['-e', json.dumps({'enable_monitoring': enabled})] + if check: + command += ['--check'] + result = run(*command, playbook, env=env, check=False) + output = result.stdout + result.stderr + assert 'BEGIN PRIVATE KEY' not in output, 'Private key appeared in Ansible output' + for secret in secret_values: + assert secret.splitlines()[1] not in output, 'Private key appeared in Ansible output' + if bool(result.returncode) != failure: + print(output) + raise AssertionError(f'Unexpected monitoring playbook status: {result.returncode}') + return output + + role_play = work / 'monitoring.json' + role_play.write_text(json.dumps([{'hosts': 'all', 'gather_facts': False, + 'pre_tasks': [{'ansible.builtin.setup': {}, 'no_log': True}], + 'tasks': [{'ansible.builtin.include_role': {'name': 'monitoring'}, + 'when': 'enable_monitoring | bool'}]}])) + + def application_pid(): + return container('systemctl', 'show', '-p', 'MainPID', 'taler-exchange-httpd').stdout + + # Install and activate our own unrelated site; another regression's + # configuration file need not have been loaded by the running master. + container('tee', '/etc/nginx/conf.d/monitoring-regression-unrelated.conf', + input='server { listen 127.0.0.1:8098; return 200 "unrelated"; }\n') + container('nginx', '-t') + container('systemctl', 'reload', 'nginx') + container('curl', '--fail', '--silent', '--show-error', 'http://127.0.0.1:8098/') + pid = application_pid() + invoke(role_play, enabled=False) + assert container('test', '-e', '/etc/taler-monitoring', check=False).returncode != 0 + invoke(REPO / 'playbooks/setup.yml', failure=True) + assert application_pid() == pid, 'Missing bundle stopped the exchange' + public, secrets = bundle() + install_bundle(public, secrets, encrypt=False) + invoke(REPO / 'playbooks/setup.yml', failure=True) + assert application_pid() == pid, 'Plaintext secrets stopped the exchange' + install_bundle(public, secrets) + (host_vars / 'monitoring-client.yml').unlink() + invoke(REPO / 'playbooks/setup.yml', failure=True) + install_bundle(public, secrets) + vault_password.write_text('incorrect-password\n') + 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']: + 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 == 'identity': + bad_public['monitoring_client']['identity'] = 'wrong-host' + elif change == 'purpose': + bad_public['monitoring_client']['client_certificate'] = public['monitoring_client']['server_certificate'] + elif change == 'key': + bad_secrets['monitoring_client_secrets']['client_private_key'] = secrets['monitoring_client_secrets']['server_private_key'] + elif change == 'missing': + del bad_public['monitoring_client']['relp'] + else: + bad_public['monitoring_client']['node_exporter']['prometheus_client_identity'] = 'sentol"; }' + install_bundle(bad_public, bad_secrets) + invoke(REPO / 'playbooks/setup.yml', failure=True) + assert application_pid() == pid, f'Invalid {change} stopped the exchange' + print('PASS: missing, plaintext, undecryptable and invalid bundles fail before shutdown', flush=True) + + install_bundle(public, secrets) + invoke(role_play, check=True) + assert container('test', '-e', '/etc/taler-monitoring', check=False).returncode != 0 + # A legacy exporter may already own port 9100. Reconfiguration must + # move it to loopback before the dedicated TLS proxy takes that port. + container('apt-get', 'install', '-y', '--no-install-recommends', 'prometheus-node-exporter') + container('systemctl', 'start', 'prometheus-node-exporter') + invoke(role_play) + + remote = '/tmp/taler-monitoring-test' + container('install', '-d', '-m', '0700', remote) + run('podman', 'cp', pki, f'{args.container}:{remote}/pki') + + def proxy(kind='sentol', path='/metrics', status=False): + command = ['curl', '--silent', '--show-error', '--max-time', '10', '--noproxy', '*', + '--cacert', f'{remote}/pki/ca.cert', '--resolve', f'{IDENTITY}:9100:127.0.0.1'] + if kind: + command += ['--cert', f'{remote}/pki/{kind}.cert', '--key', f'{remote}/pki/{kind}.key'] + if status: + command += ['--output', '/dev/null', '--write-out', '%{http_code}'] + else: + command += ['--fail'] + return container(*command, f'https://{IDENTITY}:9100{path}', check=False) + + def verify_metrics(): + for _ in range(30): + result = proxy() + if result.returncode == 0: + break + time.sleep(1) + assert result.returncode == 0, result.stderr + assert 'node_systemd_unit_state{' in result.stdout + assert 'taler-exchange-httpd.service' in result.stdout + assert proxy('client', status=True).stdout == '403' + assert proxy(None, status=True).stdout in ['400', '403'] + assert proxy(path='/other', status=True).stdout == '404' + listeners = container('ss', '-lnt').stdout + assert '127.0.0.1:9101' in listeners + assert '0.0.0.0:9101' not in listeners and '[::]:9101' not in listeners + assert container('curl', '--fail', '--max-time', '5', 'http://127.0.0.1:9100/metrics', + check=False).returncode != 0 + container('systemctl', 'is-active', '--quiet', *SERVICES) + + verify_metrics() + 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', + 'test -r /etc/taler-monitoring/tls/client.key.pem', check=False).returncode != 0 + receiver = f'''module(load="imrelp" tls.tlsLib="openssl") +ruleset(name="received") {{ action(type="omfile" file="{remote}/received.log") }} +input(type="imrelp" address="127.0.0.1" port="12514" ruleset="received" + tls="on" tls.caCert="{remote}/pki/ca.cert" + tls.myCert="{remote}/pki/receiver.cert" tls.myPrivKey="{remote}/pki/receiver.key" + tls.authMode="name" tls.permittedPeer=["{IDENTITY}"]) +''' + container('tee', f'{remote}/receiver.conf', input=receiver) + container('rsyslogd', '-N1', '-f', f'{remote}/receiver.conf') + + def receiver_start(): + container('systemd-run', '--unit=monitoring-test-receiver', '--collect', + 'rsyslogd', '-n', '-f', f'{remote}/receiver.conf', '-i', f'{remote}/receiver.pid') + + def log_delivery(message, emit=True): + if emit: + container('logger', '--tag', 'monitoring-regression', message) + for _ in range(45): + received = container('cat', f'{remote}/received.log', check=False).stdout + if message in received: + return + time.sleep(1) + raise AssertionError('RELP test message was not delivered') + + receiver_start() + try: + log_delivery('initial-monitoring-enrollment') + print('PASS: mTLS metrics, client allowlist, systemd metrics, permissions and RELP delivery', flush=True) + container('systemctl', 'stop', 'monitoring-test-receiver') + container('logger', '--tag', 'monitoring-regression', 'queued-during-receiver-outage') + time.sleep(2) + container('systemctl', 'restart', 'rsyslog') + receiver_start() + log_delivery('queued-during-receiver-outage', emit=False) + print('PASS: RELP queue survives receiver outage and rsyslog restart', flush=True) + before = container('systemctl', 'show', '-p', 'MainPID', *SERVICES).stdout + output = invoke(role_play) + assert re.search(r'changed=0\s', output), 'Second monitoring deployment was not idempotent' + invoke(role_play, check=True) + invoke(role_play, enabled=False) + assert container('systemctl', 'show', '-p', 'MainPID', *SERVICES).stdout == before + + leaf('client', IDENTITY, 'clientAuth') + leaf('server', IDENTITY, 'serverAuth') + public, secrets = bundle() + install_bundle(public, secrets) + invoke(role_play) + verify_metrics() + log_delivery('renewed-monitoring-certificates') + assert container('cat', '/etc/taler-monitoring/tls/server.cert.pem').stdout == (pki / 'server.cert').read_text() + print('PASS: idempotence, check mode, disabled management and certificate renewal', flush=True) + + # Exercise the real setup entrypoint with monitoring enabled. + invoke(REPO / 'playbooks/setup.yml') + verify_metrics() + container('mkdir', '-p', '/var/lib/prometheus/metrics2') + container('touch', '/var/lib/prometheus/metrics2/legacy-data', + '/var/lib/prometheus/node-exporter/keep.prom') + invoke(REPO / 'playbooks/remove-monitoring.yml', enabled=False) + assert container('test', '-e', '/var/lib/prometheus/metrics2', check=False).returncode != 0 + container('test', '-f', '/var/lib/prometheus/node-exporter/keep.prom') + verify_metrics() + log_delivery('monitoring-survives-legacy-removal') + container('curl', '--fail', '--silent', '--show-error', '--unix-socket', + '/var/run/taler-exchange/httpd/exchange-http.sock', 'http://localhost/config') + container('curl', '--fail', '--silent', '--show-error', 'http://127.0.0.1:8098/') + print('PASS: normal deployment and legacy cleanup preserve monitoring and exchange endpoints', flush=True) + finally: + container('systemctl', 'stop', 'monitoring-test-receiver', check=False) + container('rm', '-rf', remote) + + +if __name__ == '__main__': + main() diff --git a/inventories/group_vars/all/defaults.yml b/inventories/group_vars/all/defaults.yml @@ -6,6 +6,10 @@ deploy_auditor: true # Deploy challenger? deploy_challenger: false +# Provision the external monitoring bundle enrolled in tsys-infra/monitoring. +# False skips management; it does not stop previously installed monitoring. +enable_monitoring: false + # If true, use EBICS keys from that were externally created. ebics_keys_external: false diff --git a/playbooks/setup.yml b/playbooks/setup.yml @@ -8,6 +8,12 @@ ansible.builtin.setup: no_log: true + - name: Validate the external monitoring bundle before stopping applications + ansible.builtin.include_role: + name: monitoring + tasks_from: preflight + when: enable_monitoring | default(false) | bool + - name: Reject the removed in-deployment restore switch ansible.builtin.assert: that: not (enable_restore_backup | default(false) | bool) @@ -89,6 +95,11 @@ ansible.builtin.include_role: name: webserver + - name: Configure monitoring + ansible.builtin.include_role: + name: monitoring + when: enable_monitoring | default(false) | bool + - name: Configure database ansible.builtin.include_role: name: database diff --git a/roles/monitoring/files/node-exporter-proxy.service b/roles/monitoring/files/node-exporter-proxy.service @@ -0,0 +1,30 @@ +[Unit] +Description=nginx mTLS proxy for Prometheus node_exporter +Requires=prometheus-node-exporter.service +After=network-online.target prometheus-node-exporter.service +Wants=network-online.target + +[Service] +Type=simple +ExecStartPre=/usr/sbin/nginx -t -c /etc/taler-monitoring/nginx.conf +ExecStart=/usr/sbin/nginx -c /etc/taler-monitoring/nginx.conf -g 'daemon off;' +ExecReload=/bin/kill -HUP $MAINPID +KillSignal=SIGQUIT +TimeoutStopSec=5s +Restart=on-failure +RestartSec=5s +NoNewPrivileges=true +PrivateTmp=true +ProtectHome=true +ProtectSystem=full +ProtectControlGroups=true +ProtectKernelLogs=true +ProtectKernelModules=true +ProtectKernelTunables=true +RestrictSUIDSGID=true +LockPersonality=true +MemoryDenyWriteExecute=true +SystemCallArchitectures=native + +[Install] +WantedBy=multi-user.target diff --git a/roles/monitoring/files/validate-bundle.py b/roles/monitoring/files/validate-bundle.py @@ -0,0 +1,122 @@ +#!/usr/bin/env python3 +"""Validate external monitoring exports without disclosing their contents.""" +import ipaddress +import json +import re +from pathlib import Path +import subprocess +import sys +import tempfile + + +def require(condition, message): + if not condition: + raise ValueError(message) + + +def identity(value): + return isinstance(value, str) and re.fullmatch(r'[A-Za-z0-9_.-]+', value) + + +def port(value): + return type(value) is int and 0 < value < 65536 + + +def address(value): + require(isinstance(value, str) and '%' not in value, + 'Monitoring proxy bind address must be an IP address without a scope ID.') + try: + # The proxy template supplies brackets for IPv6. + return ipaddress.ip_address(value.removeprefix('[').removesuffix(']')) + except ValueError: + raise ValueError('Monitoring proxy bind address must be an IP address.') from None + + +def openssl(*args): + result = subprocess.run(['openssl', *map(str, args)], capture_output=True) + require(result.returncode == 0, 'Monitoring certificate or private key validation failed.') + return result.stdout + + +def validate(bundle): + public = bundle['public']['monitoring_client'] + secret = bundle['secrets']['monitoring_client_secrets'] + node = public['node_exporter'] + relp = public['relp'] + require(identity(public['identity']) and identity(node['prometheus_client_identity']), + 'Invalid monitoring certificate identity.') + require(identity(relp['server_name']), 'Invalid RELP server identity.') + target = relp['server_address'] + 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.') + + # TemporaryDirectory is private (0700); private files are created as 0600. + # Validate the complete pair before Ansible replaces any live material. + with tempfile.TemporaryDirectory(prefix='taler-monitoring-') as directory: + root = Path(directory) + values = {'ca': public['monitoring_ca_certificate'], + 'client': public['client_certificate'], 'server': public['server_certificate'], + 'client-key': secret['client_private_key'], 'server-key': secret['server_private_key']} + for name, value in values.items(): + require(isinstance(value, str) and value.strip(), 'Missing monitoring TLS material.') + path = root / name + path.touch(mode=0o600) + path.write_text(value.strip() + '\n') + for kind, purpose, eku in [('client', 'sslclient', 'TLS Web Client Authentication'), + ('server', 'sslserver', 'TLS Web Server Authentication')]: + cert, key = root / kind, root / (kind + '-key') + openssl('verify', '-CAfile', root / 'ca', '-purpose', purpose, + '-verify_hostname', public['identity'], cert) + subject = openssl('x509', '-in', cert, '-noout', '-subject', '-nameopt', 'RFC2253') + require(subject.decode().strip() == 'subject=CN=' + public['identity'], + 'Monitoring certificate subject does not match the enrolled identity.') + sans = openssl('x509', '-in', cert, '-noout', '-ext', 'subjectAltName') + require(('DNS:' + public['identity']) in sans.decode().splitlines()[1].strip().split(', '), + 'Monitoring certificate SAN does not contain the enrolled identity.') + extensions = openssl('x509', '-in', cert, '-noout', '-ext', 'extendedKeyUsage') + require(extensions.decode().splitlines()[1].strip() == eku, + 'Monitoring leaf must have exactly its designated TLS purpose.') + certificate_key = openssl('x509', '-in', cert, '-pubkey', '-noout') + private_key_public = openssl('pkey', '-in', key, '-passin', 'pass:', '-pubout') + require(certificate_key == private_key_public, + 'Monitoring certificate does not match its private key.') + + +def main(): + try: + if len(sys.argv) == 3 and sys.argv[1] == '--files': + directory = Path(sys.argv[2]) + for name in ['monitoring-client.yml', 'monitoring-client-secrets.yml']: + require((directory / name).is_file(), + 'Both monitoring-client.yml and monitoring-client-secrets.yml are required in host_vars/<host>/.') + with (directory / 'monitoring-client-secrets.yml').open('rb') as source: + require(source.readline().startswith(b'$ANSIBLE_VAULT;'), + 'Encrypt the complete monitoring-client-secrets.yml with ansible-vault before deployment.') + else: + validate(json.load(sys.stdin)) + except ValueError as error: + # JSON parse errors can contain user-supplied data; never print them. + print('Invalid monitoring bundle JSON.' if isinstance(error, json.JSONDecodeError) else str(error)) + return 1 + except (KeyError, TypeError, IndexError): + print('Monitoring bundle is missing required fields or contains invalid field types.') + return 1 + except OSError: + print('Cannot read monitoring files or run openssl on the Ansible controller.') + return 1 + return 0 + + +if __name__ == '__main__': + sys.exit(main()) diff --git a/roles/monitoring/handlers/main.yml b/roles/monitoring/handlers/main.yml @@ -1,4 +1,46 @@ --- +- name: Restart monitoring node_exporter + ansible.builtin.systemd_service: + name: prometheus-node-exporter + state: restarted + when: not ansible_check_mode + +- name: Validate monitoring proxy before reload or restart + ansible.builtin.command: /usr/sbin/nginx -t -c /etc/taler-monitoring/nginx.conf + changed_when: false + listen: + - Reload monitoring proxy + - Restart monitoring proxy + when: not ansible_check_mode + +- name: Reload validated monitoring proxy + ansible.builtin.systemd_service: + name: node-exporter-proxy + state: reloaded + listen: Reload monitoring proxy + when: not ansible_check_mode + +- name: Restart validated monitoring proxy + ansible.builtin.systemd_service: + name: node-exporter-proxy + daemon_reload: true + state: restarted + listen: Restart monitoring proxy + when: not ansible_check_mode + +- name: Validate monitoring rsyslog before restart + ansible.builtin.command: /usr/sbin/rsyslogd -N1 + changed_when: false + listen: Restart monitoring rsyslog + when: not ansible_check_mode + +- name: Restart validated monitoring rsyslog + ansible.builtin.systemd_service: + name: rsyslog + state: restarted + listen: Restart monitoring rsyslog + when: not ansible_check_mode + - name: Validate nginx before restart ansible.builtin.command: nginx -c /etc/nginx/nginx.conf -t changed_when: false diff --git a/roles/monitoring/tasks/disable.yml b/roles/monitoring/tasks/disable.yml @@ -1,4 +1,9 @@ --- +- name: Detect the external monitoring installation independently of inventory + ansible.builtin.stat: + path: /etc/systemd/system/node-exporter-proxy.service + register: monitoring_external_installation + - name: Get the list of services service_facts: @@ -14,4 +19,6 @@ - prometheus-postgres-exporter.service - prometheus-alertmanager.service - prometheus.service - when: item in ansible_facts["services"] + when: + - item in ansible_facts["services"] + - item != 'prometheus-node-exporter.service' or not monitoring_external_installation.stat.exists diff --git a/roles/monitoring/tasks/main.yml b/roles/monitoring/tasks/main.yml @@ -0,0 +1,141 @@ +--- +- name: Validate the external monitoring bundle + ansible.builtin.include_tasks: preflight.yml + +- name: Discover existing monitoring services for check mode + ansible.builtin.service_facts: + when: ansible_check_mode + +- name: Install monitoring client packages without starting default listeners + ansible.builtin.apt: + name: + - nginx + - prometheus-node-exporter + - rsyslog + - rsyslog-relp + state: present + update_cache: true + cache_valid_time: 3600 + install_recommends: false + policy_rc_d: 101 + +- name: Create monitoring configuration and TLS directories + ansible.builtin.file: + path: "{{ item.path }}" + state: directory + owner: root + group: root + mode: "{{ item.mode }}" + loop: + - { path: /etc/taler-monitoring, mode: '0755' } + - { path: /etc/taler-monitoring/tls, mode: '0700' } + +- name: Install monitoring TLS material + ansible.builtin.copy: + content: "{{ item.content | trim }}\n" + dest: "/etc/taler-monitoring/tls/{{ item.name }}" + owner: root + group: root + mode: "{{ item.mode }}" + loop: + - name: ca.cert.pem + content: "{{ monitoring_public_bundle.monitoring_client.monitoring_ca_certificate }}" + mode: '0644' + - name: client.cert.pem + content: "{{ monitoring_public_bundle.monitoring_client.client_certificate }}" + mode: '0644' + - name: client.key.pem + content: "{{ monitoring_secret_bundle.monitoring_client_secrets.client_private_key }}" + mode: '0600' + - name: server.cert.pem + content: "{{ monitoring_public_bundle.monitoring_client.server_certificate }}" + mode: '0644' + - name: server.key.pem + content: "{{ monitoring_secret_bundle.monitoring_client_secrets.server_private_key }}" + mode: '0600' + no_log: true + diff: false + notify: + - Reload monitoring proxy + - Restart monitoring rsyslog + +- name: Create the node_exporter textfile directory + ansible.builtin.file: + path: /var/lib/prometheus/node-exporter + state: directory + owner: prometheus + group: prometheus + mode: '0755' + when: not ansible_check_mode or 'prometheus-node-exporter.service' in ansible_facts['services'] + +- name: Configure the private node_exporter listener and systemd collector + ansible.builtin.template: + src: node-exporter-default.j2 + dest: /etc/default/prometheus-node-exporter + owner: root + group: root + mode: '0644' + notify: Restart monitoring node_exporter + +- name: Configure the dedicated monitoring proxy + ansible.builtin.template: + src: nginx.conf.j2 + dest: /etc/taler-monitoring/nginx.conf + owner: root + group: root + mode: '0644' + validate: /usr/sbin/nginx -t -c %s + notify: Reload monitoring proxy + +- name: Install the dedicated monitoring proxy unit + ansible.builtin.copy: + src: node-exporter-proxy.service + dest: /etc/systemd/system/node-exporter-proxy.service + owner: root + group: root + mode: '0644' + notify: Restart monitoring proxy + +- name: Create the persistent RELP queue directory + ansible.builtin.file: + path: /var/spool/rsyslog + state: directory + owner: root + group: root + mode: '0700' + +- name: Configure authenticated RELP forwarding + ansible.builtin.template: + src: rsyslog-forward.conf.j2 + dest: /etc/rsyslog.d/60-sentol-forward.conf + owner: root + group: root + mode: '0644' + validate: /usr/sbin/rsyslogd -N1 -f %s + notify: Restart monitoring rsyslog + +- name: Validate the complete rsyslog configuration before activation + ansible.builtin.command: /usr/sbin/rsyslogd -N1 + changed_when: false + when: not ansible_check_mode + +- name: Load the configured monitoring units + ansible.builtin.systemd_service: + daemon_reload: true + when: not ansible_check_mode + +# Move an existing legacy exporter off port 9100 before starting the proxy. +# This also loads renewed certificate pairs before reporting success. +- name: Apply monitoring configuration before enabling services + ansible.builtin.meta: flush_handlers + +- name: Enable monitoring services after configuration + ansible.builtin.systemd_service: + name: "{{ item }}" + state: started + enabled: true + loop: + - prometheus-node-exporter + - node-exporter-proxy + - rsyslog + when: not ansible_check_mode or (item ~ '.service') in ansible_facts['services'] diff --git a/roles/monitoring/tasks/preflight.yml b/roles/monitoring/tasks/preflight.yml @@ -0,0 +1,68 @@ +--- +- name: Check monitoring bundle files and whole-file Vault encryption + ansible.builtin.command: + argv: + - "{{ ansible_playbook_python }}" + - "{{ role_path }}/files/validate-bundle.py" + - --files + - "{{ inventory_dir }}/host_vars/{{ inventory_hostname }}" + delegate_to: localhost + become: false + register: monitoring_file_check + changed_when: false + failed_when: false + check_mode: false + +- name: Require both monitoring exports with Vault-encrypted secrets + ansible.builtin.assert: + that: monitoring_file_check.rc == 0 + fail_msg: "{{ monitoring_file_check.stdout }}" + quiet: true + +- name: Load the monitoring exports without logging credentials + block: + - name: Load public monitoring configuration + ansible.builtin.include_vars: + file: "{{ inventory_dir }}/host_vars/{{ inventory_hostname }}/monitoring-client.yml" + name: monitoring_public_bundle + no_log: true + + - name: Decrypt monitoring credentials + ansible.builtin.include_vars: + file: "{{ inventory_dir }}/host_vars/{{ inventory_hostname }}/monitoring-client-secrets.yml" + name: monitoring_secret_bundle + no_log: true + + - name: Validate monitoring settings and certificate pairs on the controller + ansible.builtin.command: + argv: + - "{{ ansible_playbook_python }}" + - "{{ role_path }}/files/validate-bundle.py" + stdin: >- + {{ {'public': monitoring_public_bundle, 'secrets': monitoring_secret_bundle} | to_json }} + delegate_to: localhost + become: false + register: monitoring_bundle_check + changed_when: false + failed_when: false + check_mode: false + no_log: true + + # The validator prints only fixed diagnostics, never supplied values or PEMs. + - name: Require a valid external monitoring bundle + ansible.builtin.assert: + that: monitoring_bundle_check.rc == 0 + fail_msg: "{{ monitoring_bundle_check.stdout }}" + quiet: true + + rescue: + - name: Report an unusable monitoring bundle + ansible.builtin.fail: + msg: >- + Cannot load or validate the monitoring exports. Check the exported YAML, + Vault password, certificate identities, purposes, validity and key pairs. + +- name: Require a Debian-family monitoring client + ansible.builtin.assert: + that: ansible_facts['os_family'] == 'Debian' + quiet: true diff --git a/roles/monitoring/tasks/remove.yml b/roles/monitoring/tasks/remove.yml @@ -51,13 +51,10 @@ - name: Purge legacy monitoring packages ansible.builtin.apt: - name: - - alloy - - prometheus - - prometheus-alertmanager - - prometheus-nginx-exporter - - prometheus-node-exporter - - prometheus-postgres-exporter + name: >- + {{ ['alloy', 'prometheus', 'prometheus-alertmanager', + 'prometheus-nginx-exporter', 'prometheus-postgres-exporter'] + + ([] if monitoring_external_installation.stat.exists else ['prometheus-node-exporter']) }} state: absent purge: true autoremove: true @@ -78,6 +75,26 @@ - /etc/prometheus - /var/lib/alloy - /var/lib/prometheus + when: >- + not monitoring_external_installation.stat.exists or + item not in ['/etc/default/prometheus-node-exporter', '/var/lib/prometheus'] + +- name: Find legacy Prometheus data alongside the retained exporter directory + ansible.builtin.find: + paths: /var/lib/prometheus + file_type: any + hidden: true + excludes: node-exporter + register: monitoring_legacy_prometheus_data + when: monitoring_external_installation.stat.exists + +- name: Remove legacy Prometheus data without removing exporter textfiles + ansible.builtin.file: + path: "{{ item.path }}" + state: absent + loop: "{{ monitoring_legacy_prometheus_data.files | default([]) }}" + loop_control: + label: "{{ item.path }}" - name: Find legacy monitoring nginx logs ansible.builtin.find: diff --git a/roles/monitoring/templates/nginx.conf.j2 b/roles/monitoring/templates/nginx.conf.j2 @@ -0,0 +1,47 @@ +# 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(']', '') %} +user www-data; +worker_processes 1; +pid /run/node-exporter-proxy.pid; +error_log stderr warn; + +events { + worker_connections 128; +} + +http { + access_log off; + server_tokens off; + map_hash_bucket_size 128; + map $ssl_client_s_dn $taler_node_exporter_client_allowed { + default 0; + {{ ('CN=' ~ node.prometheus_client_identity) | to_json }} 1; + } + + server { + listen {{ '[' ~ bind ~ ']' if ':' in bind else bind }}:{{ node.proxy_port }} ssl; + server_name _; + ssl_certificate /etc/taler-monitoring/tls/server.cert.pem; + ssl_certificate_key /etc/taler-monitoring/tls/server.key.pem; + ssl_client_certificate /etc/taler-monitoring/tls/ca.cert.pem; + ssl_verify_client on; + ssl_verify_depth 1; + ssl_protocols TLSv1.2 TLSv1.3; + ssl_session_tickets off; + + if ($taler_node_exporter_client_allowed = 0) { + return 403; + } + + location = /metrics { + proxy_pass http://{{ node.backend_listen_address }}; + proxy_http_version 1.1; + proxy_set_header Connection ""; + proxy_buffering off; + } + location / { + return 404; + } + } +} diff --git a/roles/monitoring/templates/node-exporter-default.j2 b/roles/monitoring/templates/node-exporter-default.j2 @@ -0,0 +1,2 @@ +# Managed by Ansible. Plaintext metrics must remain on loopback. +ARGS="--web.listen-address={{ monitoring_public_bundle.monitoring_client.node_exporter.backend_listen_address }} --collector.textfile.directory=/var/lib/prometheus/node-exporter --collector.systemd --collector.systemd.unit-include=.+\\.(service|socket|timer|path|target)" diff --git a/roles/monitoring/templates/rsyslog-forward.conf.j2 b/roles/monitoring/templates/rsyslog-forward.conf.j2 @@ -0,0 +1,23 @@ +# Managed by Ansible. Forward local messages with durable RELP/TLS. +{% set relp = monitoring_public_bundle.monitoring_client.relp %} +module(load="omrelp" tls.tlsLib="openssl") + +action( + name="sentol_relp" + type="omrelp" + target={{ relp.server_address | to_json }} + port="{{ relp.server_port }}" + template="RSYSLOG_SyslogProtocol23Format" + tls="on" + tls.caCert="/etc/taler-monitoring/tls/ca.cert.pem" + tls.myCert="/etc/taler-monitoring/tls/client.cert.pem" + tls.myPrivKey="/etc/taler-monitoring/tls/client.key.pem" + tls.authMode="name" + tls.permittedPeer={{ relp.server_name | to_json }} + action.resumeRetryCount="-1" + queue.type="LinkedList" + queue.filename="sentol_relp" + queue.spoolDirectory="/var/spool/rsyslog" + queue.maxDiskSpace="1g" + queue.saveOnShutdown="on" +) diff --git a/test b/test @@ -78,6 +78,8 @@ ansible_args=( ansible-playbook --verbose --diff "${ansible_args[@]}" | tee "$test_state_dir/setup.log" python3 contrib/tests/test_deployment.py \ "$test_container" "$test_state_dir/id_ed25519" "$test_state_dir/setup.log" +python3 contrib/tests/test_monitoring.py \ + "$test_container" "$test_state_dir/id_ed25519" # Basic smoke checks independent of Ansible's post-deployment checks. podman exec "$test_container" systemctl is-active --quiet \