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