packages feed

mariadb-haskell-0.1.0.0: validation/auth_fixture/serve.py

#!/usr/bin/env python3
"""Owned PAM/Kerberos/MariaDB services in a disposable Linux container.

Nothing in this module is intended to run on a developer's host. Generated
keys, ticket caches, test PAM policies and users exist only in this fixture.
"""
import json
import os
from pathlib import Path
import signal
import subprocess
import time

ROOT = Path(os.environ.get('MB_AUTH_FIXTURE_ROOT', '/fixture'))
PORT = int(os.environ.get('MB_AUTH_FIXTURE_PORT', '3306'))
REALM = 'MARIADB-HASKELL.TEST'
SERVICE = f'mariadb/localhost@{REALM}'


def command(args, **kwargs):
    return subprocess.run(list(map(str, args)), check=True, **kwargs)


def main():
    ROOT.mkdir(mode=0o700, exist_ok=True)
    os.environ.update(KRB5_CONFIG=str(ROOT/'krb5.conf'),
                      KRB5_KDC_PROFILE=str(ROOT/'kdc.conf'))
    (ROOT/'krb5.conf').write_text(f'''[libdefaults]
 default_realm = {REALM}
 dns_lookup_kdc = false
 dns_lookup_realm = false
 rdns = false
 udp_preference_limit = 1
[realms]
 {REALM} = {{
  kdc = 127.0.0.1:10088
 }}
''')
    (ROOT/'kdc.conf').write_text(f'''[kdcdefaults]
 kdc_ports = 10088
 kdc_tcp_ports = 10088
[realms]
 {REALM} = {{
  database_name = {ROOT}/principal
  key_stash_file = {ROOT}/stash
  acl_file = {ROOT}/kadm5.acl
  max_life = 1d
 }}
''')
    (ROOT/'kadm5.acl').write_text('')
    command(['kdb5_util', 'create', '-s', '-P', 'stage7-disposable-master'])
    for principal in [SERVICE, f'driver@{REALM}', f'other@{REALM}']:
        command(['kadmin.local', '-q', f'addprinc -randkey {principal}'])
    command(['kadmin.local', '-q', f'ktadd -k {ROOT}/server.keytab {SERVICE}'])
    for name in ['driver', 'other']:
        command(['kadmin.local', '-q', f'ktadd -k {ROOT}/{name}.keytab {name}@{REALM}'])
    for service, argument in [('single', ''), ('multi', ' two-factor')]:
        Path(f'/etc/pam.d/mariadb-haskell-{service}').write_text(
            'auth required /opt/driver-auth/pam_fixture.so'+argument+'\n'
            'account required pam_permit.so\n')
    command(['openssl', 'req', '-x509', '-newkey', 'rsa:2048', '-nodes',
             '-keyout', ROOT/'tls-key.pem', '-out', ROOT/'tls-cert.pem',
             '-days', '7', '-subj', '/CN=localhost',
             '-addext', 'subjectAltName=DNS:localhost,IP:127.0.0.1'],
            stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
    command(['mariadb-install-db', '--no-defaults', f'--datadir={ROOT}/data',
             '--auth-root-authentication-method=normal', '--skip-test-db'],
            stdout=subprocess.DEVNULL)
    processes = []
    logs = []
    def stop(*_):
        raise KeyboardInterrupt
    signal.signal(signal.SIGTERM, stop)
    signal.signal(signal.SIGINT, stop)
    try:
        for name, args in [('kdc', ['krb5kdc', '-n']), ('server', [
            'mariadbd', '--no-defaults', '--user=root', f'--datadir={ROOT}/data',
            f'--socket={ROOT}/server.sock', f'--pid-file={ROOT}/server.pid',
            '--bind-address=0.0.0.0', f'--port={PORT}',
            f'--ssl-cert={ROOT}/tls-cert.pem', f'--ssl-key={ROOT}/tls-key.pem',
            '--plugin-load-add=auth_pam', '--plugin-load-add=auth_gssapi',
            f'--gssapi-principal-name={SERVICE}', f'--gssapi-keytab-path={ROOT}/server.keytab',
            '--character-set-server=utf8mb4', '--collation-server=utf8mb4_unicode_ci'])]:
            log = (ROOT/f'{name}.log').open('w')
            logs.append(log)
            processes.append(subprocess.Popen(args, stdout=log, stderr=subprocess.STDOUT))
        client = ['mariadb', '--no-defaults', f'--socket={ROOT}/server.sock', '-uroot']
        for _ in range(120):
            if any(p.poll() is not None for p in processes):
                raise RuntimeError('Authentication fixture service exited; inspect /fixture/*.log')
            ready = subprocess.run(client+['-e', 'SELECT 1'], capture_output=True)
            if ready.returncode == 0:
                break
            time.sleep(0.25)
        else:
            raise RuntimeError('Authentication fixture startup deadline exceeded')
        command(client, input=f'''
CREATE DATABASE mariadb_haskell_validation;
CREATE USER 'mariadb_pam'@'%' IDENTIFIED VIA pam USING 'mariadb-haskell-single';
CREATE USER 'mariadb_pam_multi'@'%' IDENTIFIED VIA pam USING 'mariadb-haskell-multi';
CREATE USER 'mariadb_gssapi'@'%' IDENTIFIED VIA gssapi AS 'driver@{REALM}';
GRANT ALL ON mariadb_haskell_validation.* TO 'mariadb_pam'@'%','mariadb_pam_multi'@'%','mariadb_gssapi'@'%';
''', text=True)
        # Populate service tickets too: host clients can authenticate without
        # changing host DNS/realm configuration or contacting a different KDC.
        for name in ['driver', 'other']:
            cache = f'FILE:{ROOT}/{name}.ccache'
            command(['kinit', '-c', cache, '-kt', ROOT/f'{name}.keytab', f'{name}@{REALM}'])
            command(['kvno', '-c', cache, SERVICE])
        (ROOT/'ready.json').write_text(json.dumps({'realm': REALM, 'principal': SERVICE,
            'password': 'stage7-test-password', 'code': '246810'}))
        while True:
            if any(p.poll() is not None for p in processes):
                raise RuntimeError('Authentication fixture service stopped')
            time.sleep(1)
    except KeyboardInterrupt:
        pass
    except Exception:
        import sys
        for filename in ['kdc.log', 'server.log']:
            path = ROOT/filename
            if path.is_file():
                print(filename+":\n"+path.read_text()[-12000:], file=sys.stderr)
        raise
    finally:
        for p in reversed(processes):
            p.terminate()
        for p in reversed(processes):
            try:
                p.wait(timeout=10)
            except subprocess.TimeoutExpired:
                p.kill()
                p.wait()
        for log in logs:
            log.close()


if __name__ == '__main__':
    main()