95 lines
3.9 KiB
Python
95 lines
3.9 KiB
Python
#!/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)
|