cbrm 0.1.0__py3-none-any.whl
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- cbrm/__init__.py +6 -0
- cbrm/api.py +73 -0
- cbrm/cli.py +68 -0
- cbrm/client.py +41 -0
- cbrm/config.py +20 -0
- cbrm/console.py +82 -0
- cbrm/mcp.py +62 -0
- cbrm/panel.py +158 -0
- cbrm/service.py +66 -0
- cbrm-0.1.0.dist-info/METADATA +129 -0
- cbrm-0.1.0.dist-info/RECORD +18 -0
- cbrm-0.1.0.dist-info/WHEEL +4 -0
- cbrm-0.1.0.dist-info/entry_points.txt +3 -0
- cbrm-0.1.0.dist-info/licenses/LICENSE +2 -0
- cbrm-0.1.0.dist-info/licenses/LICENSE.md +17 -0
- cbrm-0.1.0.dist-info/licenses/LICENSES/GPL-3.0-only.txt +232 -0
- cbrm-0.1.0.dist-info/licenses/LICENSES/LGPL-3.0-linking-exception.txt +16 -0
- cbrm-0.1.0.dist-info/licenses/LICENSES/LGPL-3.0-only.txt +304 -0
cbrm/__init__.py
ADDED
cbrm/api.py
ADDED
|
@@ -0,0 +1,73 @@
|
|
|
1
|
+
"""HTTP API (FastAPI; OpenAPI at /openapi.json, docs at /docs). It holds the panel credential, so CLIs
|
|
2
|
+
and MCP servers elsewhere only need the API URL and its token. Every route needs the bearer token."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import os
|
|
6
|
+
import secrets
|
|
7
|
+
|
|
8
|
+
from fastapi import Depends, FastAPI, HTTPException, Request
|
|
9
|
+
from fastapi.responses import JSONResponse, Response
|
|
10
|
+
from fastapi.security import HTTPAuthorizationCredentials, HTTPBearer
|
|
11
|
+
from pydantic import BaseModel
|
|
12
|
+
|
|
13
|
+
from . import __version__
|
|
14
|
+
from .panel import PanelError
|
|
15
|
+
from .service import Service, ServiceError
|
|
16
|
+
|
|
17
|
+
_bearer = HTTPBearer()
|
|
18
|
+
|
|
19
|
+
|
|
20
|
+
def _auth(cred: HTTPAuthorizationCredentials = Depends(_bearer)) -> None:
|
|
21
|
+
token = os.environ.get("CBRM_API_TOKEN", "")
|
|
22
|
+
if not token or not secrets.compare_digest(cred.credentials, token):
|
|
23
|
+
raise HTTPException(401, "bad token")
|
|
24
|
+
|
|
25
|
+
|
|
26
|
+
class PowerRequest(BaseModel):
|
|
27
|
+
action: str
|
|
28
|
+
confirm: str
|
|
29
|
+
|
|
30
|
+
|
|
31
|
+
class ConsoleInput(BaseModel):
|
|
32
|
+
text: str = ""
|
|
33
|
+
keys: list[str] = []
|
|
34
|
+
confirm: str
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
def create_app(service: Service) -> FastAPI:
|
|
38
|
+
app = FastAPI(title="cbrm", version=__version__, dependencies=[Depends(_auth)],
|
|
39
|
+
description="BERO HOST management: panel and VNC console.")
|
|
40
|
+
|
|
41
|
+
@app.exception_handler(ServiceError)
|
|
42
|
+
async def refused(_: Request, e: ServiceError):
|
|
43
|
+
return JSONResponse({"detail": str(e)}, status_code=403)
|
|
44
|
+
|
|
45
|
+
@app.exception_handler(PanelError)
|
|
46
|
+
async def panel(_: Request, e: PanelError):
|
|
47
|
+
return JSONResponse({"detail": str(e)}, status_code=502)
|
|
48
|
+
|
|
49
|
+
@app.get("/vms")
|
|
50
|
+
def vms() -> list[dict]:
|
|
51
|
+
return service.vms()
|
|
52
|
+
|
|
53
|
+
@app.get("/vms/{vm}")
|
|
54
|
+
def status(vm: str) -> dict:
|
|
55
|
+
return service.status(vm)
|
|
56
|
+
|
|
57
|
+
@app.get("/vms/{vm}/logs")
|
|
58
|
+
def logs(vm: str, n: int = 20) -> list[dict]:
|
|
59
|
+
return service.logs(vm, n)
|
|
60
|
+
|
|
61
|
+
@app.get("/vms/{vm}/console.png", response_class=Response)
|
|
62
|
+
def console(vm: str) -> Response:
|
|
63
|
+
return Response(service.console_png(vm), media_type="image/png")
|
|
64
|
+
|
|
65
|
+
@app.post("/vms/{vm}/power")
|
|
66
|
+
def power(vm: str, body: PowerRequest) -> dict:
|
|
67
|
+
return service.power(vm, body.action, body.confirm)
|
|
68
|
+
|
|
69
|
+
@app.post("/vms/{vm}/console/input")
|
|
70
|
+
def console_input(vm: str, body: ConsoleInput) -> dict:
|
|
71
|
+
return service.console_input(vm, body.text, body.keys, body.confirm)
|
|
72
|
+
|
|
73
|
+
return app
|
cbrm/cli.py
ADDED
|
@@ -0,0 +1,68 @@
|
|
|
1
|
+
"""cbrm command line: the same operations as the API and the MCP server (see cbrm.config for setup)."""
|
|
2
|
+
from __future__ import annotations
|
|
3
|
+
|
|
4
|
+
import argparse
|
|
5
|
+
import json
|
|
6
|
+
import os
|
|
7
|
+
import sys
|
|
8
|
+
|
|
9
|
+
from .panel import POWER_ACTIONS, PanelError
|
|
10
|
+
from .service import ServiceError
|
|
11
|
+
|
|
12
|
+
|
|
13
|
+
def main(argv: list[str] | None = None) -> None:
|
|
14
|
+
p = argparse.ArgumentParser(prog="cbrm", description="BERO HOST management: panel and VNC console.")
|
|
15
|
+
p.add_argument("--json", action="store_true", help="machine-readable output")
|
|
16
|
+
sub = p.add_subparsers(dest="cmd", required=True)
|
|
17
|
+
sub.add_parser("vms", help="all VMs of the account")
|
|
18
|
+
sub.add_parser("status").add_argument("vm")
|
|
19
|
+
s = sub.add_parser("logs"); s.add_argument("vm"); s.add_argument("-n", type=int, default=20)
|
|
20
|
+
s = sub.add_parser("console", help="screenshot of the VNC console"); s.add_argument("vm"); s.add_argument("out")
|
|
21
|
+
s = sub.add_parser("power", help="changes the VM"); s.add_argument("vm"); s.add_argument("action", choices=POWER_ACTIONS)
|
|
22
|
+
s.add_argument("--confirm", required=True, metavar="VM", help="repeat the target VM")
|
|
23
|
+
s = sub.add_parser("input", help="type text and/or press keys on the console (changes the VM)")
|
|
24
|
+
s.add_argument("vm"); s.add_argument("--text", default=""); s.add_argument("--key", action="append", default=[],
|
|
25
|
+
help="vncdotool key name, repeatable")
|
|
26
|
+
s.add_argument("--confirm", required=True, metavar="VM", help="repeat the target VM")
|
|
27
|
+
s = sub.add_parser("serve", help="run the HTTP API (needs CBRM_API_TOKEN)")
|
|
28
|
+
s.add_argument("--host", default="127.0.0.1"); s.add_argument("--port", type=int, default=8765)
|
|
29
|
+
a = p.parse_args(argv)
|
|
30
|
+
|
|
31
|
+
def out(obj, text: str) -> None:
|
|
32
|
+
print(json.dumps(obj, ensure_ascii=False) if a.json else text)
|
|
33
|
+
|
|
34
|
+
if a.cmd == "serve":
|
|
35
|
+
import uvicorn
|
|
36
|
+
from .api import create_app
|
|
37
|
+
from .config import credentials
|
|
38
|
+
from .panel import Panel
|
|
39
|
+
from .service import Service
|
|
40
|
+
if not os.environ.get("CBRM_API_TOKEN"):
|
|
41
|
+
sys.exit("cbrm: serve needs CBRM_API_TOKEN")
|
|
42
|
+
uvicorn.run(create_app(Service(Panel(*credentials()))), host=a.host, port=a.port)
|
|
43
|
+
return
|
|
44
|
+
|
|
45
|
+
from .client import backend
|
|
46
|
+
b = backend()
|
|
47
|
+
try:
|
|
48
|
+
if a.cmd == "vms":
|
|
49
|
+
rows = b.vms()
|
|
50
|
+
out(rows, "\n".join(f"{r['vm']} {r['state']} {r.get('ipv4', '')} {r.get('system', '')}" for r in rows))
|
|
51
|
+
elif a.cmd == "status":
|
|
52
|
+
s = b.status(a.vm); out(s, "\n".join(f"{k}: {v}" for k, v in s.items()))
|
|
53
|
+
elif a.cmd == "logs":
|
|
54
|
+
e = b.logs(a.vm, a.n); out(e, "\n".join(f"{x['date']} {x['action']}" for x in e))
|
|
55
|
+
elif a.cmd == "console":
|
|
56
|
+
with open(a.out, "wb") as f:
|
|
57
|
+
f.write(b.console_png(a.vm))
|
|
58
|
+
out({"file": a.out}, a.out)
|
|
59
|
+
elif a.cmd == "power":
|
|
60
|
+
r = b.power(a.vm, a.action, a.confirm); out(r, r.get("message", json.dumps(r)))
|
|
61
|
+
elif a.cmd == "input":
|
|
62
|
+
out(b.console_input(a.vm, a.text, a.key, a.confirm), "sent")
|
|
63
|
+
except (ServiceError, PanelError) as e:
|
|
64
|
+
sys.exit(f"cbrm: {e}")
|
|
65
|
+
|
|
66
|
+
|
|
67
|
+
if __name__ == "__main__":
|
|
68
|
+
main()
|
cbrm/client.py
ADDED
|
@@ -0,0 +1,41 @@
|
|
|
1
|
+
"""The Service interface over the HTTP API, so the CLI and the MCP server work the same whether they
|
|
2
|
+
run next to the credential (in-process Service) or against a remote `cbrm serve` (CBRM_API_URL)."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import httpx
|
|
6
|
+
|
|
7
|
+
from .service import ServiceError
|
|
8
|
+
|
|
9
|
+
|
|
10
|
+
class Client:
|
|
11
|
+
def __init__(self, url: str, token: str):
|
|
12
|
+
self.http = httpx.Client(base_url=url.rstrip("/"), headers={"Authorization": f"Bearer {token}"},
|
|
13
|
+
timeout=120)
|
|
14
|
+
|
|
15
|
+
def _do(self, method: str, path: str, **kw):
|
|
16
|
+
r = self.http.request(method, path, **kw)
|
|
17
|
+
if r.status_code == 403:
|
|
18
|
+
raise ServiceError(r.json().get("detail", "refused"))
|
|
19
|
+
r.raise_for_status()
|
|
20
|
+
return r
|
|
21
|
+
|
|
22
|
+
def vms(self): return self._do("GET", "/vms").json()
|
|
23
|
+
def status(self, vm): return self._do("GET", f"/vms/{vm}").json()
|
|
24
|
+
def logs(self, vm, n=20): return self._do("GET", f"/vms/{vm}/logs", params={"n": n}).json()
|
|
25
|
+
def console_png(self, vm): return self._do("GET", f"/vms/{vm}/console.png").content
|
|
26
|
+
def power(self, vm, action, confirm):
|
|
27
|
+
return self._do("POST", f"/vms/{vm}/power", json={"action": action, "confirm": confirm}).json()
|
|
28
|
+
def console_input(self, vm, text="", keys=None, confirm=""):
|
|
29
|
+
return self._do("POST", f"/vms/{vm}/console/input",
|
|
30
|
+
json={"text": text, "keys": keys or [], "confirm": confirm}).json()
|
|
31
|
+
|
|
32
|
+
|
|
33
|
+
def backend():
|
|
34
|
+
"""CBRM_API_URL (+ CBRM_API_TOKEN) -> remote API client; otherwise the in-process Service."""
|
|
35
|
+
import os
|
|
36
|
+
if os.environ.get("CBRM_API_URL"):
|
|
37
|
+
return Client(os.environ["CBRM_API_URL"], os.environ.get("CBRM_API_TOKEN", ""))
|
|
38
|
+
from .config import credentials
|
|
39
|
+
from .panel import Panel
|
|
40
|
+
from .service import Service
|
|
41
|
+
return Service(Panel(*credentials()))
|
cbrm/config.py
ADDED
|
@@ -0,0 +1,20 @@
|
|
|
1
|
+
"""Configuration from the environment.
|
|
2
|
+
CBRM_USER + CBRM_PASSWORD, or CBRM_CREDENTIALS_CMD (prints user and password on two lines): the panel login.
|
|
3
|
+
CBRM_VMS: space-separated allow-list. CBRM_API_URL + CBRM_API_TOKEN: use a remote `cbrm serve` instead."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import os
|
|
7
|
+
import shlex
|
|
8
|
+
import subprocess
|
|
9
|
+
|
|
10
|
+
|
|
11
|
+
def credentials() -> tuple[str, str]:
|
|
12
|
+
if os.environ.get("CBRM_USER") and os.environ.get("CBRM_PASSWORD"):
|
|
13
|
+
return os.environ["CBRM_USER"], os.environ["CBRM_PASSWORD"]
|
|
14
|
+
cmd = os.environ.get("CBRM_CREDENTIALS_CMD")
|
|
15
|
+
if not cmd:
|
|
16
|
+
raise SystemExit("cbrm: set CBRM_USER/CBRM_PASSWORD, CBRM_CREDENTIALS_CMD, or CBRM_API_URL")
|
|
17
|
+
lines = subprocess.run(shlex.split(cmd), capture_output=True, text=True, check=True).stdout.splitlines()
|
|
18
|
+
if len(lines) < 2:
|
|
19
|
+
raise SystemExit("cbrm: CBRM_CREDENTIALS_CMD must print the user and the password on two lines")
|
|
20
|
+
return lines[0], lines[1]
|
cbrm/console.py
ADDED
|
@@ -0,0 +1,82 @@
|
|
|
1
|
+
"""The panel's VNC console: a Proxmox noVNC websocket. A local TCP port is bridged to that websocket
|
|
2
|
+
so a normal VNC client (vncdotool) can capture the screen and send keys."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import asyncio
|
|
6
|
+
import atexit
|
|
7
|
+
import ssl
|
|
8
|
+
import threading
|
|
9
|
+
|
|
10
|
+
import websockets
|
|
11
|
+
from vncdotool import api as vnc
|
|
12
|
+
|
|
13
|
+
# vncdotool runs one Twisted reactor per process and cannot restart it: stop it once, at exit,
|
|
14
|
+
# so long-running servers (API, MCP) can open many console sessions.
|
|
15
|
+
atexit.register(vnc.shutdown)
|
|
16
|
+
|
|
17
|
+
|
|
18
|
+
class Console:
|
|
19
|
+
"""Use as a context manager: `with Console(host, port, ticket) as c: c.capture("out.png")`."""
|
|
20
|
+
|
|
21
|
+
def __init__(self, host: str, port: int, password: str):
|
|
22
|
+
self.url, self.password = f"wss://{host}:{port}/websockify", password
|
|
23
|
+
self._loop = asyncio.new_event_loop()
|
|
24
|
+
self._thread = threading.Thread(target=self._loop.run_forever, daemon=True)
|
|
25
|
+
self.client = None
|
|
26
|
+
|
|
27
|
+
async def _bridge(self, reader: asyncio.StreamReader, writer: asyncio.StreamWriter) -> None:
|
|
28
|
+
async with websockets.connect(self.url, subprotocols=["binary"], ssl=ssl.create_default_context(),
|
|
29
|
+
max_size=None) as ws:
|
|
30
|
+
async def up():
|
|
31
|
+
while data := await reader.read(65536):
|
|
32
|
+
await ws.send(data)
|
|
33
|
+
async def down():
|
|
34
|
+
async for msg in ws:
|
|
35
|
+
writer.write(msg if isinstance(msg, bytes) else msg.encode())
|
|
36
|
+
await writer.drain()
|
|
37
|
+
await asyncio.wait([asyncio.ensure_future(up()), asyncio.ensure_future(down())],
|
|
38
|
+
return_when=asyncio.FIRST_COMPLETED)
|
|
39
|
+
writer.close()
|
|
40
|
+
|
|
41
|
+
def __enter__(self) -> "Console":
|
|
42
|
+
self._thread.start()
|
|
43
|
+
self._server = asyncio.run_coroutine_threadsafe(
|
|
44
|
+
asyncio.start_server(self._bridge, "127.0.0.1", 0), self._loop).result(10)
|
|
45
|
+
port = self._server.sockets[0].getsockname()[1]
|
|
46
|
+
self.client = vnc.connect(f"127.0.0.1::{port}", password=self.password, timeout=30)
|
|
47
|
+
return self
|
|
48
|
+
|
|
49
|
+
def __exit__(self, *exc) -> None:
|
|
50
|
+
try:
|
|
51
|
+
if self.client:
|
|
52
|
+
self.client.disconnect()
|
|
53
|
+
finally:
|
|
54
|
+
asyncio.run_coroutine_threadsafe(self._close(), self._loop).result(10)
|
|
55
|
+
self._loop.call_soon_threadsafe(self._loop.stop)
|
|
56
|
+
self._thread.join(5)
|
|
57
|
+
|
|
58
|
+
async def _close(self) -> None:
|
|
59
|
+
self._server.close()
|
|
60
|
+
tasks = [t for t in asyncio.all_tasks() if t is not asyncio.current_task()]
|
|
61
|
+
for t in tasks:
|
|
62
|
+
t.cancel()
|
|
63
|
+
await asyncio.gather(*tasks, return_exceptions=True)
|
|
64
|
+
|
|
65
|
+
def capture(self, path: str) -> str:
|
|
66
|
+
self.client.captureScreen(path)
|
|
67
|
+
return path
|
|
68
|
+
|
|
69
|
+
def png(self) -> bytes:
|
|
70
|
+
import io
|
|
71
|
+
self.client.refreshScreen()
|
|
72
|
+
buf = io.BytesIO()
|
|
73
|
+
self.client.screen.save(buf, "PNG")
|
|
74
|
+
return buf.getvalue()
|
|
75
|
+
|
|
76
|
+
def type(self, text: str) -> None:
|
|
77
|
+
for ch in text:
|
|
78
|
+
self.client.keyPress(ch)
|
|
79
|
+
|
|
80
|
+
def keys(self, *names: str) -> None:
|
|
81
|
+
for name in names: # vncdotool key names: enter, ctrl-c, alt-f2, f1, ...
|
|
82
|
+
self.client.keyPress(name)
|
cbrm/mcp.py
ADDED
|
@@ -0,0 +1,62 @@
|
|
|
1
|
+
"""MCP server (stdio) for AI clients. Same operations and rules as the CLI and the API; with
|
|
2
|
+
CBRM_API_URL set it is a client of a remote `cbrm serve` and never sees the panel credential."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
from mcp.server.fastmcp import FastMCP, Image
|
|
6
|
+
|
|
7
|
+
from .client import backend
|
|
8
|
+
|
|
9
|
+
mcp = FastMCP("cbrm", instructions=(
|
|
10
|
+
"Manage BERO HOST (bero-host.de) KVM rootservers through the customer panel and the VNC console. "
|
|
11
|
+
"Read first (bero_vms, bero_status, bero_logs, bero_console). Changing tools need confirm=<vm>. "
|
|
12
|
+
"`system` in the status is the last template BERO installed, not necessarily what runs. "
|
|
13
|
+
"Reinstall, backups, root password, network reset and billing are deliberately not available."))
|
|
14
|
+
_b = None
|
|
15
|
+
|
|
16
|
+
|
|
17
|
+
def b():
|
|
18
|
+
global _b
|
|
19
|
+
if _b is None:
|
|
20
|
+
_b = backend()
|
|
21
|
+
return _b
|
|
22
|
+
|
|
23
|
+
|
|
24
|
+
@mcp.tool()
|
|
25
|
+
def bero_vms() -> list[dict]:
|
|
26
|
+
"""All KVM rootservers of the account: state, addresses, last installed template."""
|
|
27
|
+
return b().vms()
|
|
28
|
+
|
|
29
|
+
|
|
30
|
+
@mcp.tool()
|
|
31
|
+
def bero_status(vm: str) -> dict:
|
|
32
|
+
"""Overview of one VM (state, hostname, addresses, last backup)."""
|
|
33
|
+
return b().status(vm)
|
|
34
|
+
|
|
35
|
+
|
|
36
|
+
@mcp.tool()
|
|
37
|
+
def bero_logs(vm: str, n: int = 20) -> list[dict]:
|
|
38
|
+
"""The panel's action log of a VM, newest first, in panel time (Europe/Berlin)."""
|
|
39
|
+
return b().logs(vm, n)
|
|
40
|
+
|
|
41
|
+
|
|
42
|
+
@mcp.tool()
|
|
43
|
+
def bero_console(vm: str) -> Image:
|
|
44
|
+
"""A screenshot of the VM's VNC console (what a monitor would show)."""
|
|
45
|
+
return Image(data=b().console_png(vm), format="png")
|
|
46
|
+
|
|
47
|
+
|
|
48
|
+
@mcp.tool()
|
|
49
|
+
def bero_power(vm: str, action: str, confirm: str) -> dict:
|
|
50
|
+
"""Power action on a VM: start, stop, shutdown or restart. confirm must repeat the VM id."""
|
|
51
|
+
return b().power(vm, action, confirm)
|
|
52
|
+
|
|
53
|
+
|
|
54
|
+
@mcp.tool()
|
|
55
|
+
def bero_console_input(vm: str, confirm: str, text: str = "", keys: list[str] | None = None) -> dict:
|
|
56
|
+
"""Type text, then press keys (vncdotool names: enter, ctrl-c, f1, ...) on the VNC console.
|
|
57
|
+
confirm must repeat the VM id. Look at bero_console before and after."""
|
|
58
|
+
return b().console_input(vm, text, keys, confirm)
|
|
59
|
+
|
|
60
|
+
|
|
61
|
+
def main() -> None:
|
|
62
|
+
mcp.run()
|
cbrm/panel.py
ADDED
|
@@ -0,0 +1,158 @@
|
|
|
1
|
+
"""The BERO HOST customer panel (bero-host.de) has no API. This module speaks the panel's own HTTP:
|
|
2
|
+
a form login, server-rendered pages, one Livewire request for the action log, and the JSON endpoints
|
|
3
|
+
its power buttons call. Only reading and power exist here; everything else stays in the panel."""
|
|
4
|
+
from __future__ import annotations
|
|
5
|
+
|
|
6
|
+
import json
|
|
7
|
+
import os
|
|
8
|
+
import re
|
|
9
|
+
from dataclasses import dataclass
|
|
10
|
+
from pathlib import Path
|
|
11
|
+
|
|
12
|
+
import httpx
|
|
13
|
+
from selectolax.lexbor import LexborHTMLParser as HTMLParser
|
|
14
|
+
|
|
15
|
+
BASE = "https://bero-host.de"
|
|
16
|
+
POWER_ACTIONS = ("start", "stop", "shutdown", "restart")
|
|
17
|
+
|
|
18
|
+
|
|
19
|
+
class PanelError(RuntimeError):
|
|
20
|
+
pass
|
|
21
|
+
|
|
22
|
+
|
|
23
|
+
@dataclass
|
|
24
|
+
class LogEntry:
|
|
25
|
+
date: str # panel time (Europe/Berlin), as the panel shows it: "11.10.2026 00:02"
|
|
26
|
+
action: str # e.g. "Rootserver installieren", "Neustarten"
|
|
27
|
+
|
|
28
|
+
|
|
29
|
+
def _lines(html: str) -> list[str]:
|
|
30
|
+
tree = HTMLParser(html)
|
|
31
|
+
for node in tree.css("script, style"):
|
|
32
|
+
node.decompose()
|
|
33
|
+
root = tree.body or tree.root
|
|
34
|
+
return [l.strip() for l in root.text(separator="\n").splitlines() if l.strip()]
|
|
35
|
+
|
|
36
|
+
|
|
37
|
+
class Panel:
|
|
38
|
+
"""A logged-in panel session. Cookies persist in `state_dir` (mode 0600) between runs."""
|
|
39
|
+
|
|
40
|
+
def __init__(self, user: str, password: str, state_dir: Path | None = None):
|
|
41
|
+
self._user, self._password = user, password
|
|
42
|
+
self.state_dir = state_dir or Path(os.environ.get("XDG_STATE_HOME", Path.home() / ".local/state")) / "cbrm"
|
|
43
|
+
self.state_dir.mkdir(parents=True, exist_ok=True, mode=0o700)
|
|
44
|
+
self._cookies = self.state_dir / "cookies.json"
|
|
45
|
+
jar = httpx.Cookies()
|
|
46
|
+
if self._cookies.exists():
|
|
47
|
+
for name, value in json.loads(self._cookies.read_text()).items():
|
|
48
|
+
jar.set(name, value, domain="bero-host.de")
|
|
49
|
+
self.http = httpx.Client(base_url=BASE, cookies=jar, timeout=60,
|
|
50
|
+
headers={"User-Agent": "cbrm (+https://git.corbet.ch/corbet-infra/cbrm)"})
|
|
51
|
+
self._products: dict[str, str] | None = None
|
|
52
|
+
|
|
53
|
+
# -- session -----------------------------------------------------------------------------
|
|
54
|
+
def _save(self) -> None:
|
|
55
|
+
fd = os.open(self._cookies, os.O_WRONLY | os.O_CREAT | os.O_TRUNC, 0o600) # never readable by others
|
|
56
|
+
with os.fdopen(fd, "w") as f:
|
|
57
|
+
json.dump({c.name: c.value for c in self.http.cookies.jar}, f)
|
|
58
|
+
|
|
59
|
+
def _login(self) -> None:
|
|
60
|
+
form = HTMLParser(self.http.get("/login").text).css_first("form[action$='/login'] input[name=_token]")
|
|
61
|
+
if form is None:
|
|
62
|
+
raise PanelError("login form not found")
|
|
63
|
+
r = self.http.post("/login", data={"_token": form.attributes["value"], "email": self._user,
|
|
64
|
+
"password": self._password})
|
|
65
|
+
if r.status_code != 302 or "/login" in r.headers.get("location", ""):
|
|
66
|
+
raise PanelError("login failed")
|
|
67
|
+
self._save()
|
|
68
|
+
|
|
69
|
+
def page(self, path: str) -> str:
|
|
70
|
+
"""GET a panel page, logging in again when the session has expired."""
|
|
71
|
+
r = self.http.get(path)
|
|
72
|
+
if r.status_code == 302 and "/login" in r.headers.get("location", ""):
|
|
73
|
+
self._login()
|
|
74
|
+
r = self.http.get(path)
|
|
75
|
+
r.raise_for_status()
|
|
76
|
+
return r.text
|
|
77
|
+
|
|
78
|
+
# -- reading -----------------------------------------------------------------------------
|
|
79
|
+
def products(self) -> dict[str, str]:
|
|
80
|
+
"""VM id -> product id, for every KVM rootserver in the account."""
|
|
81
|
+
if self._products is None:
|
|
82
|
+
ids = sorted(set(re.findall(r"/customer/products/(\d+)", self.page("/customer"))))
|
|
83
|
+
self._products = {}
|
|
84
|
+
for pid in ids:
|
|
85
|
+
title = HTMLParser(self.page(f"/customer/products/{pid}")).css_first("title").text()
|
|
86
|
+
m = re.search(r"#(\d+) \(KVM Rootserver #%s\)" % pid, title)
|
|
87
|
+
if m:
|
|
88
|
+
self._products[m.group(1)] = pid
|
|
89
|
+
return self._products
|
|
90
|
+
|
|
91
|
+
def _product_page(self, vm: str) -> str:
|
|
92
|
+
pid = self.products().get(vm)
|
|
93
|
+
if pid is None:
|
|
94
|
+
raise PanelError(f"VM {vm} is not in this account")
|
|
95
|
+
return self.page(f"/customer/products/{pid}")
|
|
96
|
+
|
|
97
|
+
def status(self, vm: str) -> dict[str, str]:
|
|
98
|
+
"""Overview fields. `system` is the last template BERO installed, not what is running now."""
|
|
99
|
+
lines = _lines(self._product_page(vm))
|
|
100
|
+
i = next(k for k, l in enumerate(lines) if l.startswith("KVM Rootserver #"))
|
|
101
|
+
out = {"vm": vm, "state": lines[i + 1]}
|
|
102
|
+
names = {"Hostname": "hostname", "Installiertes System": "system", "Haupt IP-Adressen": "ipv4"}
|
|
103
|
+
for k in range(i, min(len(lines) - 2, i + 80)):
|
|
104
|
+
if lines[k] in names:
|
|
105
|
+
out[names[lines[k]]] = lines[k + 1]
|
|
106
|
+
if lines[k] == "Haupt IP-Adressen":
|
|
107
|
+
out["ipv6"] = lines[k + 2]
|
|
108
|
+
if lines[k].startswith("Letztes Backup"):
|
|
109
|
+
out["last_backup"] = lines[k].split(":", 1)[1].strip() or lines[k + 1]
|
|
110
|
+
break
|
|
111
|
+
return out
|
|
112
|
+
|
|
113
|
+
def logs(self, vm: str) -> list[LogEntry]:
|
|
114
|
+
"""The panel's action log, newest first. It is a lazy Livewire component: replay its
|
|
115
|
+
__lazyLoad call with the component's own snapshot."""
|
|
116
|
+
html = self._product_page(vm)
|
|
117
|
+
tree = HTMLParser(html)
|
|
118
|
+
csrf = tree.css_first("meta[name=csrf-token]").attributes["content"]
|
|
119
|
+
uri = tree.css_first("[data-update-uri]").attributes["data-update-uri"]
|
|
120
|
+
for node in tree.css("[wire\\:snapshot][x-intersect]"):
|
|
121
|
+
snapshot = node.attributes["wire:snapshot"]
|
|
122
|
+
if json.loads(snapshot)["memo"]["name"].endswith(".logs-table"):
|
|
123
|
+
param = re.search(r"__lazyLoad\('([^']+)'\)", node.attributes["x-intersect"]).group(1)
|
|
124
|
+
break
|
|
125
|
+
else:
|
|
126
|
+
raise PanelError("log component not found")
|
|
127
|
+
r = self.http.post(uri, headers={"X-Livewire": "true", "X-CSRF-TOKEN": csrf}, json={
|
|
128
|
+
"_token": csrf, "components": [{"snapshot": snapshot, "updates": {},
|
|
129
|
+
"calls": [{"path": "", "method": "__lazyLoad", "params": [param]}]}]})
|
|
130
|
+
r.raise_for_status()
|
|
131
|
+
# Rows read "Datum : 11.10.2026 | 00:02 Uhr Aktion: Starten"; line breaks vary with the parser.
|
|
132
|
+
lines = _lines(r.json()["components"][0]["effects"]["html"])
|
|
133
|
+
entries, date = [], None
|
|
134
|
+
for k, line in enumerate(lines):
|
|
135
|
+
if m := re.search(r"(\d{2}\.\d{2}\.\d{4}) \| (\d{2}:\d{2})", line):
|
|
136
|
+
date = f"{m.group(1)} {m.group(2)}"
|
|
137
|
+
elif line.startswith("Aktion:") and date and k + 1 < len(lines):
|
|
138
|
+
entries.append(LogEntry(date, line[len("Aktion:"):].strip() or lines[k + 1]))
|
|
139
|
+
date = None
|
|
140
|
+
return entries
|
|
141
|
+
|
|
142
|
+
def console_session(self, vm: str) -> tuple[str, int, str]:
|
|
143
|
+
"""(host, port, password) of a fresh noVNC session. The password is a Proxmox VNC ticket."""
|
|
144
|
+
src = HTMLParser(self.page(f"/customer/rootserver/{vm}/vnc")).css_first("iframe").attributes["src"]
|
|
145
|
+
q = httpx.URL(src).params
|
|
146
|
+
if not (q.get("host") and q.get("port") and q.get("password")):
|
|
147
|
+
raise PanelError(f"no console session offered for {vm}")
|
|
148
|
+
return q["host"], int(q["port"]), q["password"]
|
|
149
|
+
|
|
150
|
+
# -- changing ----------------------------------------------------------------------------
|
|
151
|
+
def power(self, vm: str, action: str) -> dict:
|
|
152
|
+
if action not in POWER_ACTIONS:
|
|
153
|
+
raise PanelError(f"power action must be one of {POWER_ACTIONS}")
|
|
154
|
+
self._product_page(vm) # also proves the VM belongs to this account
|
|
155
|
+
r = self.http.get(f"/customer/rootserver/{vm}/{action}",
|
|
156
|
+
headers={"X-Requested-With": "XMLHttpRequest", "Accept": "application/json"})
|
|
157
|
+
r.raise_for_status()
|
|
158
|
+
return r.json()
|
cbrm/service.py
ADDED
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
"""The operations every interface offers (CLI, HTTP API, MCP), with the rules in one place:
|
|
2
|
+
the allow-list, the repeated target for anything that changes a VM, and the audit log."""
|
|
3
|
+
from __future__ import annotations
|
|
4
|
+
|
|
5
|
+
import datetime
|
|
6
|
+
import os
|
|
7
|
+
from pathlib import Path
|
|
8
|
+
|
|
9
|
+
from .panel import POWER_ACTIONS, Panel
|
|
10
|
+
|
|
11
|
+
|
|
12
|
+
class ServiceError(ValueError):
|
|
13
|
+
"""The request is refused (unknown VM, missing confirmation, ...)."""
|
|
14
|
+
|
|
15
|
+
|
|
16
|
+
class Service:
|
|
17
|
+
def __init__(self, panel: Panel, allow: list[str] | None = None, audit: Path | None = None):
|
|
18
|
+
self.panel = panel
|
|
19
|
+
self.allow = allow if allow is not None else os.environ.get("CBRM_VMS", "").split()
|
|
20
|
+
self.audit_path = audit or panel.state_dir / "audit.log"
|
|
21
|
+
|
|
22
|
+
def _audit(self, line: str) -> None:
|
|
23
|
+
fd = os.open(self.audit_path, os.O_WRONLY | os.O_CREAT | os.O_APPEND, 0o600)
|
|
24
|
+
with os.fdopen(fd, "a") as f:
|
|
25
|
+
f.write(f"{datetime.datetime.now(datetime.timezone.utc):%Y-%m-%dT%H:%M:%SZ} {line}\n")
|
|
26
|
+
|
|
27
|
+
def _vm(self, vm: str, confirm: str | None = None, changing: bool = False) -> str:
|
|
28
|
+
if self.allow and vm not in self.allow:
|
|
29
|
+
raise ServiceError(f"VM {vm} is not in the allow-list")
|
|
30
|
+
if changing and confirm != vm:
|
|
31
|
+
raise ServiceError(f"this changes VM {vm}: repeat it as confirm={vm}")
|
|
32
|
+
return vm
|
|
33
|
+
|
|
34
|
+
# reading
|
|
35
|
+
def vms(self) -> list[dict]:
|
|
36
|
+
return [self.panel.status(v) for v in sorted(self.panel.products()) if not self.allow or v in self.allow]
|
|
37
|
+
|
|
38
|
+
def status(self, vm: str) -> dict:
|
|
39
|
+
return self.panel.status(self._vm(vm))
|
|
40
|
+
|
|
41
|
+
def logs(self, vm: str, n: int = 20) -> list[dict]:
|
|
42
|
+
return [e.__dict__ for e in self.panel.logs(self._vm(vm))[:n]]
|
|
43
|
+
|
|
44
|
+
def console_png(self, vm: str) -> bytes:
|
|
45
|
+
from .console import Console
|
|
46
|
+
with Console(*self.panel.console_session(self._vm(vm))) as c:
|
|
47
|
+
return c.png()
|
|
48
|
+
|
|
49
|
+
# changing
|
|
50
|
+
def power(self, vm: str, action: str, confirm: str) -> dict:
|
|
51
|
+
self._vm(vm, confirm, changing=True)
|
|
52
|
+
if action not in POWER_ACTIONS:
|
|
53
|
+
raise ServiceError(f"action must be one of {', '.join(POWER_ACTIONS)}")
|
|
54
|
+
self._audit(f"power {vm} {action} requested")
|
|
55
|
+
r = self.panel.power(vm, action)
|
|
56
|
+
self._audit(f"power {vm} {action} -> {r}")
|
|
57
|
+
return r
|
|
58
|
+
|
|
59
|
+
def console_input(self, vm: str, text: str = "", keys: list[str] | None = None, confirm: str = "") -> dict:
|
|
60
|
+
self._vm(vm, confirm, changing=True)
|
|
61
|
+
self._audit(f"console {vm} text={len(text)} chars keys={' '.join(keys or [])}")
|
|
62
|
+
from .console import Console
|
|
63
|
+
with Console(*self.panel.console_session(vm)) as c:
|
|
64
|
+
c.type(text)
|
|
65
|
+
c.keys(*(keys or []))
|
|
66
|
+
return {"sent": True}
|