diff --git a/.gitignore b/.gitignore index 3127051e..6db1d3a7 100644 --- a/.gitignore +++ b/.gitignore @@ -19,3 +19,4 @@ *.pyc *.swp /venv/ +/.vscode/ diff --git a/extras/remote/manifest.yaml b/extras/remote/manifest.yaml new file mode 100644 index 00000000..7a057d6d --- /dev/null +++ b/extras/remote/manifest.yaml @@ -0,0 +1,6 @@ +name: Remote +description: Remote Hosts Control +daemon: kvmd +icon: share/svg/ipmi.svg +path: remote +place: 30 diff --git a/kvmd/apps/__init__.py b/kvmd/apps/__init__.py index 90399638..8f2378da 100644 --- a/kvmd/apps/__init__.py +++ b/kvmd/apps/__init__.py @@ -31,6 +31,8 @@ import pygments import pygments.lexers.data import pygments.formatters +from pathlib import Path + from .. import tools from ..mouse import MouseRange @@ -163,6 +165,20 @@ def init( "Make sure you understand exactly what you are doing!" ) + path = Path(config.kvmd.remote.path) + config.kvmd.remote.hosts = [] + + for file in [x for x in os.listdir(path) if x.endswith(".conf")]: + pairs = {'name': file.rsplit('.', 1)[0]} + + with open(path / file) as conf: + lines = conf.readlines() + for line in lines: + key, value = line.rstrip('\n').split("=", 1) + pairs[key.strip()] = value.strip() + + config.kvmd.remote.hosts.append(pairs) + return (parser, remaining, config) @@ -348,6 +364,11 @@ def _get_config_scheme() -> dict: "logging": Option({}), "kvmd": { + "remote": { + "path": Option("/etc/kvmd/hosts.d", type=valid_abs_path), + "ssh_key": Option("/etc/kvmd/ssh/id_rsa", type=valid_abs_path), + "timeout": Option(10, type=valid_int_f1), + }, "server": { "unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"), "unix_rm": Option(True, type=valid_bool), diff --git a/kvmd/apps/kvmd/__init__.py b/kvmd/apps/kvmd/__init__.py index 2d0219db..fd69b765 100644 --- a/kvmd/apps/kvmd/__init__.py +++ b/kvmd/apps/kvmd/__init__.py @@ -36,11 +36,12 @@ from .streamer import Streamer from .snapshoter import Snapshoter from .ocr import Ocr from .server import KvmdServer +from .remote import RemoteControl # ===== def main(argv: (list[str] | None)=None) -> None: - config = init( + _, _, config = init( prog="kvmd", description="The main PiKVM daemon", argv=argv, @@ -50,7 +51,7 @@ def main(argv: (list[str] | None)=None) -> None: load_atx=True, load_msd=True, load_gpio=True, - )[2] + ) msd_kwargs = config.kvmd.msd._unpack(ignore=["type"]) if config.kvmd.msd.type == "otg": @@ -102,6 +103,12 @@ def main(argv: (list[str] | None)=None) -> None: **config.snapshot._unpack(), ), + remote=RemoteControl.from_dict( + hosts=config.remote.hosts, + timeout=config.remote.timeout, + ssh_key=config.remote.ssh_key, + ), + keymap_path=config.hid.keymap, ignore_keys=config.hid.ignore_keys, mouse_x_range=(config.hid.mouse_x_range.min, config.hid.mouse_x_range.max), diff --git a/kvmd/apps/kvmd/api/remote.py b/kvmd/apps/kvmd/api/remote.py new file mode 100644 index 00000000..3ad0fe80 --- /dev/null +++ b/kvmd/apps/kvmd/api/remote.py @@ -0,0 +1,47 @@ +import sys +import traceback + +from aiohttp.web import Request +from aiohttp.web import Response + +from ....htserver import UnavailableError +from ....htserver import exposed_http +from ....htserver import make_json_response + +from ....logging import get_logger + +from ..remote import RemoteControl + + +# ===== +class RemoteApi: + def __init__(self, remote: RemoteControl) -> None: + self.__remote = remote + + # ===== + + @exposed_http("POST", "/remote") + async def __state_handler(self, _: Request) -> Response: + return make_json_response({ + "hosts": await self.__remote.get_state(), + }) + + @exposed_http("POST", "/remote/control") + async def __remote_control_handler(self, request: Request) -> Response: + data = await request.json() + try: + code, message, error = await self.__remote.perform( + data["target"], data["action"] + ) + except Exception: + logger = get_logger(0) + tb = traceback.format_exception(*sys.exc_info()) + + logger.exception(repr(tb)) + raise UnavailableError() + + return make_json_response({ + "code": code, + "message": message, + "error": error, + }) diff --git a/kvmd/apps/kvmd/remote.py b/kvmd/apps/kvmd/remote.py new file mode 100644 index 00000000..2eb36209 --- /dev/null +++ b/kvmd/apps/kvmd/remote.py @@ -0,0 +1,114 @@ +import asyncio + +from time import time + +from typing import AsyncGenerator +from typing import Tuple + +from ... import aiotools + + +class RemoteHost: + def __init__( + self, + name, + address='', + encoding='utf-8', + **kwargs + ) -> None: + self.name = name + self.address = address + self.encoding = encoding + self.actions = kwargs + + self.online = False + self.last_seen = 0 + + def get_state(self) -> dict: + return { + "name": self.name, + "online": self.online, + "last_seen": self.last_seen, + "actions": self.parse_action(), + } + + def parse_action(self, action=None) -> list[str] | str: + if not action: + return [ + key[3:].upper() for key, value in self.actions.items() + if key.startswith('on_') and value + ] + command = self.actions.get("on_%s" % action.lower()) + if not command: raise NotImplementedError() + return command + + async def ping(self) -> bool: + process = await asyncio.create_subprocess_shell( + f"ping -i 0.5 -c 2 -w 2 {self.address}", + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + await process.wait() + online = process.returncode == 0 + + changed = self.online != online + self.online = online + + if self.online: + self.last_seen = time() + return changed + + +class RemoteControl: + def __init__(self, hosts: list[RemoteHost], timeout: int, ssh_key: str) -> None: + self.__hosts = { host.name: host for host in hosts } + self.__timeout = timeout + self.__ssh_key = ssh_key + + self.__notifier = aiotools.AioNotifier() + + @staticmethod + def from_dict(hosts: list[dict], **kwargs): + roll = [RemoteHost(**host) for host in hosts] + return RemoteControl(roll, **kwargs) + + async def get_state(self) -> list[dict]: + return [ + host.get_state() for host in self.__hosts.values() + ] + + async def perform(self, hostname, action) -> Tuple[int | None, str, str]: + if hostname not in self.__hosts: + raise KeyError(hostname) + host = self.__hosts.get(hostname) + command = host.parse_action(action) + + if command.startswith("ssh"): + head, tail = command.split(' ', 1) + slices = [head, "-oStrictHostKeyChecking=no", "-i", self.__ssh_key, tail] + command = ' '.join(slices) + + process = await asyncio.create_subprocess_shell( + command, + stdout=asyncio.subprocess.PIPE, + stderr=asyncio.subprocess.PIPE + ) + + await process.wait() + stdout, stderr = await process.communicate() + + message = stdout.decode(host.encoding) + error = stderr.decode(host.encoding) + return (process.returncode, message, error) + + async def update(self) -> list[dict]: + return [ + host.get_state() for host in self.__hosts.values() + if await host.ping() + ] + + async def poll_state(self) -> AsyncGenerator[list, None]: + while True: + state = await self.update() + if state: yield state + await self.__notifier.wait(self.__timeout) diff --git a/kvmd/apps/kvmd/server.py b/kvmd/apps/kvmd/server.py index 8f696fb7..536ac167 100644 --- a/kvmd/apps/kvmd/server.py +++ b/kvmd/apps/kvmd/server.py @@ -70,6 +70,7 @@ from .ugpio import UserGpio from .streamer import Streamer from .snapshoter import Snapshoter from .ocr import Ocr +from .remote import RemoteControl from .api.auth import AuthApi from .api.auth import check_request_auth @@ -83,6 +84,7 @@ from .api.msd import MsdApi from .api.streamer import StreamerApi from .api.export import ExportApi from .api.redfish import RedfishApi +from .api.remote import RemoteApi # ===== @@ -137,6 +139,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins msd: BaseMsd, streamer: Streamer, snapshoter: Snapshoter, + remote: RemoteControl, keymap_path: str, ignore_keys: List[str], @@ -170,6 +173,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins _Component("ATX", "atx_state", atx), _Component("MSD", "msd_state", msd), _Component("Streamer", "streamer_state", streamer), + _Component("Remote", "remote_state", remote), ], ] @@ -187,6 +191,7 @@ class KvmdServer(HttpServer): # pylint: disable=too-many-arguments,too-many-ins self.__streamer_api, ExportApi(info_manager, atx, user_gpio), RedfishApi(info_manager, atx), + RemoteApi(remote), ] self.__streamer_notifier = aiotools.AioNotifier() diff --git a/web/remote/index.html b/web/remote/index.html new file mode 100644 index 00000000..1154b03d --- /dev/null +++ b/web/remote/index.html @@ -0,0 +1,37 @@ + + +
+ +