#!/usr/bin/env python3 """Authenticated control client. No credentials are accepted on the command line.""" import argparse import json import os from pathlib import Path import shlex import subprocess import sys import urllib.error import urllib.request class APIError(RuntimeError): def __init__(self,status): self.status=status super().__init__('Control API returned HTTP %s' % status) class NoRedirect(urllib.request.HTTPRedirectHandler): def redirect_request(self,req,fp,code,msg,headers,newurl): return None class Client: def __init__(self, config): self.config = config def call(self, path, data=None, method=None): if not path.startswith('/api/') or '\n' in path: raise ValueError('Only control API paths are accepted') if self.config.get('transport') == 'ssh': cfg = self.config command = ['ssh', '-i', str(Path(cfg['identity_file']).expanduser()), '-o', 'IdentitiesOnly=yes', '-o', 'BatchMode=yes', '-o', 'StrictHostKeyChecking=yes', '-o', 'ConnectTimeout=10', cfg['ssh_host'], 'python3 -X utf8 ' + shlex.quote(cfg['remote_client']) + ' --config ' + shlex.quote(cfg['remote_config']) + ' rpc'] result = subprocess.run(command, input=json.dumps({'path': path, 'data': data, 'method': method}), capture_output=True, text=True, encoding='utf-8', errors='strict', timeout=45) if result.returncode: try: problem=json.loads(result.stdout) except ValueError: problem={} if set(problem)=={'api_error'} and isinstance(problem['api_error'],int): raise APIError(problem['api_error']) raise RuntimeError('SSH control request failed; check connection and remote configuration') return json.loads(result.stdout) cfg = self.config url = cfg.get('url') or ('http://%s:%s' % (cfg['bind'], cfg['port'])) token_path = Path(cfg['token_file']).expanduser() if os.name != 'nt' and token_path.stat().st_mode & 0o077: raise ValueError('Token file must not be readable by group or other users') token = token_path.read_text(encoding='utf-8').strip() if not token: raise ValueError('Empty token file') body = None if data is None else json.dumps(data).encode() request = urllib.request.Request(url.rstrip('/') + path, data=body, headers={'Authorization': 'Bearer ' + token, 'Content-Type': 'application/json', 'X-CSRF-Token': '1'}, method=method or ('GET' if data is None else 'POST')) try: opener=urllib.request.build_opener(NoRedirect(),urllib.request.ProxyHandler({})) with opener.open(request, timeout=30) as response: return json.load(response) except urllib.error.HTTPError as exc: status = exc.code exc.close() raise APIError(status) from None def main(): parser = argparse.ArgumentParser(description=__doc__) parser.add_argument('--config', required=True) parser.add_argument('action', choices=['state', 'rpc']) args = parser.parse_args() client = Client(json.loads(Path(args.config).read_text(encoding='utf-8-sig'))) if args.action == 'state': result = client.call('/api/state') else: payload = json.load(sys.stdin) result = client.call(payload['path'], payload.get('data'), payload.get('method')) print(json.dumps(result, ensure_ascii=True)) if __name__ == '__main__': try: main() except APIError as exc: print(json.dumps({'api_error':exc.status})) sys.exit(2) except Exception as exc: print(str(exc), file=sys.stderr) sys.exit(1)