This commit is contained in:
onysd 2026-07-28 02:21:10 +03:00
parent b39d35585e
commit 12b8b52ced

View file

@ -26,12 +26,13 @@ from dataclasses import dataclass
from pathlib import Path from pathlib import Path
import psutil import psutil
from cryptography.hazmat.primitives.serialization import Encoding, PublicFormat, load_pem_private_key
from textual import work from textual import work
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.binding import Binding from textual.binding import Binding
from textual.containers import Horizontal, Vertical from textual.containers import Horizontal, Vertical
from textual.screen import ModalScreen, Screen from textual.screen import Screen
from textual.widgets import Button, DataTable, Footer, Header, Label, OptionList, ProgressBar, RichLog, Static from textual.widgets import DataTable, Footer, Header, Label, OptionList, ProgressBar, RichLog, Static
from textual.widgets.option_list import Option from textual.widgets.option_list import Option
IS_WINDOWS = platform.system() == "Windows" IS_WINDOWS = platform.system() == "Windows"
@ -326,6 +327,40 @@ def admin_ui_info() -> tuple[str, str | None] | None:
return url, (read_env_value("TELESRV_ADMIN_UI_PASSWORD") or None) return url, (read_env_value("TELESRV_ADMIN_UI_PASSWORD") or None)
def server_address() -> str | None:
"""The MTProto address clients connect to (advertise IP + listen port).
Present as soon as .env is configured, even before the first Start."""
ip = read_env_value("TELESRV_ADVERTISE_IP")
listen = read_env_value("TELESRV_LISTEN")
if not ip or not listen:
return None
port = listen.rsplit(":", 1)[-1]
if not port.isdigit():
return None
return f"{ip}:{port}"
def server_public_key_pem() -> str | None:
"""The server's RSA public key (PKCS1 "RSA PUBLIC KEY" PEM, the format
patched into client builds), derived from the private key file. That
file is only generated the first time telesrv actually starts, so this
is legitimately absent on a never-started install -- and returns None
rather than raising on a missing/corrupt/unparseable file instead of
crashing the panel over it."""
key_path_value = read_env_value("TELESRV_RSA_KEY") or "data/server_rsa.pem"
key_path = Path(key_path_value)
if not key_path.is_absolute():
key_path = ROOT / key_path
if not key_path.exists():
return None
try:
private_key = load_pem_private_key(key_path.read_bytes(), password=None)
public_pem = private_key.public_key().public_bytes(Encoding.PEM, PublicFormat.PKCS1)
return public_pem.decode("ascii").strip()
except Exception: # noqa: BLE001 - any parse/format issue just means "unavailable"
return None
def copy_to_clipboard(text: str) -> bool: def copy_to_clipboard(text: str) -> bool:
if IS_WINDOWS: if IS_WINDOWS:
candidates = [["clip"]] candidates = [["clip"]]
@ -358,71 +393,32 @@ def system_stats() -> tuple[float, float, float]:
# --- UI ----------------------------------------------------------------- # --- UI -----------------------------------------------------------------
class PasswordSpoiler(Static): class CopyButton(Static):
"""Masks a password until clicked; the click handler (owned by whoever """A clickable label that copies `value` to the clipboard on click. The
creates this widget) decides whether/how to confirm before revealing.""" actual value is never shown on screen -- just the label and a "click to
copy" hint (optionally masked with dots for secret-looking fields, purely
cosmetic since nothing is ever displayed either way). value=None renders
as "not available" and isn't clickable -- used before the first Start,
when e.g. the RSA public key file doesn't exist yet."""
def __init__(self, password: str, on_click_callback, **kwargs): def __init__(self, label: str, value: str | None, on_copy_callback, *, masked: bool = False, **kwargs):
super().__init__(**kwargs) super().__init__(**kwargs)
self.password = password self.label = label
self.revealed = False self.value = value
self._on_click_callback = on_click_callback self.masked = masked
self._on_copy_callback = on_copy_callback
def on_mount(self) -> None: def on_mount(self) -> None:
self._render_masked() if self.value is None:
self.update(f"{self.label}: [dim]not available[/]")
def _render_masked(self) -> None: elif self.masked:
self.update("Password: [dim]" + "•" * 10 + "[/] [i](click to reveal)[/]") self.update(f"{self.label}: [dim]{'•' * 10}[/] [i](click to copy)[/]")
else:
def reveal(self) -> None: self.update(f"{self.label}: [dim](click to copy)[/]")
self.revealed = True
self.update(f"Password: [b green]{self.password}[/]")
def on_click(self, event) -> None: def on_click(self, event) -> None:
if not self.revealed: if self.value is not None:
self._on_click_callback(self) self._on_copy_callback(self)
class ConfirmRevealScreen(ModalScreen[bool]):
"""Yes/no confirmation before a spoiler is revealed."""
CSS = """
ConfirmRevealScreen {
align: center middle;
}
#confirm-dialog {
width: 56;
height: auto;
padding: 1 2;
border: round $warning;
background: $surface;
}
#confirm-dialog Label {
width: 100%;
content-align: center middle;
margin-bottom: 1;
}
#confirm-buttons {
align: center middle;
height: auto;
}
#confirm-buttons Button {
margin: 0 1;
}
"""
def compose(self) -> ComposeResult:
with Vertical(id="confirm-dialog"):
yield Label("Show the admin UI password?\nIt will also be copied to the clipboard.")
with Horizontal(id="confirm-buttons"):
yield Button("Yes, show it", id="yes", variant="warning")
yield Button("Cancel", id="no", variant="default")
def on_mount(self) -> None:
self.query_one("#no", Button).focus()
def on_button_pressed(self, event: Button.Pressed) -> None:
self.dismiss(event.button.id == "yes")
class LogTailScreen(Screen): class LogTailScreen(Screen):
@ -544,6 +540,9 @@ class MainScreen(Screen):
yield ProgressBar(total=100, id="ram-bar", show_eta=False) yield ProgressBar(total=100, id="ram-bar", show_eta=False)
yield Label("Disk", id="disk-label", classes="stat-label") yield Label("Disk", id="disk-label", classes="stat-label")
yield ProgressBar(total=100, id="disk-bar", show_eta=False) yield ProgressBar(total=100, id="disk-bar", show_eta=False)
yield Label("Server", classes="panel-title")
yield CopyButton("Address", server_address(), self._handle_copy_click, id="server-address")
yield CopyButton("Public key", server_public_key_pem(), self._handle_copy_click, masked=True, id="server-public-key")
yield Label("Admin UI", classes="panel-title") yield Label("Admin UI", classes="panel-title")
yield Static(id="admin-link") yield Static(id="admin-link")
yield self._admin_password_widget() yield self._admin_password_widget()
@ -560,20 +559,14 @@ class MainScreen(Screen):
info = admin_ui_info() info = admin_ui_info()
password = info[1] if info else None password = info[1] if info else None
if password: if password:
return PasswordSpoiler(password, self._handle_password_click, id="admin-password") return CopyButton("Password", password, self._handle_copy_click, masked=True, id="admin-password")
return Static("Password: [dim]not set (token auth)[/]", id="admin-password") return Static("Password: [dim]not set (token auth)[/]", id="admin-password")
def _handle_password_click(self, spoiler: PasswordSpoiler) -> None: def _handle_copy_click(self, widget: CopyButton) -> None:
def handle(confirmed: bool | None) -> None: if copy_to_clipboard(widget.value):
if not confirmed: self.notify(f"{widget.label} copied to clipboard")
return
spoiler.reveal()
if copy_to_clipboard(spoiler.password):
self.notify("Password revealed and copied to clipboard")
else: else:
self.notify("Password revealed (clipboard copy failed)", severity="warning") self.notify(f"{widget.label} copy failed", severity="warning")
self.app.push_screen(ConfirmRevealScreen(), handle)
def on_mount(self) -> None: def on_mount(self) -> None:
self.query_one("#services-table", DataTable).add_columns("Service", "Type", "Status") self.query_one("#services-table", DataTable).add_columns("Service", "Type", "Status")
@ -810,6 +803,7 @@ class ServerPanelApp(App):
height: 100%; height: 100%;
border: round $accent; border: round $accent;
padding: 1 2; padding: 1 2;
overflow-y: auto;
} }
.stat-label { .stat-label {
margin-top: 1; margin-top: 1;
@ -820,8 +814,9 @@ class ServerPanelApp(App):
#admin-link { #admin-link {
margin-top: 1; margin-top: 1;
} }
#admin-password { #admin-password, #server-address, #server-public-key {
margin-top: 1; margin-top: 1;
height: auto;
} }
LogTailScreen Horizontal { LogTailScreen Horizontal {
height: 1fr; height: 1fr;