aoh

A tool for running Ansible playbooks with `curl <url> | python` commands
git clone https://git.ashermorgan.net/aoh/
Log | Files | Refs | README

commit dbfc04950798ab69ba7214aa3f970fc104cd07a1
parent dbfd8916010b69b1d8f1f89020e57c387733ff9c
Author: Asher Morgan <asher@ashermorgan.net>
Date:   Sun, 23 Aug 2026 12:41:26 -0700

Add support for --ask-*pass flags

Diffstat:
Maoh/client.py | 48+++++++++++++++++++++++++++++++++---------------
Maoh/playbook.py | 97+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++----------------
Maoh/runner.py | 15++++++++++++---
Maoh/server.py | 13++++++++++++-
4 files changed, 135 insertions(+), 38 deletions(-)

diff --git a/aoh/client.py b/aoh/client.py @@ -1,6 +1,7 @@ #!/usr/bin/env python3 import base64 +import getpass import os import platform import subprocess @@ -11,6 +12,12 @@ import requests # TODO: eliminate dependency? API = '{{ API_URL }}' # Substitution performed by Flask +PASSWORD_PROMPTS = { + 'vault_password': 'Vault password: ', + 'become_password': 'BECOME password: ', + 'connection_password': 'SSH password: ', +} + def exec(args): """Run a command on the local host.""" @@ -56,22 +63,9 @@ def fetch(args): except Exception as e: # noqa: BLE001 return { 'err': str(e) } - -def main(playbook, args): +def runner_loop(runner_url, cookies): """Main execution loop.""" - res = requests.post(f'{API}/runners/', json={ - 'host': platform.node() or 'aoh_node', - 'playbook': playbook, - 'args': args, - }) - if res.status_code != 201: - print(res.json()['err']) - sys.exit(1) - - cookies = res.cookies - runner_url = f'{API}{res.headers['Location']}' - req = {} while True: res = requests.put(runner_url, cookies=cookies, json=req).json() @@ -95,6 +89,30 @@ def main(playbook, args): time.sleep(0.1) +def create_runner(playbook, args): + """Main execution loop.""" + + req = { + 'host': platform.node() or 'aoh_node', + 'playbook': playbook, + 'args': args, + } + res = requests.post(f'{API}/runners/', json=req) + + if res.status_code == 401 and 'passwords' in res.json(): + req['passwords'] = {} + for pw_type in res.json()['passwords']: + req['passwords'][pw_type] = \ + getpass.getpass(PASSWORD_PROMPTS[pw_type]) + res = requests.post(f'{API}/runners/', json=req) + + if res.status_code != 201: + print(res.json()['err']) + sys.exit(1) + + runner_loop(f'{API}{res.headers['Location']}', res.cookies) + + def cli(args): """Execute the AoH client CLI.""" @@ -127,7 +145,7 @@ def cli(args): playbook = 'main' aoh_args = args[1:] - main(playbook, aoh_args) + create_runner(playbook, aoh_args) if __name__ == '__main__': diff --git a/aoh/playbook.py b/aoh/playbook.py @@ -1,9 +1,15 @@ +import tempfile + +import ansible_runner import yaml CONFIG_FILE = 'config.yml' # TODO: make configurable -DEFAULT_OPT_WHITELIST = [ +_CLI_OPT_WHITELIST = [ # These options should be safe for clients to invoke + '--ask-become-pass', '-K', + '--ask-pass', '-k', + '--ask-vault-password', '--ask-vault-pass', '-J', '--check', '-C', '--diff', '-D', '--extra-vars', '-e', @@ -18,15 +24,47 @@ DEFAULT_OPT_WHITELIST = [ '--version', ] -OPT_BLACKLIST = [ - # These options are not supported at all because they are interactive - '--step', - '-J', '--ask-vault-password', '--ask-vault-pass', - '-K', '--ask-become-pass', - '-c', '--connection,', - '-k', '--ask-pass', +_CLI_OPT_BLACKLIST = [ + # These options are not supported at all by AoH + '--step', # Interactive + '-c', '--connection,', # Conflicts with ansible_connection=aoh ] +_CLI_PASSWORD_OPTS = { + # These command line flags require us to prompt the user for a password + '--ask-become-pass': 'become_password', + '--ask-pass': 'connection_password', + '--ask-vault-pass': 'vault_password', + '--ask-vault-password': 'vault_password', + '-J': 'vault_password', + '-K': 'become_password', + '-k': 'connection_password', +} + +_CONFIG_PASSWORD_OPTS = { + # These config options require us to prompt the user for a password + 'DEFAULT_ASK_PASS': 'connection_password', + 'DEFAULT_ASK_VAULT_PASS': 'vault_password', + 'DEFAULT_BECOME_ASK_PASS': 'become_password', +} + + +def _get_opts(args): + """Identify options present in a list of CLI arguments.""" + + opts = [] + + for arg in args: + if arg == '--': + break + elif arg.startswith('--'): + opts += [arg.split('=')[0]] + elif arg.startswith('-'): + for opt in arg.split('=')[0][1:]: + opts += ['-' + opt] + + return opts + class PlaybookError(Exception): """Raised for AoH playbook configuration errors.""" @@ -69,22 +107,43 @@ class Playbook: if not self.jinja and ('{{' in cmdline or '{%' in cmdline): return False - opts = [] - for arg in args: - if arg.startswith('--'): - opts += [arg.split('=')[0]] - elif arg.startswith('-'): - for opt in arg.split('=')[0][1:]: - opts += ['-' + opt] - - for opt in opts: - if (not (opt in DEFAULT_OPT_WHITELIST or opt in self.allow_opts) or - (opt in OPT_BLACKLIST or opt in self.block_opts)): + for opt in _get_opts(args): + if (not (opt in _CLI_OPT_WHITELIST or opt in self.allow_opts) or + (opt in _CLI_OPT_BLACKLIST or opt in self.block_opts)): return False return True + def get_required_passwords(self, args): + """Determine what passwords the user must be prompted for.""" + + pw_types = set() + + opts = _get_opts(args) + for opt, pw_type in _CLI_PASSWORD_OPTS.items(): + if opt in opts: + pw_types.add(pw_type) + + with tempfile.TemporaryDirectory(prefix='aoh-') as dir: + raw_config = ansible_runner.get_ansible_config( + 'dump', + self.config, + private_data_dir=dir, + quiet=True, + )[0] + + for opt, pw_type in _CONFIG_PASSWORD_OPTS.items(): + val = eval(next( + x for x in raw_config.split('\n') + if x.startswith(opt) + ).split('= ')[1]) + if val: + pw_types.add(pw_type) + + return list(pw_types) + + def get_playbook(playbook): """Lookup a playbook configuration.""" diff --git a/aoh/runner.py b/aoh/runner.py @@ -11,6 +11,11 @@ import ansible_runner _CONNECTION_PLUGIN_DIR = f'{os.path.dirname(__file__)}/connection_plugins/' _CLIENT_TIMEOUT = 600 # 10 minutes +_PASSWORD_PROMPTS = { + 'become_password': '^BECOME password.*:\\s*?$', + 'connect_password': '^SSH password:\\s*?$', + 'vault_password': '^Vault password:\\s*?$', +} class RunnerError(Exception): """Raised for AoH runner errors.""" @@ -19,7 +24,7 @@ class RunnerError(Exception): class Runner: """An Ansible-over-HTTP playbook runner.""" - def __init__(self, playbook, host, args): + def __init__(self, playbook, host, args, passwords): """Create a new AoH runner.""" self.id = str(uuid4()) # Runner ID (set to None after teardown) @@ -45,7 +50,7 @@ class Runner: with open(f'{self._DIR}/hostname', 'w') as f: f.write(f'{host}\n') - self._start_runner(host, args) + self._start_runner(host, args, passwords) # We assume that ansible-runner will eventually create its log file while not os.path.exists(self._LOGS_PATH): @@ -66,7 +71,7 @@ class Runner: raise - def _start_runner(self, host, args): + def _start_runner(self, host, args, passwords): """Configure and start the underlying ansible-runner.""" # Set ansible_connection=aoh for the client host only. Note that we @@ -103,6 +108,9 @@ class Runner: vars = { 'ansible_aoh_dir': self._DIR, } + pw_prompt_answers = {} + for pw_type in passwords: + pw_prompt_answers[_PASSWORD_PROMPTS[pw_type]] = passwords[pw_type] cmdline = ' '.join(shlex.quote(arg) for arg in args) cmdline += ' ' + self.playbook.cmdline @@ -112,6 +120,7 @@ class Runner: envvars=env, extravars=vars, cmdline=cmdline, + passwords=pw_prompt_answers, playbook=self.playbook.playbook, quiet=True, suppress_env_files=True, diff --git a/aoh/server.py b/aoh/server.py @@ -42,7 +42,18 @@ def new_runner(): if not playbook.validate_args(request.json['args']): return { 'err': 'Bad or banned arguments passed.' }, 400 - runner = Runner(playbook, request.json['host'], request.json['args']) + exp_pw_types = playbook.get_required_passwords(request.json['args']) + act_pws = request.json.get('passwords', {}) + missing_pw_types = [pw_type for pw_type in exp_pw_types if pw_type not in + act_pws] + if missing_pw_types: + return { + 'err': f"Missing required passwords: {', '.join(missing_pw_types)}", + 'passwords': exp_pw_types, + }, 401 + + runner = Runner(playbook, request.json['host'], request.json['args'], + act_pws) _RUNNERS[runner.id] = runner session['runner'] = runner.id