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())