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:
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