#!/usr/bin/env python3 """Attended repair of one consumer field using an operator-held current password. Never reads a Secret payload, changes a provider password, or copies a bundle. """ from __future__ import annotations import argparse import base64 import getpass import json import re import subprocess import sys CLUSTER_UID = 'a553c742-0115-43d4-99a4-a5ca56fe0786' KUBE = ['kubectl', '--kubeconfig', '/home/worsch/.kube/config-hosteurope', '--server', 'https://127.0.0.1:16444', '--request-timeout=20s'] NAMESPACE = 'sso' SECRET = 'lldap-secrets' KEY = 'LLDAP_LDAP_USER_PASS' # The password and returned directory token remain in this child process only. # Child stdin carries the operator input; no credential is a process argument. VERIFY = '''import json,os,sys from urllib.error import HTTPError from provisioner import LLDAPProvisioner try: assert os.environ['LLDAP_URL']=='http://lldap.sso.svc.cluster.local:17170' p=LLDAPProvisioner(base_url=os.environ['LLDAP_URL'],admin_password=json.load(sys.stdin)['password']) token=p._login() p._gql(token,'query { groups { id } }',{}) except Exception: print('directory-check-failed');sys.exit(1) print('directory-check-passed') ''' VERIFY_RELOADED = '''import os,sys from provisioner import LLDAPProvisioner try: p=LLDAPProvisioner(base_url=os.environ['LLDAP_URL'],admin_password=os.environ['LLDAP_ADMIN_PASSWORD']) token=p._login() p._gql(token,'query { groups { id } }',{}) except Exception: print('reloaded-check-failed');sys.exit(1) print('reloaded-check-passed') ''' class Refused(Exception): """Fixed safe code only; never carry child output or an exception repr.""" def run(arguments, *, data=None, timeout=30): try: result = subprocess.run(KUBE + arguments, input=data, text=True, capture_output=True, timeout=timeout) except (OSError, subprocess.TimeoutExpired): raise Refused('command_unavailable') from None if result.returncode: raise Refused('command_refused') from None return result.stdout.strip() def inspect(): uid = run(['get', 'namespace', 'kube-system', '-o', 'jsonpath={.metadata.uid}']) if uid != CLUSTER_UID: raise Refused('wrong_cluster') # Do not include annotations: last-applied configuration can contain data. fields = run(['-n', NAMESPACE, 'get', 'secret', SECRET, '-o', 'go-template={{.metadata.uid}} {{.metadata.resourceVersion}} {{if .metadata.ownerReferences}}{{len .metadata.ownerReferences}}{{else}}0{{end}}']).split() if len(fields) != 3 or not re.fullmatch(r'[a-f0-9-]{36}', fields[0]) or not fields[1].isdigit(): raise Refused('invalid_metadata') if fields[2] != '0': raise Refused('controller_owned_secret') return {'uid': fields[0], 'resource_version': fields[1]} def patch(password, metadata): return [ {'op': 'test', 'path': '/metadata/uid', 'value': metadata['uid']}, {'op': 'test', 'path': '/metadata/resourceVersion', 'value': metadata['resource_version']}, {'op': 'replace', 'path': '/data/' + KEY, 'value': base64.b64encode(password.encode()).decode()}, ] def reconcile(password, *, mode, expected_uid=None, expected_resource_version=None): if not password or len(password) > 4096 or '\x00' in password: raise Refused('invalid_protected_input') metadata = inspect() if mode == 'apply' and metadata != {'uid': expected_uid, 'resource_version': expected_resource_version}: raise Refused('stale_metadata') result = run(['-n', NAMESPACE, 'exec', '-i', 'deployment/identity-provisioner', '--', 'python', '-c', VERIFY], data=json.dumps({'password': password})) if result != 'directory-check-passed': raise Refused('current_provider_credential_unverified') if mode == 'check': return {'result': 'checked', 'provider_login': True, 'secret_written': False, **metadata} payload = json.dumps(patch(password, metadata)) command = ['-n', NAMESPACE, 'patch', 'secret', SECRET, '--type=json', '--patch-file=/dev/stdin', '-o', 'name'] run(command + ['--dry-run=server'], data=payload) run(command, data=payload) # Do not restore the known nonworking field on failure. Report partial repair. try: run(['-n', NAMESPACE, 'rollout', 'restart', 'deployment/identity-provisioner']) run(['-n', NAMESPACE, 'rollout', 'status', 'deployment/identity-provisioner', '--timeout=120s'], timeout=150) result = run(['-n', NAMESPACE, 'exec', 'deployment/identity-provisioner', '--', 'python', '-c', VERIFY_RELOADED]) if result != 'reloaded-check-passed': raise Refused('consumer_verification_failed') except Refused: raise Refused('field_updated_consumer_verification_incomplete') from None return {'result': 'reconciled', 'provider_login': True, 'consumer_login': True, 'provider_password_changed': False, 'secret_field': KEY, **inspect()} def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('mode', choices=['inspect', 'check', 'apply']) parser.add_argument('--expected-uid') parser.add_argument('--expected-resource-version') args = parser.parse_args() try: if args.mode == 'inspect': result = {'result': 'inspected', **inspect()} else: if args.mode == 'apply' and not (args.expected_uid and args.expected_resource_version): raise Refused('exact_metadata_required') if not sys.stdin.isatty(): raise Refused('attended_terminal_required') password = getpass.getpass('Current working LLDAP admin password (hidden): ') result = reconcile(password, mode=args.mode, expected_uid=args.expected_uid, expected_resource_version=args.expected_resource_version) del password print(json.dumps(result, sort_keys=True)) return 0 except Refused as error: print(json.dumps({'result': 'refused', 'code': str(error)})) return 1 if __name__ == '__main__': raise SystemExit(main())