feature: remote host control

This commit is contained in:
Break27 2023-10-07 16:55:05 +08:00
parent 988a190957
commit c536a1ac49
12 changed files with 637 additions and 2 deletions

1
.gitignore vendored
View File

@ -19,3 +19,4 @@
*.pyc *.pyc
*.swp *.swp
/venv/ /venv/
/.vscode/

View File

@ -0,0 +1,6 @@
name: Remote
description: Remote Hosts Control
daemon: kvmd
icon: share/svg/ipmi.svg
path: remote
place: 30

View File

@ -31,6 +31,8 @@ import pygments
import pygments.lexers.data import pygments.lexers.data
import pygments.formatters import pygments.formatters
from pathlib import Path
from .. import tools from .. import tools
from ..mouse import MouseRange from ..mouse import MouseRange
@ -163,6 +165,20 @@ def init(
"Make sure you understand exactly what you are doing!" "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) return (parser, remaining, config)
@ -348,6 +364,11 @@ def _get_config_scheme() -> dict:
"logging": Option({}), "logging": Option({}),
"kvmd": { "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": { "server": {
"unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"), "unix": Option("/run/kvmd/kvmd.sock", type=valid_abs_path, unpack_as="unix_path"),
"unix_rm": Option(True, type=valid_bool), "unix_rm": Option(True, type=valid_bool),

View File

@ -36,11 +36,12 @@ from .streamer import Streamer
from .snapshoter import Snapshoter from .snapshoter import Snapshoter
from .ocr import Ocr from .ocr import Ocr
from .server import KvmdServer from .server import KvmdServer
from .remote import RemoteControl
# ===== # =====
def main(argv: (list[str] | None)=None) -> None: def main(argv: (list[str] | None)=None) -> None:
config = init( _, _, config = init(
prog="kvmd", prog="kvmd",
description="The main PiKVM daemon", description="The main PiKVM daemon",
argv=argv, argv=argv,
@ -50,7 +51,7 @@ def main(argv: (list[str] | None)=None) -> None:
load_atx=True, load_atx=True,
load_msd=True, load_msd=True,
load_gpio=True, load_gpio=True,
)[2] )
msd_kwargs = config.kvmd.msd._unpack(ignore=["type"]) msd_kwargs = config.kvmd.msd._unpack(ignore=["type"])
if config.kvmd.msd.type == "otg": if config.kvmd.msd.type == "otg":
@ -102,6 +103,12 @@ def main(argv: (list[str] | None)=None) -> None:
**config.snapshot._unpack(), **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, keymap_path=config.hid.keymap,
ignore_keys=config.hid.ignore_keys, ignore_keys=config.hid.ignore_keys,
mouse_x_range=(config.hid.mouse_x_range.min, config.hid.mouse_x_range.max), mouse_x_range=(config.hid.mouse_x_range.min, config.hid.mouse_x_range.max),

View File

@ -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,
})

114
kvmd/apps/kvmd/remote.py Normal file
View File

@ -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)

View File

@ -70,6 +70,7 @@ from .ugpio import UserGpio
from .streamer import Streamer from .streamer import Streamer
from .snapshoter import Snapshoter from .snapshoter import Snapshoter
from .ocr import Ocr from .ocr import Ocr
from .remote import RemoteControl
from .api.auth import AuthApi from .api.auth import AuthApi
from .api.auth import check_request_auth from .api.auth import check_request_auth
@ -83,6 +84,7 @@ from .api.msd import MsdApi
from .api.streamer import StreamerApi from .api.streamer import StreamerApi
from .api.export import ExportApi from .api.export import ExportApi
from .api.redfish import RedfishApi 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, msd: BaseMsd,
streamer: Streamer, streamer: Streamer,
snapshoter: Snapshoter, snapshoter: Snapshoter,
remote: RemoteControl,
keymap_path: str, keymap_path: str,
ignore_keys: List[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("ATX", "atx_state", atx),
_Component("MSD", "msd_state", msd), _Component("MSD", "msd_state", msd),
_Component("Streamer", "streamer_state", streamer), _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, self.__streamer_api,
ExportApi(info_manager, atx, user_gpio), ExportApi(info_manager, atx, user_gpio),
RedfishApi(info_manager, atx), RedfishApi(info_manager, atx),
RemoteApi(remote),
] ]
self.__streamer_notifier = aiotools.AioNotifier() self.__streamer_notifier = aiotools.AioNotifier()

37
web/remote/index.html Normal file
View File

@ -0,0 +1,37 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8">
<title>Remote Control</title>
<link rel="apple-touch-icon" sizes="180x180" href="/share/apple-touch-icon.png">
<link rel="icon" type="image/png" sizes="32x32" href="/share/favicon-32x32.png">
<link rel="icon" type="image/png" sizes="16x16" href="/share/favicon-16x16.png">
<link rel="manifest" href="/share/site.webmanifest">
<link rel="mask-icon" href="/share/safari-pinned-tab.svg" color="#5bbad5">
<meta name="msapplication-TileColor" content="#2b5797">
<meta name="theme-color" content="#ffffff">
<link rel="stylesheet" href="/share/css/remote/remote.css">
<link rel="stylesheet" href="/share/css/remote/actions.css">
<link rel="stylesheet" href="/share/css/vars.css">
<link rel="stylesheet" href="/share/css/main.css">
<link rel="stylesheet" href="/share/css/start.css">
<link rel="stylesheet" href="/share/css/user.css">
<script type="module">import { main } from "/share/js/remote/main.js";
main();
</script>
</head>
<body>
<div class="start-box">
<div class="start">
<div id="actions">
<a class="link" href="/">&nbsp;&nbsp;&larr;&nbsp;&nbsp; [ PiKVM Index ]</a>
<div id="rf" class="link">[ Refresh ]<div class="icon">&circlearrowright;</div></div>
</div>
<hr>
<div id="bulletin">
<h4>Loading ...</h4>
</div>
</div>
</div>
</body>
</html>

View File

@ -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);
}

View File

@ -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);
}
}

192
web/share/js/remote/main.js Normal file
View File

@ -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 = '&nbsp;&nbsp;&olcross;&nbsp;&nbsp;Failed';
setTimeout(update, 3000);
}
}, body, contentType);
}
/****************************************************/
function makeView(hosts) {
let parent = $("bulletin");
if (! hosts) {
parent.innerHTML = '<h4>Error</h4>';
return;
}
if (hosts.length == 0) {
parent.innerHTML = '<h4>No Hosts</h4>';
return;
}
parent.innerHTML = `
<div id="hosts">
<div id="separator"></div>
</div>
`;
for (const host of hosts) {
let child = document.createElement("div");
child.setAttribute("name", host.name);
child.classList.add("host");
child.innerHTML = `
<div>
<div>
<span class="bulb"></span>
<span class="hostname">${host.name}</span>
</div>
<span class="state"></span>
</div><div class="remote-actions">
</div>
`;
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 = '&nbsp;&nbsp;&DoubleRightArrow;&nbsp;&nbsp;'
+ 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 = '&nbsp;&nbsp;&ndash;&nbsp;&nbsp;' + 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;
}

View File

@ -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";
}