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 ADDED
@@ -0,0 +1,6 @@
1
+ """cbrm — BERO HOST management. `Panel` drives the customer panel, `Console` its VNC console."""
2
+ from .panel import Panel, PanelError
3
+ from .console import Console
4
+
5
+ __all__ = ["Panel", "PanelError", "Console"]
6
+ __version__ = "0.1.0"
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}