From c536a1ac4901d199df659df82783e2c2d71ffaa4 Mon Sep 17 00:00:00 2001 From: Break27 Date: Sat, 7 Oct 2023 16:55:05 +0800 Subject: [PATCH] feature: remote host control --- .gitignore | 1 + extras/remote/manifest.yaml | 6 + kvmd/apps/__init__.py | 21 +++ kvmd/apps/kvmd/__init__.py | 11 +- kvmd/apps/kvmd/api/remote.py | 47 +++++++ kvmd/apps/kvmd/remote.py | 114 +++++++++++++++++ kvmd/apps/kvmd/server.py | 5 + web/remote/index.html | 37 ++++++ web/share/css/remote/actions.css | 57 +++++++++ web/share/css/remote/remote.css | 116 +++++++++++++++++ web/share/js/remote/main.js | 192 ++++++++++++++++++++++++++++ web/share/js/remote/relativeTime.js | 32 +++++ 12 files changed, 637 insertions(+), 2 deletions(-) create mode 100644 extras/remote/manifest.yaml create mode 100644 kvmd/apps/kvmd/api/remote.py create mode 100644 kvmd/apps/kvmd/remote.py create mode 100644 web/remote/index.html create mode 100644 web/share/css/remote/actions.css create mode 100644 web/share/css/remote/remote.css create mode 100644 web/share/js/remote/main.js create mode 100644 web/share/js/remote/relativeTime.js 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 @@ + + + + + Remote Control + + + + + + + + + + + + + + + + +
+
+
+   ←   [ PiKVM Index ] + +
+
+
+

Loading ...

+
+
+
+ + diff --git a/web/share/css/remote/actions.css b/web/share/css/remote/actions.css new file mode 100644 index 00000000..e89a4030 --- /dev/null +++ b/web/share/css/remote/actions.css @@ -0,0 +1,57 @@ +div.host[state='online'] button.remote-action[action='WAKE'], +div.host[state='offline'] button.remote-action[action='SHUTDOWN'], +div.host[state='unknown'] button.remote-action { + display: none; +} + +div.host[state='unknown'] div.remote-actions::before { + content: "⏳"; +} + +/********************************************/ + +button.remote-action[action='WAKE']::before { + content: "☀️" +} + +button.remote-action[action='SHUTDOWN']::before { + content: "🌙" +} + +button.remote-action[action='RESTART']::before { + content: "🔄" +} + +/********************************************/ + +div.remote-actions { + display: inline-flex; + flex: 1; + justify-content: flex-end; + align-items: center; + font-size: 20px; +} + +button.remote-action { + border: var(--border-key-thin); + border-radius: 6px; + box-shadow: var(--shadow-micro); + width: 40px; + height: 40px; + + display: flex; + justify-content: center; + align-items: center; + + margin-left: 10px; + background-color: var(--cs-control-default-bg); +} + +button.remote-action:hover { + border-color: var(--cs-corner-bg); +} + +button.remote-action:active { + background-color: var(--cs-control-pressed-bg); +} + diff --git a/web/share/css/remote/remote.css b/web/share/css/remote/remote.css new file mode 100644 index 00000000..968c7c10 --- /dev/null +++ b/web/share/css/remote/remote.css @@ -0,0 +1,116 @@ +h4 { + text-align: center; + margin-top: 25px; +} + +div#rf div.icon { + display: inline-flex; + margin-left: 12px; + margin-right: 12px; + font-size: 20px; + + align-items: center; +} + +div#rf div.icon.spin { + animation: spin 1s linear infinite; +} + +div#hosts { + margin-top: 12px; +} + +div#hosts div.host { + display: flex; + margin-top: 8px; + padding: 12px 12px 12px 12px; + + background-color: var(--cs-control-default-bg); + border: var(--border-key-thin); + border-radius: 6px; +} + +span.bulb { + width: 12px; + height: 12px; + border-radius: 50%; + display: inline-block; + margin-right: 8px; +} + +div[state='unknown'] span.bulb { + background-color: yellow; +} + +div[state='online'] span.bulb { + background-color: limegreen; +} + +div[state='offline'] span.bulb { + background-color: grey; +} + +span.state { + margin-left: 24px; + color: grey; + user-select: none; +} + +div[state='unknown'] span.state::before { + content: "[ Unknown ]"; +} + +div[state='online'] span.state::before { + content: "[ Online ]"; +} + +div[state='offline'] span.state::before { + content: "[ Offline ]"; +} + +span.hostname { + font-size: 18px; + font-weight: 500; + line-height: 28px; + color: white; +} + +div.link { + margin-top: 1.5px; + margin-left: 32px; + cursor: pointer; +} + +div#actions { + display:flex; + justify-content: space-between; +} + +div.start { + min-width: 100%; + height: 100vh; +} + +.link { + display: inline-flex; + margin-top: 4px; + color: #5c90bc; + text-decoration: none; + align-items: center; +} + +@media only screen and (min-width: 768px) { + div.start { + min-width: 40%; + height: 100%; + } +} + +@keyframes spin { + from { + transform: rotate(0deg); + } + to { + transform: rotate(360deg); + } +} diff --git a/web/share/js/remote/main.js b/web/share/js/remote/main.js new file mode 100644 index 00000000..fd06e05d --- /dev/null +++ b/web/share/js/remote/main.js @@ -0,0 +1,192 @@ +"use strict"; + + +import { $, $$$, tools } from "../tools.js"; +import { fromNow } from "./relativeTime.js"; + + +let prev_state = {}; + +let loading = false; + +/********************************************************/ + +export function main() { + loadRemoteApi(x => makeView(x)); + setInterval(update, 10000); + + $("rf").addEventListener("click", refresh); +} + +function update() { + loadRemoteApi(x => x.forEach(y => updateState(y))); + updateOfflineTime(); +} + +function refresh(event) { + if (loading) return; + loading = true; + + let icon = $$$("#rf div.icon")[0]; + icon.classList.toggle("spin"); + + prev_state = {}; + loadRemoteApi(x => { + x.forEach(y => updateState(y)); + setTimeout(() => { + icon.classList.toggle("spin") + loading = false; + }, 950); + }); +} + +/********************************************************/ + +function guards(http) { + if (http.readyState !== 4) { + return false; + } + + if (http.status === 401 || http.status === 403) { + document.location.href = "/login"; + return false; + } + + if (http.status !== 200) { + return false; + } + + return true; +} + +function loadRemoteApi(callback) { + let http = tools.makeRequest("POST", "/api/remote", () => { + let response = http.responseText; + + if (! guards(http)) return; + if (! response) return; + + let hosts = JSON.parse(response).result.hosts; + let diff = stateDiff(hosts); + + callback(diff); + }); +} + +function actionPerform(target, action) { + let body = JSON.stringify({ target, action }); + let contentType = "application/json"; + + let http = tools.makeRequest("POST", "/api/remote/control", () => { + if (! guards(http)) return; + + let response = http.responseText; + let result = JSON.parse(response).result; + + if (result.code != 0) { + delete prev_state[target]; + let state = $$$(`.host[name='${target}'] span.state`)[0]; + + state.innerHTML = '  ⦻  Failed'; + setTimeout(update, 3000); + } + }, body, contentType); +} + +/****************************************************/ + +function makeView(hosts) { + let parent = $("bulletin"); + + if (! hosts) { + parent.innerHTML = '

