ansible-taler-exchange

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

validate-bundle.py (7358B)


      1 #!/usr/bin/env python3
      2 """Validate external monitoring exports without disclosing their contents."""
      3 import ipaddress
      4 import json
      5 import re
      6 from pathlib import Path
      7 import subprocess
      8 import sys
      9 import tempfile
     10 from urllib.parse import urlsplit
     11 
     12 
     13 def require(condition, message):
     14     if not condition:
     15         raise ValueError(message)
     16 
     17 
     18 def identity(value):
     19     return isinstance(value, str) and re.fullmatch(r'[A-Za-z0-9_.-]+', value)
     20 
     21 
     22 def port(value):
     23     return type(value) is int and 0 < value < 65536
     24 
     25 
     26 def address(value):
     27     require(isinstance(value, str) and '%' not in value,
     28             'Monitoring proxy bind address must be an IP address without a scope ID.')
     29     try:
     30         # The proxy template supplies brackets for IPv6.
     31         return ipaddress.ip_address(value.removeprefix('[').removesuffix(']'))
     32     except ValueError:
     33         raise ValueError('Monitoring proxy bind address must be an IP address.') from None
     34 
     35 
     36 def validate_listeners(node, nginx_latency=False):
     37     backend = node['backend_listen_address']
     38     require(isinstance(backend, str), 'Invalid node_exporter backend address.')
     39     match = re.fullmatch(r'(127\.0\.0\.1|\[::1\]):([0-9]+)', backend)
     40     require(match and 0 < int(match[2]) < 65536,
     41             'The node_exporter backend must use 127.0.0.1 or [::1] and a valid port.')
     42     require(int(match[2]) != 2020, 'The node_exporter backend conflicts with Fluent Bit metrics.')
     43     bind_value = node.get('proxy_bind_address', '127.0.0.1')
     44     if bind_value == '*':
     45         overlaps_backend = True
     46     else:
     47         bind = address(bind_value)
     48         require(not bind.is_unspecified and not bind.is_multicast,
     49                 'The monitoring proxy must bind a specific unicast address or use "*" for all interfaces.')
     50         overlaps_backend = bind.is_loopback
     51     require(not (overlaps_backend and node['proxy_port'] == 2020),
     52             'The monitoring proxy conflicts with Fluent Bit metrics.')
     53     require(not (overlaps_backend and node['proxy_port'] == int(match[2])),
     54             'Monitoring proxy and backend listeners must not conflict.')
     55     if nginx_latency:
     56         require(int(match[2]) != 2021 and not (overlaps_backend and node['proxy_port'] == 2021),
     57                 'Monitoring listeners conflict with nginx latency metrics.')
     58 
     59 
     60 def openssl(*args):
     61     result = subprocess.run(['openssl', *map(str, args)], capture_output=True)
     62     require(result.returncode == 0, 'Monitoring certificate or private key validation failed.')
     63     return result.stdout
     64 
     65 
     66 def validate(bundle):
     67     public = bundle['public']['monitoring_client']
     68     secret = bundle['secrets']['monitoring_client_secrets']
     69     node = public['node_exporter']
     70     logs = public['logs']
     71     require(identity(public['identity']) and identity(node['prometheus_client_identity']),
     72             'Invalid monitoring certificate identity.')
     73     require(logs.get('protocol') == 'jsonline', 'Unsupported monitoring log protocol; export a JSON Lines bundle.')
     74     url = logs.get('url')
     75     require(isinstance(url, str) and re.fullmatch(
     76         r'https://[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]{1,5})?/jsonline', url),
     77         'Monitoring logs require an HTTPS URL ending in /jsonline without credentials, query or fragment.')
     78     try:
     79         target_port = urlsplit(url).port
     80     except ValueError:
     81         raise ValueError('Invalid monitoring log port.') from None
     82     require(port(target_port if target_port is not None else 443) and port(node['proxy_port']),
     83             'Invalid monitoring port.')
     84     latency = public.get('nginx_latency', {})
     85     require(isinstance(latency, dict) and type(latency.get('enabled', False)) is bool,
     86             'The nginx latency capability must contain a boolean enabled value.')
     87     validate_listeners(node, latency.get('enabled', False))
     88 
     89     # TemporaryDirectory is private (0700); private files are created as 0600.
     90     # Validate the complete pair before Ansible replaces any live material.
     91     with tempfile.TemporaryDirectory(prefix='taler-monitoring-') as directory:
     92         root = Path(directory)
     93         values = {'ca': public['monitoring_ca_certificate'],
     94                   'client': public['client_certificate'], 'server': public['server_certificate'],
     95                   'client-key': secret['client_private_key'], 'server-key': secret['server_private_key']}
     96         for name, value in values.items():
     97             require(isinstance(value, str) and value.strip(), 'Missing monitoring TLS material.')
     98             path = root / name
     99             path.touch(mode=0o600)
    100             path.write_text(value.strip() + '\n')
    101         for kind, purpose, eku in [('client', 'sslclient', 'TLS Web Client Authentication'),
    102                                    ('server', 'sslserver', 'TLS Web Server Authentication')]:
    103             cert, key = root / kind, root / (kind + '-key')
    104             openssl('verify', '-CAfile', root / 'ca', '-purpose', purpose,
    105                     '-verify_hostname', public['identity'], cert)
    106             subject = openssl('x509', '-in', cert, '-noout', '-subject', '-nameopt', 'RFC2253')
    107             require(subject.decode().strip() == 'subject=CN=' + public['identity'],
    108                     'Monitoring certificate subject does not match the enrolled identity.')
    109             sans = openssl('x509', '-in', cert, '-noout', '-ext', 'subjectAltName')
    110             require(('DNS:' + public['identity']) in sans.decode().splitlines()[1].strip().split(', '),
    111                     'Monitoring certificate SAN does not contain the enrolled identity.')
    112             extensions = openssl('x509', '-in', cert, '-noout', '-ext', 'extendedKeyUsage')
    113             require(extensions.decode().splitlines()[1].strip() == eku,
    114                     'Monitoring leaf must have exactly its designated TLS purpose.')
    115             certificate_key = openssl('x509', '-in', cert, '-pubkey', '-noout')
    116             private_key_public = openssl('pkey', '-in', key, '-passin', 'pass:', '-pubout')
    117             require(certificate_key == private_key_public,
    118                     'Monitoring certificate does not match its private key.')
    119 
    120 
    121 def main():
    122     try:
    123         if len(sys.argv) == 3 and sys.argv[1] == '--files':
    124             directory = Path(sys.argv[2])
    125             for name in ['monitoring-client.yml', 'monitoring-client-secrets.yml']:
    126                 require((directory / name).is_file(),
    127                         'Both monitoring-client.yml and monitoring-client-secrets.yml are required in host_vars/<host>/.')
    128             with (directory / 'monitoring-client-secrets.yml').open('rb') as source:
    129                 require(source.readline().startswith(b'$ANSIBLE_VAULT;'),
    130                         'Encrypt the complete monitoring-client-secrets.yml with ansible-vault before deployment.')
    131         else:
    132             validate(json.load(sys.stdin))
    133     except ValueError as error:
    134         # JSON parse errors can contain user-supplied data; never print them.
    135         print('Invalid monitoring bundle JSON.' if isinstance(error, json.JSONDecodeError) else str(error))
    136         return 1
    137     except (KeyError, TypeError, IndexError):
    138         print('Monitoring bundle is missing required fields or contains invalid field types.')
    139         return 1
    140     except OSError:
    141         print('Cannot read monitoring files or run openssl on the Ansible controller.')
    142         return 1
    143     return 0
    144 
    145 
    146 if __name__ == '__main__':
    147     sys.exit(main())