├── .gitignore ├── .isort.cfg ├── CHANGELOG.rst ├── CONTRIBUTORS.rst ├── LICENSE ├── MANIFEST.in ├── README.rst ├── pmxc ├── __init__.py ├── __main__.py ├── api2client │ ├── __init__.py │ ├── connection.py │ ├── exception.py │ ├── resource.py │ └── wsterminal.py ├── cli │ ├── __init__.py │ ├── enter.py │ ├── list.py │ ├── remote │ │ ├── __init__.py │ │ ├── add.py │ │ ├── list.py │ │ └── remove.py │ ├── reset.py │ ├── resume.py │ ├── shutdown.py │ ├── spice.py │ ├── start.py │ ├── stop.py │ ├── suspend.py │ └── version.py └── lib │ ├── __init__.py │ ├── config.py │ ├── loader.py │ ├── remote.py │ └── utils.py └── setup.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Editor 2 | .vscode/ 3 | 4 | # Playground 5 | playground/ 6 | 7 | __pycache__/ 8 | *.egg-info 9 | venv/ 10 | -------------------------------------------------------------------------------- /.isort.cfg: -------------------------------------------------------------------------------- 1 | [settings] 2 | force_alphabetical_sort = False 3 | force_single_line = True 4 | lines_after_imports = 2 5 | line_length = 200 6 | not_skip = __init__.py 7 | -------------------------------------------------------------------------------- /CHANGELOG.rst: -------------------------------------------------------------------------------- 1 | Changelog 2 | ========= 3 | 4 | This document describes changes between each past release. 5 | 6 | 5.2.0.0 (unreleased) 7 | ------------------ 8 | 9 | - Initial version. [pcdummy] 10 | -------------------------------------------------------------------------------- /CONTRIBUTORS.rst: -------------------------------------------------------------------------------- 1 | Contributors 2 | ============ 3 | 4 | * René Jochum [pcdummy] -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2018 Rene Jochum 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /MANIFEST.in: -------------------------------------------------------------------------------- 1 | include *.txt *.ini *.cfg *.rst 2 | recursive-include drawstack *.ico *.png *.css *.gif *.jpg *.pt *.txt *.mak *.mako *.js *.html *.xml *.jinja2 3 | -------------------------------------------------------------------------------- /README.rst: -------------------------------------------------------------------------------- 1 | pmxc - a console client for Proxmox VE 2 | ====================================== 3 | 4 | Install on Debian 5 | +++++++++++++++++ 6 | 7 | .. code:: bash 8 | 9 | sudo apt install python3-click python3-pip python3-uvloop python3-aiohttp python3-texttable python3-aiodns python3-chardet virt-viewer 10 | sudo pip3 install "[performance,uvloop]" 11 | 12 | Using pmxc 13 | ++++++++++ 14 | 15 | First create a remote: 16 | 17 | WARNING: If you store the password it will be saved in plain text in ~/.config/pmxc/config.json 18 | 19 | .. code:: bash 20 | 21 | $ pmxc remote add pve01 https://pve01.fk.jochum.dev:8006 root@pam 22 | 23 | Now you can list VM's/Container's: 24 | 25 | .. code:: bash 26 | 27 | $ pmxc list pve01 28 | 29 | Or open a the virt-viewer on VM id **100**: 30 | 31 | .. code:: bash 32 | 33 | $ pmxc spice pve01 100 34 | 35 | If you add a serial console to a Linux VM (maybe also FreeBSD) you can enter it too now: 36 | 37 | This requires that you setup the console first inside the VM: https://www.rogerirwin.co.nz/open-source/enabling-a-serial-port-console/ 38 | 39 | .. code:: bash 40 | 41 | $ pmxc enter pve01 100 42 | 43 | Or open a shell on the container **101**: 44 | 45 | You can exit it with: CTRL+A q 46 | 47 | .. code:: bash 48 | 49 | $ pmxc enter pve01 101 50 | 51 | Have fun with pmxc i hope you like it as i do :) 52 | 53 | The `pmxc enter` command now supports piping commands: 54 | 55 | You need to enable automatic login to serial console as described here: https://wiki.gentoo.org/wiki/Automatic_login_to_virtual_console 56 | 57 | .. code:: bash 58 | 59 | $ echo "/bin/bash -c 'touch /test; echo bla'" | pmxc enter pve01 101 60 | 61 | Windows 62 | ++++++++++++++++++ 63 | 64 | 2018 I had success with babun on Windows, now lacking a Windows box and babun discontinued I have no clue. 65 | It should work unter cygwin though. 66 | 67 | The version parts 68 | +++++++++++++++++ 69 | 70 | The first 2 numbers are the Promox VE API pmxc targets to, the next 2 are the pmxc version. 71 | 72 | Like 5.2.0.1 means its target for Promox VE 5.2 and its the first release (0.1) for that. 73 | 74 | Development 75 | +++++++++++ 76 | 77 | Linux 78 | ----- 79 | 80 | Create a venv: 81 | 82 | .. code:: bash 83 | $ sudo apt install virtualenv 84 | $ virtualenv -p /usr/bin/python3 venv 85 | $ source venv/bin/activate 86 | 87 | 88 | .. code:: bash 89 | 90 | $ venv/bin/pip install -e ".[development,performance,uvloop]" 91 | 92 | Now use ./venv/bin/pmxc instead of just plain `pmxc` 93 | 94 | License 95 | +++++++ 96 | 97 | MIT 98 | 99 | 100 | Copyright 101 | +++++++++ 102 | 103 | Copyright (c) 2018-2020 by René Jochum 104 | -------------------------------------------------------------------------------- /pmxc/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | 4 | __all__ = [ 5 | "__version__", 6 | "DEFAULT_CONFIG_FILE", 7 | ] 8 | 9 | __version__ = "6.2.1.0" 10 | DEFAULT_CONFIG_FILE = os.getenv('PMXC_CONFIG', os.path.join(os.path.expanduser("~"), ".config", "pmxc", 'config.json')) -------------------------------------------------------------------------------- /pmxc/__main__.py: -------------------------------------------------------------------------------- 1 | import argparse 2 | import logging 3 | import sys 4 | import click 5 | 6 | from pmxc import DEFAULT_CONFIG_FILE 7 | from pmxc.lib.loader import load_all 8 | from pmxc.lib.config import load_config 9 | import pmxc.cli 10 | 11 | import asyncio 12 | try: 13 | import uvloop 14 | UVLOOP_AVAILABLE = True 15 | except ImportError: 16 | UVLOOP_AVAILABLE = False 17 | 18 | DEFAULT_LOG_LEVEL = logging.INFO 19 | DEFAULT_LOG_FORMAT = '%(levelname)-8.8s %(message)s' 20 | 21 | 22 | async def run(cmd, loop, config, args): 23 | result = await cmd.execute(loop, config, args) 24 | if result != 0: 25 | sys.exit(result) 26 | 27 | 28 | @click.group() 29 | @click.option('--config', help="Application configuration file", default=DEFAULT_CONFIG_FILE) 30 | @click.option('--debug', 'verbosity', flag_value=logging.DEBUG, default=False) 31 | @click.option('--quiet', 'verbosity', flag_value=logging.CRITICAL, default=False) 32 | @click.pass_context 33 | def cli(ctx, config, verbosity): 34 | """proxmox Command-Line Interface""" 35 | 36 | # ensure that ctx.obj exists and is a dict (in case `cli()` is called 37 | # by means other than the `if` block below) 38 | ctx.ensure_object(dict) 39 | 40 | level = verbosity or DEFAULT_LOG_LEVEL 41 | logging.basicConfig(level=level, format=DEFAULT_LOG_FORMAT) 42 | 43 | if UVLOOP_AVAILABLE: 44 | asyncio.set_event_loop_policy(uvloop.EventLoopPolicy()) 45 | 46 | ctx.obj['config_path'] = config 47 | ctx.obj['config'] = load_config(config) 48 | ctx.obj['loop'] = asyncio.get_event_loop() 49 | 50 | 51 | # Load all commands (importlib.import_module) and add them as command to cli 52 | cmds = load_all(pmxc.cli) 53 | for _, cmd in cmds.items(): 54 | if hasattr(cmd, 'command'): 55 | cli.add_command(cmd.command) 56 | if hasattr(cmd, 'group'): 57 | cli.add_command(cmd.group) -------------------------------------------------------------------------------- /pmxc/api2client/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jochumdev/pmxc/c0e7535ec9ecb923d75b4ce23aad72a4f5ea4b81/pmxc/api2client/__init__.py -------------------------------------------------------------------------------- /pmxc/api2client/connection.py: -------------------------------------------------------------------------------- 1 | import logging 2 | import urllib.parse 3 | 4 | import aiohttp 5 | 6 | from pmxc.api2client.exception import AuthenticationException 7 | from pmxc.api2client.exception import ConnectionException 8 | from pmxc.api2client.exception import HTTPException 9 | from pmxc.api2client.exception import VerifyException 10 | from pmxc.api2client.resource import Resource 11 | 12 | 13 | __all__ = [ 14 | 'Connection', 15 | ] 16 | 17 | 18 | def _hex_fp_to_binary(fp): 19 | return bytes.fromhex(''.join(fp.split(':'))) 20 | 21 | 22 | def _binary_fp_to_hex(fp): 23 | return ':'.join(['{0:02x}'.format(fp[i]) for i in range(0, len(fp))]) 24 | 25 | 26 | def _manual_verify_fp(host, _, hex_fp): 27 | res = input('''The authenticity of host '%s' can't be established. 28 | X509 SHA256 key fingerprint is %s. 29 | Are you sure you want to continue connecting (yes/no)? ''' % (host, hex_fp)) 30 | if res.lower()[0] != 'y': 31 | return False 32 | 33 | return True 34 | 35 | 36 | class Connection(object): 37 | def __init__(self, loop, host, *, port=None, fingerprint=None, 38 | verify_cb=None): 39 | 40 | self._loop = loop 41 | self._host = host 42 | self._port = port if port is not None else 8006 43 | if fingerprint is not None: 44 | self._fingerprint = _hex_fp_to_binary(fingerprint) 45 | else: 46 | self._fingerprint = None 47 | self._verify_cb = verify_cb if verify_cb is not None else _manual_verify_fp 48 | 49 | self._timeout = 30 50 | 51 | self._ticket = None 52 | self._caps = None 53 | self._csrftoken = None 54 | 55 | self._url = 'https://' + host + ':' + str(port) 56 | 57 | @property 58 | def fingerprint(self): 59 | return _binary_fp_to_hex(self._fingerprint) if self._fingerprint is not None else None 60 | 61 | @property 62 | def binary_fingerprint(self): 63 | return self._fingerprint 64 | 65 | @property 66 | def timeout(self): 67 | return self._timeout 68 | 69 | @timeout.setter 70 | def timeout(self, value): 71 | self._timeout = value 72 | 73 | @property 74 | def ticket(self): 75 | return self._ticket 76 | 77 | async def update_ticket(self, value): 78 | self._ticket = value 79 | 80 | # Reconnect with Ticket 81 | await self._close_connection() 82 | await self._make_connection() 83 | 84 | @property 85 | def caps(self): 86 | return self._caps 87 | 88 | @property 89 | def csrftoken(self): 90 | return self._csrftoken 91 | 92 | @property 93 | def url(self): 94 | return self._url 95 | 96 | @property 97 | def session(self): 98 | return self._session 99 | 100 | def url_join(self, *args): 101 | path = '/api2/json/' + '/'.join(args) 102 | return Resource(self, path) 103 | 104 | def __getattr__(self, item): 105 | if item.startswith('_'): 106 | raise AttributeError(item) 107 | 108 | return self.url_join(item) 109 | 110 | async def get(self, path, **params): 111 | json = await self.request('GET', path, **params) 112 | return json['data'] 113 | 114 | async def post(self, path, **params): 115 | json = await self.request('POST', path, **params) 116 | return json['data'] 117 | 118 | async def put(self, path, **params): 119 | json = await self.request('PUT', path, **params) 120 | return json['data'] 121 | 122 | async def delete(self, path, **params): 123 | json = await self.request('DELETE', path, **params) 124 | return json['data'] 125 | 126 | async def options(self, path, **params): 127 | json = await self.request('OPTIONS', path, **params) 128 | return json['data'] 129 | 130 | async def request(self, method, path, **params): 131 | url = self._url + path 132 | 133 | data = aiohttp.FormData() 134 | for k, v in params.items(): 135 | data.add_field(k, v) 136 | 137 | try: 138 | logging.debug('%s: %s', method, url) 139 | resp = await self._session.request(method, url, data=data, ssl=aiohttp.Fingerprint(self._fingerprint)) 140 | if int(resp.status / 100) != 2: 141 | raise HTTPException(url, resp.status, resp.reason) 142 | except aiohttp.client_exceptions.ClientConnectorError as e: 143 | raise ConnectionException(e) 144 | 145 | return await resp.json() 146 | 147 | async def login(self, username, password=None, ticket=None): 148 | if password is None and ticket is None: 149 | raise ValueError('You need to give either a password or a cookie') 150 | 151 | assert self._session 152 | 153 | url = self._url + '/api2/json/access/ticket' 154 | 155 | data = aiohttp.FormData() 156 | data.add_field("username", username) 157 | data.add_field("password", password) 158 | resp = await self._session.post(url, data=data, ssl=aiohttp.Fingerprint(self._fingerprint)) 159 | if int(resp.status / 100) != 2: 160 | raise AuthenticationException(url, resp.status, resp.reason) 161 | 162 | json = await resp.json() 163 | result = json['data'] 164 | 165 | self._caps = result['cap'] 166 | self._csrftoken = result['CSRFPreventionToken'] 167 | await self.update_ticket(result['ticket']) 168 | 169 | async def _make_connection(self): 170 | headers = {} 171 | cookies = {} 172 | if self._ticket is not None: 173 | cookies['PVEAuthCookie'] = urllib.parse.quote_plus(self._ticket) 174 | if self._csrftoken is not None: 175 | headers['CSRFPreventionToken'] = self._csrftoken 176 | 177 | try: 178 | self._session = await aiohttp.ClientSession( 179 | headers=headers, 180 | cookies=cookies, 181 | conn_timeout=self.timeout, 182 | ).__aenter__() 183 | except aiohttp.client_exceptions.ClientConnectorError as e: 184 | raise ConnectionException(e) 185 | 186 | # User supplied a fingerprint use that. 187 | if self.fingerprint is not None: 188 | return 189 | 190 | # Get the fingerprint from the remote and verify that. 191 | bad_fp = b'0'*32 192 | exc = None 193 | try: 194 | await self._session.get(self._url, ssl=aiohttp.Fingerprint(bad_fp)) 195 | except aiohttp.ServerFingerprintMismatch as e: 196 | exc = e 197 | except aiohttp.client_exceptions.ClientConnectorError as e: 198 | raise ConnectionException(e) 199 | 200 | if exc is not None: 201 | hex_fp = _binary_fp_to_hex(exc.got) 202 | if not self._verify_cb(self._host, exc.got, hex_fp): 203 | # Close the session 204 | await self._close_connection() 205 | 206 | raise VerifyException("Failed to verify: %s" % hex_fp) 207 | 208 | self._fingerprint = exc.got 209 | 210 | async def _close_connection(self): 211 | await self._session.__aexit__(None, None, None) 212 | self._session = None 213 | 214 | async def __aenter__(self): 215 | await self._make_connection() 216 | return self 217 | 218 | async def __aexit__(self, *args): 219 | await self._close_connection() 220 | -------------------------------------------------------------------------------- /pmxc/api2client/exception.py: -------------------------------------------------------------------------------- 1 | class API2Exception(Exception): pass 2 | 3 | class VerifyException(API2Exception): pass 4 | 5 | class ConnectionException(API2Exception): pass 6 | 7 | class HTTPException(API2Exception): 8 | def __init__(self, url, code, reason): 9 | super().__init__("%s: %d %s" % (url, code, reason)) 10 | 11 | self._url = url 12 | self._code = code 13 | self._reason = reason 14 | 15 | @property 16 | def code(self): 17 | return self._code 18 | 19 | @property 20 | def reason(self): 21 | return self._reason 22 | 23 | def __unicode__(self): 24 | return "%s: %d %s" % (self._url, self._code, self._reason) 25 | 26 | def __repr__(self): 27 | return '' % (self._url, self._code, self._reason) 28 | 29 | class AuthenticationException(HTTPException): pass -------------------------------------------------------------------------------- /pmxc/api2client/resource.py: -------------------------------------------------------------------------------- 1 | class Resource(object): 2 | def __init__(self, conn, path): 3 | self._conn = conn 4 | self._path = path 5 | 6 | def url_join(self, *args): 7 | if not args: 8 | return self 9 | 10 | path = self._path + '/' + '/'.join(args) 11 | return Resource(self._conn, path) 12 | 13 | def __getattr__(self, item): 14 | if item.startswith('_'): 15 | raise AttributeError(item) 16 | 17 | return self.url_join(item) 18 | 19 | def __call__(self, args): 20 | if not args: 21 | return self 22 | 23 | if not isinstance(args, (tuple, list)): 24 | args = [str(args)] 25 | 26 | return self.url_join(*args) 27 | 28 | @property 29 | def path(self): 30 | return self._path 31 | 32 | @property 33 | def conn(self): 34 | return self._conn 35 | 36 | async def get(self, *args, **params): 37 | json = await self(args).request2('GET', **params) 38 | return json['data'] 39 | 40 | async def post(self, *args, **params): 41 | json = await self(args).request2('POST', **params) 42 | return json['data'] 43 | 44 | async def put(self, *args, **params): 45 | json = await self(args).request2('PUT', **params) 46 | return json['data'] 47 | 48 | async def delete(self, *args, **params): 49 | json = await self(args).request2('DELETE', **params) 50 | return json['data'] 51 | 52 | async def options(self, *args, **params): 53 | json = await self(args).request2('OPTIONS', **params) 54 | return json['data'] 55 | 56 | async def request(self, *args, method, **params): 57 | return await self(args).request2(method, **params) 58 | 59 | async def request2(self, method, **params): 60 | return await self._conn.request(method, self._path, **params) 61 | -------------------------------------------------------------------------------- /pmxc/api2client/wsterminal.py: -------------------------------------------------------------------------------- 1 | import re 2 | import pprint 3 | import asyncio 4 | from os import get_terminal_size 5 | import sys 6 | from functools import partial 7 | 8 | import aiohttp 9 | 10 | class WSTerminal(object): 11 | def __init__(self, loop, ws, user, ticket, *, terminal=None): 12 | self._loop = loop 13 | self._ws = ws 14 | self._user = user 15 | self._ticket = ticket 16 | 17 | if (terminal is not None and ('size' not in terminal or 'stdin' not in terminal or 'stdout' not in terminal)): 18 | raise ValueError('Invalid terminal dict received') 19 | 20 | if (terminal is not None): 21 | self._provided_termrw = True 22 | self._size = terminal['size'] 23 | self._stdin = terminal['stdin'] 24 | self._stdout = terminal['stdout'] 25 | else: 26 | self._provided_termrw = False 27 | self._size = get_terminal_size(sys.stdout.fileno()) 28 | self._stdin = sys.stdin 29 | self._stdout = sys.stdout 30 | 31 | self._ws_ready = False 32 | self._closed = False 33 | self._tasks = [] 34 | 35 | async def run(self): 36 | # Start tasks 37 | self._tasks.append(asyncio.Task(self._ping_websocket())) 38 | self._tasks.append(asyncio.Task(self._check_resize())) 39 | 40 | if not self._stdin.isatty(): 41 | self._cmd = await self._loop.run_in_executor(None, partial(self._stdin.read)) 42 | await self._read_websocket() 43 | else: 44 | # Start ws and terminal loop 45 | await asyncio.wait([self._read_websocket(), self._read_terminal()]) 46 | 47 | # Stop all tasks 48 | for task in self._tasks: 49 | task.cancel() 50 | 51 | 52 | async def resize(self, columns, rows): 53 | if self._closed: 54 | return False 55 | 56 | # Already send that size 57 | if self._size[0] == columns and self._size[1] == rows: 58 | return True 59 | 60 | self._size = (columns, rows,) 61 | message = "1:" + str(self._size[0]) + ":" + str(self._size[1]) + ":" 62 | message = str.encode(message) 63 | await self._ws.send_bytes(message) 64 | 65 | async def _check_resize(self): 66 | if self._provided_termrw: 67 | return 68 | 69 | while True: 70 | await asyncio.sleep(1) 71 | if self._closed: 72 | break 73 | 74 | if not self._ws_ready: 75 | continue 76 | 77 | await self.resize(*get_terminal_size(sys.stdout.fileno())) 78 | 79 | async def _ping_websocket(self): 80 | while True: 81 | await asyncio.sleep(3) 82 | if self._closed: 83 | break 84 | 85 | if not self._ws_ready: 86 | continue 87 | 88 | message = str.encode("2") 89 | await self._ws.send_bytes(message) 90 | 91 | async def _read_websocket(self): 92 | # 93 | # Auth (again) 94 | # 95 | message = self._user + ":" + self._ticket + "\n" 96 | message = str.encode(message) 97 | await self._ws.send_bytes(message) 98 | 99 | # 100 | # Resize message 101 | # 102 | message = "1:" + str(self._size[0]) + ":" + str(self._size[1]) + ":" 103 | message = str.encode(message) 104 | await self._ws.send_bytes(message) 105 | del(message) 106 | 107 | self._ws_ready = True 108 | 109 | if not self._stdin.isatty(): 110 | await asyncio.sleep(1) 111 | message = "0:" + str(len(self._cmd)) + ":" + self._cmd 112 | await self._ws.send_bytes(str.encode(message)) 113 | 114 | PROMPT_RE = re.compile(r'[\w]+@[\w]+:[~\w]+# ') 115 | 116 | buff = '' 117 | try: 118 | async for msg in self._ws: 119 | await asyncio.sleep(0) 120 | 121 | if self._closed: 122 | break 123 | 124 | if msg.type == aiohttp.WSMsgType.BINARY: 125 | message = msg.data.decode('utf8') 126 | if message == 'OK': 127 | continue 128 | 129 | if not self._stdin.isatty(): 130 | buff += message 131 | lines = buff.split("\n") 132 | if PROMPT_RE.search(lines[len(lines)-1]): 133 | self._closed = True 134 | break 135 | 136 | if len(lines) > 0 and lines[0].startswith("starting serial terminal on interface"): 137 | lines = lines[1:] 138 | 139 | if len(lines) > 0 and lines[0].rstrip("\x0d") + "\x0a" == self._cmd: 140 | lines = lines[1:] 141 | 142 | if len(lines) > 1: 143 | for line in lines: 144 | if len(line) > 0: 145 | await self._loop.run_in_executor(None, partial(print, line)) 146 | buff = '' 147 | 148 | continue 149 | 150 | await self._loop.run_in_executor(None, partial(self._stdout.write, message)) 151 | await self._loop.run_in_executor(None, self._stdout.flush) 152 | 153 | elif msg.type == aiohttp.WSMsgType.CLOSED: 154 | self._closed = True 155 | break 156 | 157 | elif msg.type == aiohttp.WSMsgType.ERROR: 158 | self._closed = True 159 | break 160 | finally: 161 | self._closed = True 162 | 163 | if not self._stdin.isatty(): 164 | lines = buff.split("\n") 165 | if PROMPT_RE.search(lines[len(lines)-1]): 166 | lines = lines[:-1] 167 | 168 | for line in lines: 169 | await self._loop.run_in_executor(None, partial(print, line)) 170 | 171 | 172 | async def _read_terminal(self): 173 | try: 174 | if not self._provided_termrw: 175 | p = await asyncio.create_subprocess_exec('stty', 'raw', '-echo') 176 | await p.communicate() 177 | 178 | ctrl_a_pressed_before = False 179 | while True: 180 | if self._closed: 181 | break 182 | 183 | await asyncio.sleep(0) 184 | 185 | char = await self._loop.run_in_executor(None, partial(self._stdin.read, 1)) 186 | 187 | num = ord(char) 188 | if ctrl_a_pressed_before and num == int("0x71", 16): 189 | await self._ws.close() 190 | self._closed = True 191 | break 192 | 193 | if num == int("0x01", 16): 194 | ctrl_a_pressed_before = not ctrl_a_pressed_before 195 | else: 196 | ctrl_a_pressed_before = False 197 | 198 | message = "0:" + str(len(char)) + ":" + char 199 | await self._ws.send_bytes(str.encode(message)) 200 | 201 | except Exception as e: 202 | print(e) 203 | 204 | finally: 205 | if not self._provided_termrw: 206 | p = await asyncio.create_subprocess_exec('stty', 'sane') 207 | await p.communicate() 208 | -------------------------------------------------------------------------------- /pmxc/cli/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jochumdev/pmxc/c0e7535ec9ecb923d75b4ce23aad72a4f5ea4b81/pmxc/cli/__init__.py -------------------------------------------------------------------------------- /pmxc/cli/enter.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import logging 3 | 4 | import click 5 | import aiohttp 6 | from yarl import URL 7 | 8 | from pmxc.api2client.exception import HTTPException 9 | from pmxc.api2client.wsterminal import WSTerminal 10 | from pmxc.lib.utils import get_vmid_resource 11 | from pmxc.lib.remote import RemoteConnection 12 | from pmxc.lib.utils import coro 13 | 14 | 15 | __all__ = [ 16 | "command", 17 | ] 18 | 19 | @click.command(name='enter', help="Enter a VM/Container") 20 | @click.argument('remote') 21 | @click.argument('vmid', default="") 22 | @coro 23 | @click.pass_context 24 | async def command(ctx, remote, vmid): 25 | loop = ctx.obj['loop'] 26 | config = ctx.obj['config'] 27 | try: 28 | async with RemoteConnection(loop, config, remote) as conn: 29 | resource = await get_vmid_resource(conn, remote, vmid) 30 | if not resource: 31 | return 1 32 | 33 | termproxy = await resource.termproxy.post() 34 | path_vncwebsocket = resource.vncwebsocket.path 35 | 36 | websocket_url = URL(conn.url).with_path(path_vncwebsocket).with_query({ 37 | "port": termproxy['port'], 38 | "vncticket": termproxy['ticket'], 39 | }) 40 | 41 | if sys.stdin.isatty(): 42 | print('Connecting: %s' % path_vncwebsocket) 43 | 44 | async with conn.session.ws_connect(str(websocket_url), ssl=aiohttp.Fingerprint(conn.binary_fingerprint), protocols=('binary',)) as ws: 45 | await WSTerminal(loop, ws, termproxy['user'], termproxy['ticket']).run() 46 | 47 | except HTTPException as e: 48 | logging.fatal("HTTP Error: %s", e) 49 | return 1 50 | -------------------------------------------------------------------------------- /pmxc/cli/list.py: -------------------------------------------------------------------------------- 1 | import json 2 | import logging 3 | 4 | import click 5 | import texttable 6 | 7 | from pmxc.api2client.exception import HTTPException 8 | from pmxc.lib.remote import RemoteConnection 9 | from pmxc.lib.utils import parse_key_value_string 10 | from pmxc.lib.utils import coro 11 | 12 | 13 | __all__ = [ 14 | "command", 15 | ] 16 | 17 | @click.command(name='list', help="List Virtual Machines/Containers") 18 | @click.option('-f', '--format', 'format', help='output format [table|json] (default "table")', default='table') 19 | @click.option('-c', '--columns', 'columns', help='Columns (default "ntvhsarm46")', default='ntvhsarm46') 20 | @click.argument('remote') 21 | @coro 22 | @click.pass_context 23 | async def command(ctx, format, columns, remote): 24 | 25 | resources = [] 26 | try: 27 | async with RemoteConnection(ctx.obj['loop'], ctx.obj['config'], remote) as conn: 28 | cresources = await conn.cluster.resources.get() 29 | 30 | for resource in filter(lambda x: (x['type'] == 'resource' or x['type'] == 'qemu' or x['type'] == 'lxc'), cresources): 31 | if resource['type'] == 'lxc': 32 | node = resource['node'] 33 | vmid = resource['vmid'] 34 | 35 | try: 36 | lxc_cfg = await conn.nodes(node).lxc(vmid).config.get() 37 | lxc_cfg['type'] = resource['type'] 38 | lxc_cfg['node'] = node 39 | lxc_cfg['vmid'] = vmid 40 | lxc_cfg['status'] = resource['status'] 41 | 42 | for i in range(0, 9): 43 | key = "net" + str(i) 44 | if key in lxc_cfg: 45 | lxc_cfg[key] = parse_key_value_string(lxc_cfg[key]) 46 | 47 | lxc_cfg['rootfs'] = parse_key_value_string(lxc_cfg['rootfs']) 48 | 49 | resources.append(lxc_cfg) 50 | except HTTPException as e: 51 | logging.error("HTTP Error: %s", e) 52 | else: 53 | node = resource['node'] 54 | vmid = resource['vmid'] 55 | rtype = resource['type'] 56 | 57 | try: 58 | resource_cfg = await conn.nodes(node).url_join(rtype, str(vmid)).config.get() 59 | resource_cfg['type'] = rtype 60 | resource_cfg['node'] = node 61 | resource_cfg['vmid'] = vmid 62 | resource_cfg['status'] = resource['status'] 63 | 64 | for i in range(0, 9): 65 | key = "net" + str(i) 66 | if key in resource_cfg: 67 | resource_cfg[key] = parse_key_value_string(resource_cfg[key]) 68 | 69 | if (rtype == 'resource'): 70 | resource_cfg['rootfs'] = parse_key_value_string(resource_cfg['rootfs']) 71 | else: 72 | resource_cfg['arch'] = 'unknown' 73 | resource_cfg['hostname'] = '' 74 | resource_cfg['rootfs'] = {'size': 0} 75 | 76 | resources.append(resource_cfg) 77 | except HTTPException as e: 78 | logging.error("HTTP Error: %s", e) 79 | 80 | except HTTPException as e: 81 | logging.fatal("HTTP Error: %s", e) 82 | return 1 83 | 84 | if format == 'json': 85 | print(json.dumps(resources, sort_keys=True, indent=4)) 86 | else: 87 | _print_table(resources, columns) 88 | 89 | return 0 90 | 91 | def _print_table(resources, columns): 92 | columns = list(columns.replace(',', '')) 93 | 94 | available_headers = { 95 | 'n': ('Node', 10,), 96 | 't': ('Type', 5,), 97 | 'v': ('VMID', 5,), 98 | 'h': ('Hostname', 20,), 99 | 'a': ('Arch/CPU', 10,), 100 | 'r': ('Rootfs (GiB)', 6,), 101 | 'm': ('Memory (MiB)', 6,), 102 | 's': ('Status', 10,), 103 | '4': ('IPv4', 24,), 104 | '6': ('IPv6', 35,), 105 | } 106 | 107 | headers = [] 108 | sizes = [] 109 | for c in columns: 110 | headers.append(available_headers[c][0]) 111 | sizes.append(available_headers[c][1]) 112 | 113 | table = texttable.Texttable() 114 | table.header(headers) 115 | table.set_cols_width(sizes) 116 | 117 | for resource in resources: 118 | if resource['type'] == 'qemu': 119 | row = [] 120 | 121 | net4 = '' 122 | net6 = '' 123 | 124 | for i in range(0, 9): 125 | key = "net" + str(i) 126 | if key in resource: 127 | if 'ip' in resource[key]: 128 | if net4 != '': 129 | net4 += '\n' 130 | net4 += resource[key]['ip'] + " (" + resource[key]['name'] + ")" 131 | if 'ip6' in resource[key]: 132 | if net6 != '': 133 | net6 += '\n' 134 | net6 += resource[key]['ip6'] + " (" + resource[key]['name'] + ")" 135 | 136 | arch = '' 137 | if resource['type'] == 'resource': 138 | arch = resource['arch'] 139 | elif resource['type'] == 'qemu' and 'cpu' in resource: 140 | arch = resource['cpu'] 141 | 142 | available_data = { 143 | 'n': resource['node'], 144 | 't': resource['type'], 145 | 'v': resource['vmid'], 146 | 'h': resource['hostname'] if resource['type'] == 'resource' else resource['name'], 147 | 'a': arch, 148 | 'r': resource['rootfs']['size'], 149 | 'm': resource['memory'], 150 | 's': resource['status'], 151 | '4': net4, 152 | '6': net6 153 | } 154 | 155 | for c in columns: 156 | row.append(available_data[c]) 157 | 158 | elif resource['type'] == 'lxc': 159 | row = [] 160 | 161 | net4 = '' 162 | net6 = '' 163 | 164 | for i in range(0, 9): 165 | key = "net" + str(i) 166 | if key in resource: 167 | if 'ip' in resource[key]: 168 | if net4 != '': 169 | net4 += '\n' 170 | net4 += resource[key]['ip'] + " (" + resource[key]['name'] + ")" 171 | if 'ip6' in resource[key]: 172 | if net6 != '': 173 | net6 += '\n' 174 | net6 += resource[key]['ip6'] + " (" + resource[key]['name'] + ")" 175 | 176 | available_data = { 177 | 'n': resource['node'], 178 | 't': resource['type'], 179 | 'v': resource['vmid'], 180 | 'h': resource['hostname'], 181 | 'a': resource['arch'], 182 | 'r': resource['rootfs']['size'], 183 | 'm': resource['memory'], 184 | 's': resource['status'], 185 | '4': net4, 186 | '6': net6 187 | } 188 | 189 | for c in columns: 190 | row.append(available_data[c]) 191 | 192 | table.add_row(row) 193 | 194 | print(table.draw()) -------------------------------------------------------------------------------- /pmxc/cli/remote/__init__.py: -------------------------------------------------------------------------------- 1 | import os 2 | import click 3 | from pmxc.lib.loader import load_all 4 | 5 | __all__ = [ 6 | 'group' 7 | ] 8 | 9 | class objectview(object): 10 | """Convert dict(or parameters of dict) to object view 11 | See also: 12 | - https://goodcode.io/articles/python-dict-object/ 13 | - https://stackoverflow.com/questions/1305532/convert-python-dict-to-object 14 | >>> o = objectview({'a': 1, 'b': 2}) 15 | >>> o.a, o.b 16 | (1, 2) 17 | >>> o = objectview(a=1, b=2) 18 | >>> o.a, o.b 19 | (1, 2) 20 | """ 21 | def __init__(self, *args, **kwargs): 22 | d = dict(*args, **kwargs) 23 | self.__dict__ = d 24 | 25 | @click.group(name='remote') 26 | def group(): 27 | pass 28 | 29 | # Load all commands (importlib.import_module) and add them as command to cli 30 | cmds = load_all(objectview(__path__ = [os.path.dirname(__file__),], __name__ = __name__)) 31 | for _, cmd in cmds.items(): 32 | if hasattr(cmd, 'command'): 33 | group.add_command(cmd.command) 34 | -------------------------------------------------------------------------------- /pmxc/cli/remote/add.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import click 3 | import getpass 4 | import logging 5 | import re 6 | 7 | from pmxc.api2client.connection import Connection 8 | from pmxc.api2client.exception import AuthenticationException 9 | from pmxc.api2client.exception import VerifyException 10 | from pmxc.lib.utils import REMOTE_RE 11 | from pmxc.cli.remote import group 12 | from pmxc.lib.utils import coro 13 | from pmxc.lib.config import save_config 14 | 15 | 16 | __all__ = [ 17 | "command", 18 | ] 19 | 20 | 21 | # def configure_argparse(subparser): 22 | # subparser.add_argument("name", help="The name of the remote") 23 | # subparser.add_argument( 24 | # "host", help="The host, either host, host:port or https://host:port") 25 | # subparser.add_argument("username", help="The username") 26 | 27 | 28 | @click.command(name='add', help="Add a remote") 29 | @click.argument('name') 30 | @click.argument('host') 31 | @click.argument('username') 32 | @coro 33 | @click.pass_context 34 | async def command(ctx, name, host, username): 35 | loop = ctx.obj['loop'] 36 | config = ctx.obj['config'] 37 | 38 | match = REMOTE_RE.match(name) 39 | if match is None: 40 | print('"%s" is a invalid remote name' % name, file=sys.stderr) 41 | return 1 42 | 43 | name = match.group('remote') 44 | 45 | if 'remotes' in config and name in config['remotes']: 46 | print('The remote "%s" has already been registered' % name, file=sys.stderr) 47 | return 1 48 | 49 | match = re.fullmatch( 50 | r"(?P[a-z][a-z0-9+\-.]*:\/\/)?" 51 | r"(?P[a-z0-9\-._~%]+|\[[a-z0-9\-._~%!\$&'()*+,;=:]+\])" 52 | r":?(?P[0-9]+)?", host 53 | ) 54 | if match is None: 55 | print('The given host "%s" is not valid' % host, file=sys.stderr) 56 | return 1 57 | 58 | password = getpass.getpass( 59 | 'Enter the password for "%s" (leave empty if you don\'t want to save it): ' % name) 60 | 61 | host = match.group('host') 62 | port = 8006 63 | if match.group('port') is not None: 64 | port = match.group('port') 65 | 66 | fingerprint = None 67 | try: 68 | async with Connection(loop, host, port=port) as conn: 69 | fingerprint = conn.fingerprint 70 | 71 | if password != '': 72 | await conn.login(username, password) 73 | 74 | except VerifyException: 75 | print('Aborting, you didn\'t verify the fingerprint', file=sys.stderr) 76 | return 1 77 | 78 | except AuthenticationException: 79 | print("Aborting, authentication failed", file=sys.stderr) 80 | return 1 81 | 82 | if 'remotes' not in config: 83 | config['remotes'] = {} 84 | 85 | config['remotes'][name] = { 86 | 'host': host, 87 | 'port': port, 88 | 'username': username, 89 | 'fingerprint': fingerprint, 90 | } 91 | if password != '': 92 | config['remotes'][name]['password'] = password 93 | 94 | save_config(config, ctx.obj['config_path']) 95 | 96 | return 0 97 | -------------------------------------------------------------------------------- /pmxc/cli/remote/list.py: -------------------------------------------------------------------------------- 1 | import click 2 | import json 3 | import texttable 4 | from copy import deepcopy 5 | 6 | from pmxc.lib.utils import coro 7 | 8 | __all__ = [ 9 | 'command', 10 | ] 11 | 12 | @click.command(name='list', help="List remotes") 13 | @click.option('-f', '--format', '_format', help='output format [table|json] (default "table")', default='table') 14 | @coro 15 | @click.pass_context 16 | async def command(ctx, _format): 17 | config = ctx.obj['config'] 18 | 19 | if 'remotes' not in config: 20 | return 1 21 | 22 | if _format == 'json': 23 | 24 | remotes = deepcopy(config['remotes']) 25 | for key in remotes.keys(): 26 | if 'password' in remotes[key]: 27 | del(remotes[key]['password']) 28 | 29 | print(json.dumps(remotes, sort_keys=True, indent=4)) 30 | else: 31 | table = texttable.Texttable() 32 | table.header([ 'Name', 'Host', 'Port', 'Username', 'Fingerprint', ]) 33 | table.set_cols_width((10, 25, 6, 15, 95)) 34 | 35 | for name, data in config['remotes'].items(): 36 | table.add_row((name, data['host'], data['port'], data['username'], data['fingerprint'],)) 37 | print(table.draw()) 38 | 39 | return 0 -------------------------------------------------------------------------------- /pmxc/cli/remote/remove.py: -------------------------------------------------------------------------------- 1 | import sys 2 | import click 3 | import logging 4 | from pmxc.lib.utils import coro 5 | from pmxc.lib.config import save_config 6 | 7 | __all__ = [ 8 | 'command', 9 | ] 10 | 11 | @click.command(name='remove', help="Remove a remote") 12 | @click.argument('remote') 13 | @coro 14 | @click.pass_context 15 | async def command(ctx, remote): 16 | config = ctx.obj['config'] 17 | 18 | if 'remotes' not in config or remote not in config['remotes']: 19 | print('Unknown remote "%s"' % remote, file=sys.stderr) 20 | return 1 21 | 22 | del(config['remotes'][remote]) 23 | 24 | save_config(config, ctx.obj['config_path']) 25 | 26 | print("OK") 27 | 28 | return 0 -------------------------------------------------------------------------------- /pmxc/cli/reset.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | 4 | from pmxc.api2client.exception import HTTPException 5 | from pmxc.lib.utils import get_vmid_resource 6 | from pmxc.lib.remote import RemoteConnection 7 | from pmxc.lib.utils import coro 8 | 9 | 10 | __all__ = [ 11 | "command", 12 | ] 13 | 14 | @click.command(name='reset', help="Reset a Virtual Machine/Container") 15 | @click.argument('remote') 16 | @click.argument('vmid', default="") 17 | @coro 18 | @click.pass_context 19 | async def command(ctx, remote, vmid): 20 | loop = ctx.obj['loop'] 21 | config = ctx.obj['config'] 22 | try: 23 | async with RemoteConnection(loop, config, remote) as conn: 24 | resource = await get_vmid_resource(conn, remote, vmid) 25 | if not resource: 26 | return 1 27 | 28 | await resource.status.reset.post() 29 | print("OK") 30 | 31 | except HTTPException as e: 32 | logging.fatal("HTTP Error: %s", e) 33 | return 1 34 | -------------------------------------------------------------------------------- /pmxc/cli/resume.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | 4 | from pmxc.api2client.exception import HTTPException 5 | from pmxc.lib.utils import get_vmid_resource 6 | from pmxc.lib.remote import RemoteConnection 7 | from pmxc.lib.utils import coro 8 | 9 | 10 | __all__ = [ 11 | "command", 12 | ] 13 | 14 | @click.command(name='resume', help="Resume a Virtual Machine/Container") 15 | @click.argument('remote') 16 | @click.argument('vmid', default="") 17 | @coro 18 | @click.pass_context 19 | async def command(ctx, remote, vmid): 20 | loop = ctx.obj['loop'] 21 | config = ctx.obj['config'] 22 | try: 23 | async with RemoteConnection(loop, config, remote) as conn: 24 | resource = await get_vmid_resource(conn, remote, vmid) 25 | if not resource: 26 | return 1 27 | 28 | await resource.status.resume.post() 29 | print("OK") 30 | 31 | except HTTPException as e: 32 | logging.fatal("HTTP Error: %s", e) 33 | return 1 34 | -------------------------------------------------------------------------------- /pmxc/cli/shutdown.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | 4 | from pmxc.api2client.exception import HTTPException 5 | from pmxc.lib.utils import get_vmid_resource 6 | from pmxc.lib.remote import RemoteConnection 7 | from pmxc.lib.utils import coro 8 | 9 | 10 | __all__ = [ 11 | "command", 12 | ] 13 | 14 | @click.command(name='shutdown', help="Shutdown a Virtual Machine/Container") 15 | @click.option('-f', '--force/--no-force', 'force', help='Force (default "False")', default=False) 16 | @click.option('-t', '--timeout', 'timeout', help='Timeout (default "60")', default=60) 17 | @click.argument('remote') 18 | @click.argument('vmid', default="") 19 | @coro 20 | @click.pass_context 21 | async def command(ctx, force, timeout, remote, vmid): 22 | loop = ctx.obj['loop'] 23 | config = ctx.obj['config'] 24 | try: 25 | async with RemoteConnection(loop, config, remote) as conn: 26 | resource = await get_vmid_resource(conn, remote, vmid) 27 | if not resource: 28 | return 1 29 | 30 | await resource.status.shutdown.post(timeout=int(timeout), forceStop=0 if force else 1) 31 | print("OK") 32 | 33 | except HTTPException as e: 34 | logging.fatal("HTTP Error: %s", e) 35 | return 1 36 | -------------------------------------------------------------------------------- /pmxc/cli/spice.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | import os 4 | import subprocess 5 | from tempfile import mkstemp 6 | 7 | from pmxc.api2client.exception import HTTPException 8 | from pmxc.lib.remote import RemoteConnection 9 | from pmxc.lib.utils import SPICE_VIEWER_PATHS 10 | from pmxc.lib.utils import find_path 11 | from pmxc.lib.utils import get_vmid_resource 12 | from pmxc.lib.utils import is_cygwin 13 | from pmxc.lib.utils import randstring 14 | from pmxc.lib.utils import coro 15 | 16 | __all__ = [ 17 | "command", 18 | ] 19 | 20 | @click.command(name='spice', help="Connect with spice to the Virtual Machine/Container") 21 | @click.argument('remote') 22 | @click.argument('vmid', default="") 23 | @coro 24 | @click.pass_context 25 | async def command(ctx, remote, vmid): 26 | loop = ctx.obj['loop'] 27 | config = ctx.obj['config'] 28 | try: 29 | async with RemoteConnection(loop, config, remote) as conn: 30 | resource = await get_vmid_resource(conn, remote, vmid) 31 | if not resource: 32 | return 1 33 | 34 | spdata = await resource.spiceproxy.post() 35 | 36 | filecontents = "[virt-viewer]\n" 37 | for k, v in spdata.items(): 38 | filecontents += str(k) + "=" + str(v) + "\n" 39 | 40 | filename = None 41 | if not is_cygwin(): 42 | fd, filename = mkstemp(text=True) 43 | 44 | with open(filename, 'w') as fp: 45 | fp.write(filecontents) 46 | os.close(fd) 47 | else: 48 | filename = os.path.join(os.getenv('USERPROFILE'), randstring() + ".vv") 49 | with open(filename, 'w') as fp: 50 | fp.write(filecontents) 51 | 52 | executable = find_path(SPICE_VIEWER_PATHS) 53 | subprocess.Popen([executable, filename], stdin=subprocess.DEVNULL, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) 54 | 55 | except HTTPException as e: 56 | logging.fatal("HTTP Error: %s", e) 57 | return 1 58 | -------------------------------------------------------------------------------- /pmxc/cli/start.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | 4 | from pmxc.api2client.exception import HTTPException 5 | from pmxc.lib.utils import get_vmid_resource 6 | from pmxc.lib.remote import RemoteConnection 7 | from pmxc.lib.utils import coro 8 | 9 | 10 | __all__ = [ 11 | "command", 12 | ] 13 | 14 | @click.command(name='start', help="Start a Virtual Machine/Container") 15 | @click.argument('remote') 16 | @click.argument('vmid', default="") 17 | @coro 18 | @click.pass_context 19 | async def command(ctx, remote, vmid): 20 | loop = ctx.obj['loop'] 21 | config = ctx.obj['config'] 22 | try: 23 | async with RemoteConnection(loop, config, remote) as conn: 24 | resource = await get_vmid_resource(conn, remote, vmid) 25 | if not resource: 26 | return 1 27 | 28 | await resource.status.start.post() 29 | print("OK") 30 | 31 | except HTTPException as e: 32 | logging.fatal("HTTP Error: %s", e) 33 | return 1 34 | -------------------------------------------------------------------------------- /pmxc/cli/stop.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | 4 | from pmxc.api2client.exception import HTTPException 5 | from pmxc.lib.utils import get_vmid_resource 6 | from pmxc.lib.remote import RemoteConnection 7 | from pmxc.lib.utils import coro 8 | 9 | 10 | __all__ = [ 11 | "command", 12 | ] 13 | 14 | @click.command(name='stop', help="Stop (kill) a Virtual Machine/Container") 15 | @click.argument('remote') 16 | @click.argument('vmid', default="") 17 | @coro 18 | @click.pass_context 19 | async def command(ctx, remote, vmid): 20 | loop = ctx.obj['loop'] 21 | config = ctx.obj['config'] 22 | try: 23 | async with RemoteConnection(loop, config, remote) as conn: 24 | resource = await get_vmid_resource(conn, remote, vmid) 25 | if not resource: 26 | return 1 27 | 28 | await resource.status.stop.post() 29 | print("OK") 30 | 31 | except HTTPException as e: 32 | logging.fatal("HTTP Error: %s", e) 33 | return 1 34 | -------------------------------------------------------------------------------- /pmxc/cli/suspend.py: -------------------------------------------------------------------------------- 1 | import click 2 | import logging 3 | 4 | from pmxc.api2client.exception import HTTPException 5 | from pmxc.lib.utils import get_vmid_resource 6 | from pmxc.lib.remote import RemoteConnection 7 | from pmxc.lib.utils import coro 8 | 9 | 10 | __all__ = [ 11 | "command", 12 | ] 13 | 14 | @click.command(name='suspend', help="Suspend a Virtual Machine/Containers") 15 | @click.argument('remote') 16 | @click.argument('vmid', default="") 17 | @coro 18 | @click.pass_context 19 | async def command(ctx, remote, vmid): 20 | loop = ctx.obj['loop'] 21 | config = ctx.obj['config'] 22 | try: 23 | async with RemoteConnection(loop, config, remote) as conn: 24 | resource = await get_vmid_resource(conn, remote, vmid) 25 | if not resource: 26 | return 1 27 | 28 | await resource.status.suspend.post() 29 | print("OK") 30 | 31 | except HTTPException as e: 32 | logging.fatal("HTTP Error: %s", e) 33 | return 1 34 | -------------------------------------------------------------------------------- /pmxc/cli/version.py: -------------------------------------------------------------------------------- 1 | import os 2 | import sys 3 | import click 4 | 5 | from pmxc import __version__ 6 | from pmxc.lib.utils import coro 7 | 8 | __all__ = [ 9 | "command", 10 | ] 11 | 12 | @click.command(name='version', help="Show the version") 13 | @coro 14 | @click.pass_context 15 | async def command(ctx): 16 | print("%s version %s" % (os.path.basename(sys.argv[0]), __version__)) 17 | return 0 18 | -------------------------------------------------------------------------------- /pmxc/lib/__init__.py: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/jochumdev/pmxc/c0e7535ec9ecb923d75b4ce23aad72a4f5ea4b81/pmxc/lib/__init__.py -------------------------------------------------------------------------------- /pmxc/lib/config.py: -------------------------------------------------------------------------------- 1 | import os 2 | import os.path 3 | import sys 4 | import json 5 | 6 | 7 | def load_config(path): 8 | if not os.path.exists(path): 9 | return {} 10 | 11 | with open(path, 'r') as fp: 12 | content = fp.read() 13 | return json.loads(content) 14 | 15 | 16 | def save_config(config, path): 17 | os.makedirs(os.path.dirname(path), mode=0o700, exist_ok=True) 18 | with os.fdopen(os.open(path, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600), 'w') as fp: 19 | content = json.dumps(config, indent=4, sort_keys=True) 20 | fp.write(content) 21 | -------------------------------------------------------------------------------- /pmxc/lib/loader.py: -------------------------------------------------------------------------------- 1 | import importlib 2 | import pkgutil 3 | 4 | 5 | __all__ = [ 6 | "load_all", 7 | ] 8 | 9 | def load_all(package): 10 | """ 11 | https://stackoverflow.com/a/1707786/3368468 12 | """ 13 | result = {} 14 | for _, modname, _ in pkgutil.iter_modules(package.__path__): 15 | module = importlib.import_module( 16 | package.__name__ + '.' + modname, package) 17 | result[modname] = module 18 | return result 19 | -------------------------------------------------------------------------------- /pmxc/lib/remote.py: -------------------------------------------------------------------------------- 1 | import getpass 2 | 3 | from pmxc.api2client.connection import Connection 4 | from pmxc.lib.utils import REMOTE_RE 5 | 6 | 7 | __all__ = [ 8 | 'RemoteConnection', 9 | ] 10 | 11 | class RemoteConnection(object): 12 | def __init__(self, loop, config, name, verify_cb=None): 13 | match = REMOTE_RE.match(name) 14 | if match is None: 15 | raise ValueError('"%s" is a invalid remote name' % name) 16 | 17 | name = match.group('remote') 18 | if name not in config['remotes']: 19 | raise ValueError('Unknown remote "%s"' % name) 20 | 21 | self._loop = loop 22 | self._config = config 23 | self._name = name 24 | self._verify_cb = verify_cb 25 | 26 | self._conn = None 27 | 28 | async def __aenter__(self): 29 | cfg = self._config['remotes'][self._name] 30 | self._conn = Connection( 31 | self._loop, cfg['host'], port=cfg['port'], fingerprint=cfg['fingerprint'], verify_cb=self._verify_cb) 32 | await self._conn.__aenter__() 33 | 34 | password = None 35 | if 'password' in cfg: 36 | password = cfg['password'] 37 | else: 38 | password = getpass.getpass( 39 | 'Enter the password for "%s": ' % self._name) 40 | 41 | await self._conn.login(cfg['username'], password) 42 | 43 | return self._conn 44 | 45 | async def __aexit__(self, *args): 46 | await self._conn.__aexit__(*args) 47 | -------------------------------------------------------------------------------- /pmxc/lib/utils.py: -------------------------------------------------------------------------------- 1 | import asyncio 2 | import logging 3 | import os 4 | import platform 5 | import re 6 | import shutil 7 | import string 8 | import random 9 | from functools import wraps 10 | 11 | 12 | __all__ = [ 13 | 'REMOTE_RE', 14 | 'REMOTE_VMID_RE', 15 | 'SPICE_VIEWER_PATHS', 16 | 'parse_key_value_string', 17 | 'get_vmid_resource', 18 | 'is_cygwin', 19 | 'find_path', 20 | 'randstring', 21 | ] 22 | 23 | REMOTE_RE = re.compile(r'^(?P[\w\d\.\-\_]+):?') 24 | REMOTE_VMID_RE = re.compile(r'^(?P[\w\d\.\-\_]+):(?P[\d]+)$') 25 | 26 | 27 | SPICE_VIEWER_PATHS = { 28 | 'Linux': ['remote-viewer'], 29 | 'Windows': ['VirtViewer v6.0-256\\bin\\remote-viewer'], 30 | } 31 | 32 | 33 | def coro(f): 34 | """ AsnycIO Wrapper for Click 35 | 36 | Found here: https://github.com/pallets/click/issues/85 37 | """ 38 | 39 | @wraps(f) 40 | def wrapper(*args, **kwargs): 41 | return asyncio.get_event_loop().run_until_complete(f(*args, **kwargs)) 42 | 43 | return wrapper 44 | 45 | 46 | def parse_key_value_string(data): 47 | split = data.split(',') 48 | 49 | result = {} 50 | for s in split: 51 | if '=' in s: 52 | key, value = s.split('=') 53 | result[key] = value 54 | else: 55 | result[''] = s 56 | 57 | return result 58 | 59 | async def get_vmid_resource(conn, remote_vmid, vmid=""): 60 | match = REMOTE_VMID_RE.match(remote_vmid) 61 | if match is not None: 62 | vmid = match.group('vmid') 63 | elif vmid == "": 64 | logging.error('Not a remote:vmid: %s', remote_vmid) 65 | return False 66 | 67 | vmid = int(vmid) 68 | 69 | resources = await conn.cluster.resources.get() 70 | 71 | r = [x for x in resources if (x['type'] == 'qemu' or x['type'] == 'lxc') and x['vmid'] == vmid] 72 | if len(r) < 1: 73 | logging.error('VMID %d not found' % vmid) 74 | return False 75 | if len(r) > 1: 76 | logging.error('More than one resource with that vmid found: %d', vmid) 77 | return False 78 | 79 | node = r[0]['node'] 80 | rtype = r[0]['type'] 81 | 82 | return conn.nodes(node).url_join(rtype, str(vmid)) 83 | 84 | def is_cygwin(): 85 | return platform.system().startswith('CYGWIN_NT') 86 | 87 | 88 | def find_path(paths): 89 | os_name = platform.system() 90 | 91 | if os_name.startswith('CYGWIN_NT'): 92 | os_name = 'Windows' 93 | 94 | executables = paths[os_name] 95 | 96 | if os_name == 'Linux' or os_name == 'Darwin': 97 | for executable in executables: 98 | cmd = shutil.which(executable) 99 | if cmd is not None: 100 | return cmd 101 | elif os_name == 'Windows': 102 | x32 = os.environ['ProgramFiles(x86)'] 103 | x64 = os.environ['ProgramW6432'] 104 | 105 | for executable in executables: 106 | cmd = os.path.join(x64, executable) 107 | if os.access(cmd, mode=os.F_OK | os.X_OK): 108 | return cmd 109 | cmd = os.path.join(x32, executable) 110 | if os.access(cmd, mode=os.F_OK | os.X_OK): 111 | return cmd 112 | 113 | return None 114 | 115 | def randstring(maxchars=8): 116 | return ''.join(random.choice(string.ascii_letters) for _ in range(maxchars)) -------------------------------------------------------------------------------- /setup.py: -------------------------------------------------------------------------------- 1 | import codecs 2 | import os 3 | from setuptools import setup, find_packages 4 | 5 | # abspath here because setup.py may be __main__, in which case 6 | # __file__ is not guaranteed to be absolute 7 | here = os.path.abspath(os.path.dirname(__file__)) 8 | 9 | 10 | def read_file(filename): 11 | """Open a related file and return its content.""" 12 | with codecs.open(os.path.join(here, filename), encoding='utf-8') as f: 13 | content = f.read() 14 | return content 15 | 16 | 17 | README = read_file('README.rst') 18 | CHANGELOG = read_file('CHANGELOG.rst') 19 | CONTRIBUTORS = read_file('CONTRIBUTORS.rst') 20 | 21 | REQUIREMENTS = [ 22 | 'aiohttp', 23 | 'click', 24 | 'texttable', 25 | ] 26 | 27 | UVLOOP_REQUIRES = [ 28 | 'uvloop', 29 | ] 30 | 31 | PERFORMANCE_REQUIRES = [ 32 | 'cchardet', 33 | 'aiodns', 34 | ] 35 | 36 | DEVELOPMENT_REQUIRES = [ 37 | 'pycodestyle==2.6.0', # version pin for flake8 and prospector 38 | 'pylint==2.5.2', 39 | 'autopep8', 40 | 'flake8', 41 | 'ipython', 42 | 'prospector[with_pyroma]', 43 | 'zest.releaser', 44 | ] 45 | 46 | ENTRY_POINTS = { 47 | 'console_scripts': [ 48 | 'pmxc = pmxc.__main__:cli' 49 | ] 50 | } 51 | 52 | setup(name='pmxc', 53 | version='5.2.0.0.dev0', 54 | description='', 55 | long_description='{}\n\n{}\n\n{}'.format( 56 | README, CHANGELOG, CONTRIBUTORS), 57 | license='MIT', 58 | classifiers=[ 59 | 'Programming Language :: Python', 60 | 'Programming Language :: Python :: 3', 61 | 'Programming Language :: Python :: 3.5', 62 | 'Programming Language :: Python :: 3.6', 63 | 'Programming Language :: Python :: 3.7', 64 | 'Programming Language :: Python :: 3.8', 65 | 'License :: OSI Approved :: MIT License', 66 | ], 67 | keywords='Console Proxmox PVE', 68 | author='René Jochum', 69 | author_email='rene@jochums.at', 70 | url='https://git.lxch.eu/pcdummy/pmxc-py', 71 | packages=find_packages(), 72 | package_data={'': ['*.rst', '*.py', '*.yaml']}, 73 | include_package_data=True, 74 | zip_safe=False, 75 | install_requires=REQUIREMENTS, 76 | extras_require={ 77 | 'development': DEVELOPMENT_REQUIRES, 78 | 'performance': PERFORMANCE_REQUIRES, 79 | 'uvloop': UVLOOP_REQUIRES, 80 | }, 81 | entry_points=ENTRY_POINTS) 82 | --------------------------------------------------------------------------------