Error

'; + return; + } + + if (hosts.length == 0) { + parent.innerHTML = '

No Hosts

'; + return; + } + + parent.innerHTML = ` +
+
+
+ `; + + for (const host of hosts) { + let child = document.createElement("div"); + child.setAttribute("name", host.name); + child.classList.add("host"); + + child.innerHTML = ` +
+
+ + ${host.name} +
+ +
+
+ `; + + let actions = child.querySelector(".remote-actions"); + for (const action of host.actions) { + let button = document.createElement("button"); + button.classList.add("remote-action"); + button.setAttribute("action", action); + button.onclick = () => { + child.setAttribute("state", "unknown"); + child.querySelector("span.state") + .innerHTML = '  ⇒  ' + + action[0] + action.slice(1).toLowerCase(); + actionPerform(host.name, action); + }; + actions.appendChild(button); + } + updateState(host, child); + } +} + +function updateState(host, child) { + let parent = $("hosts"); + let separator = $("separator"); + let element = child ?? $$$(`div.host[name='${host.name}']`)[0]; + + element.setAttribute("state", host.online ? 'online' : 'offline'); + element.setAttribute("last-seen", host.last_seen); + + if (host.online) { + parent.insertBefore(element, separator); + element.querySelector("span.state").innerHTML = ''; + } else { + separator.after(element); + updateOfflineTime(host.name); + } +} + +function updateOfflineTime(name) { + let attr = name ? `[name='${name}']` : ''; + + for (const element of $$$(`div.host${attr}[state='offline']`)) { + let timestamp = element.getAttribute("last-seen"); + let state = element.querySelector("span.state"); + + state.innerHTML = '  –  ' + fromNow(timestamp); + } +} + +function stateDiff(hosts) { + let diff = []; + + for (const host of hosts) { + if (! (host.name in prev_state) + || prev_state[host.name].online != host.online) + { + diff.push(host); + } + prev_state[host.name] = host; + } + + return diff; +} diff --git a/web/share/js/remote/relativeTime.js b/web/share/js/remote/relativeTime.js new file mode 100644 index 00000000..71b998a6 --- /dev/null +++ b/web/share/js/remote/relativeTime.js @@ -0,0 +1,32 @@ +"use strict"; + + +const RTF = new Intl.RelativeTimeFormat('en', { + numeric: "always", + style: "long" +}); + +const Time = { + year: 29030400, + month: 2419200, + week: 604800, + day: 86400, + hour: 3600, + minute: 60, + second: 1 +} + + +export function fromNow(timestamp) { + if (timestamp == 0) return "never seen"; + + let now = Date.now() / 1000; + let time = now - timestamp; + + for (const unit in Time) if (time >= Time[unit]) { + let value = Math.floor(time / Time[unit]); + return RTF.format(-value, unit); + } + + return "just now"; +}