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
*.swp
/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.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),

View File

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

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 .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()

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