ansible-taler-exchange

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

validate-bundle.py (6895B)


      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):
     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 
     56 
     57 def openssl(*args):
     58     result = subprocess.run(['openssl', *map(str, args)], capture_output=True)
     59     require(result.returncode == 0, 'Monitoring certificate or private key validation failed.')
     60     return result.stdout
     61 
     62 
     63 def validate(bundle):
     64     public = bundle['public']['monitoring_client']
     65     secret = bundle['secrets']['monitoring_client_secrets']
     66     node = public['node_exporter']
     67     logs = public['logs']
     68     require(identity(public['identity']) and identity(node['prometheus_client_identity']),
     69             'Invalid monitoring certificate identity.')
     70     require(logs.get('protocol') == 'jsonline', 'Unsupported monitoring log protocol; export a JSON Lines bundle.')
     71     url = logs.get('url')
     72     require(isinstance(url, str) and re.fullmatch(
     73         r'https://[A-Za-z0-9][A-Za-z0-9.-]*(?::[0-9]{1,5})?/jsonline', url),
     74         'Monitoring logs require an HTTPS URL ending in /jsonline without credentials, query or fragment.')
     75     try:
     76         target_port = urlsplit(url).port
     77     except ValueError:
     78         raise ValueError('Invalid monitoring log port.') from None
     79     require(port(target_port if target_port is not None else 443) and port(node['proxy_port']),
     80             'Invalid monitoring port.')
     81     validate_listeners(node)
     82 
     83     # TemporaryDirectory is private (0700); private files are created as 0600.
     84     # Validate the complete pair before Ansible replaces any live material.
     85     with tempfile.TemporaryDirectory(prefix='taler-monitoring-') as directory:
     86         root = Path(directory)
     87         values = {'ca': public['monitoring_ca_certificate'],
     88                   'client': public['client_certificate'], 'server': public['server_certificate'],
     89                   'client-key': secret['client_private_key'], 'server-key': secret['server_private_key']}
     90         for name, value in values.items():
     91             require(isinstance(value, str) and value.strip(), 'Missing monitoring TLS material.')
     92             path = root / name
     93             path.touch(mode=0o600)
     94             path.write_text(value.strip() + '\n')
     95         for kind, purpose, eku in [('client', 'sslclient', 'TLS Web Client Authentication'),
     96                                    ('server', 'sslserver', 'TLS Web Server Authentication')]:
     97             cert, key = root / kind, root / (kind + '-key')
     98             openssl('verify', '-CAfile', root / 'ca', '-purpose', purpose,
     99                     '-verify_hostname', public['identity'], cert)
    100             subject = openssl('x509', '-in', cert, '-noout', '-subject', '-nameopt', 'RFC2253')
    101             require(subject.decode().strip() == 'subject=CN=' + public['identity'],
    102                     'Monitoring certificate subject does not match the enrolled identity.')
    103             sans = openssl('x509', '-in', cert, '-noout', '-ext', 'subjectAltName')
    104             require(('DNS:' + public['identity']) in sans.decode().splitlines()[1].strip().split(', '),
    105                     'Monitoring certificate SAN does not contain the enrolled identity.')
    106             extensions = openssl('x509', '-in', cert, '-noout', '-ext', 'extendedKeyUsage')
    107             require(extensions.decode().splitlines()[1].strip() == eku,
    108                     'Monitoring leaf must have exactly its designated TLS purpose.')
    109             certificate_key = openssl('x509', '-in', cert, '-pubkey', '-noout')
    110             private_key_public = openssl('pkey', '-in', key, '-passin', 'pass:', '-pubout')
    111             require(certificate_key == private_key_public,
    112                     'Monitoring certificate does not match its private key.')
    113 
    114 
    115 def main():
    116     try:
    117         if len(sys.argv) == 3 and sys.argv[1] == '--files':
    118             directory = Path(sys.argv[2])
    119             for name in ['monitoring-client.yml', 'monitoring-client-secrets.yml']:
    120                 require((directory / name).is_file(),
    121                         'Both monitoring-client.yml and monitoring-client-secrets.yml are required in host_vars/<host>/.')
    122             with (directory / 'monitoring-client-secrets.yml').open('rb') as source:
    123                 require(source.readline().startswith(b'$ANSIBLE_VAULT;'),
    124                         'Encrypt the complete monitoring-client-secrets.yml with ansible-vault before deployment.')
    125         else:
    126             validate(json.load(sys.stdin))
    127     except ValueError as error:
    128         # JSON parse errors can contain user-supplied data; never print them.
    129         print('Invalid monitoring bundle JSON.' if isinstance(error, json.JSONDecodeError) else str(error))
    130         return 1
    131     except (KeyError, TypeError, IndexError):
    132         print('Monitoring bundle is missing required fields or contains invalid field types.')
    133         return 1
    134     except OSError:
    135         print('Cannot read monitoring files or run openssl on the Ansible controller.')
    136         return 1
    137     return 0
    138 
    139 
    140 if __name__ == '__main__':
    141     sys.exit(main())