#!/usr/bin/python3 """Standalone GTK application. Only the fixed polkit helper runs as root.""" import argparse import http.client import json import os from pathlib import Path import re import socket import subprocess import threading from urllib.parse import urlsplit import gi gi.require_version("Gtk", "3.0") gi.require_version("WebKit2", "4.1") from gi.repository import Gio, GLib, Gtk, WebKit2 ORIGIN = "http://127.0.0.1:8780" LOGIN = re.compile(r"http://127\.0\.0\.1:8780/#login=[A-Za-z0-9_-]{43}") def local_url(uri): try: u = urlsplit(uri) return (u.scheme, u.hostname, u.port) == ("http", "127.0.0.1", 8780) and not u.username and not u.password except ValueError: return False def authorize(development_socket=None): if development_socket: # Engineering-only, unprivileged service. Cannot read the deployed # service's protected Unix socket and never grants OS privileges. connection = http.client.HTTPConnection("local", timeout=5) connection.sock = socket.socket(socket.AF_UNIX) connection.sock.settimeout(5) try: connection.sock.connect(development_socket) connection.request("POST", "/login", headers={"Content-Type": "application/json"}) response = connection.getresponse() if response.status != 200: raise ValueError("Local authorization failed") uri = json.loads(response.read(1024))["url"] finally: connection.close() else: result = subprocess.run( ["/usr/bin/pkexec", "/usr/lib/mission-core-node/authorize"], check=True, capture_output=True, text=True, timeout=180, ) uri = result.stdout.strip() if not LOGIN.fullmatch(uri): raise ValueError("Unexpected launcher response") return uri class NodeApplication(Gtk.Application): def __init__(self, development_socket=None): super().__init__(application_id="org.nodedc.MissionCoreNode", flags=Gio.ApplicationFlags.FLAGS_NONE) self.development_socket = development_socket self.window = None self.pending = False self.initial_login = False self.cancelled_downloads = set() self.environment_timer = None self.environment_previous = None def do_activate(self): if self.window: self.window.present() return self.window = Gtk.ApplicationWindow(application=self) self.window.set_title("Mission Core Node") self.window.set_default_size(1100, 780) self.window.set_icon_name("org.nodedc.MissionCoreNode") context = WebKit2.WebContext.new_ephemeral() context.connect("download-started", self.download_started) self.view = WebKit2.WebView.new_with_context(context) self.view.get_settings().set_enable_developer_extras(False) self.view.connect("context-menu", lambda *_: True) self.view.connect("decide-policy", self.decide_policy) self.view.connect("permission-request", self.deny_permission) self.view.connect("load-failed", self.load_failed) self.view.connect("load-changed", self.loaded) self.view.connect("web-process-terminated", self.process_failed) manager = self.view.get_user_content_manager() manager.add_script(WebKit2.UserScript.new( "Object.defineProperty(window, 'missionCoreDesktop', {value: Object.freeze({networkSetup: true, environmentSetup: true})});", WebKit2.UserContentInjectedFrames.TOP_FRAME, WebKit2.UserScriptInjectionTime.START, None, None, )) manager.register_script_message_handler("node") manager.connect("script-message-received::node", self.message) self.window.add(self.view) self.window.connect("destroy", self.destroyed) self.window.show_all() self.view.load_uri(ORIGIN) def loaded(self, _view, event): if event == WebKit2.LoadEvent.FINISHED and not self.initial_login: self.initial_login = True self.login() def destroyed(self, *_): self.window = None def message(self, _manager, result): if not local_url(self.view.get_uri() or ""): return action = result.get_js_value().to_string() if action == "authorize": self.login() elif action == "configure-system": self.configure_environment() elif action in ("install-tailscale", "connect-tailscale"): self.network_action(action) def environment_record(self): try: path = Path("/var/lib/mission-core-node-environment/last-run.json") if path.stat().st_size > 32768: return None value = json.loads(path.read_text()) if value.get("schema") == "missioncore.node.environment/v1": return value except (OSError, ValueError, TypeError): pass return None def environment_progress(self): if not self.window or not self.pending: self.environment_timer = None return False record = self.environment_record() if record and record.get("run_id") != self.environment_previous: script = "window.dispatchEvent(new CustomEvent('mission-core-environment-progress', {detail: " + json.dumps(record) + "}));" self.view.evaluate_javascript(script, -1, None, None, None, None, None) return True def configure_environment(self): if self.pending: return self.pending = True previous = self.environment_record() self.environment_previous = previous.get("run_id") if previous else None self.environment_timer = GLib.timeout_add(1000, self.environment_progress) def work(): value = {"ok": False} try: process = subprocess.run(["/usr/bin/pkexec", "/usr/lib/mission-core-node/configure-system"], capture_output=True, text=True) if process.returncode: value["error"] = "Системное подтверждение отменено или недоступно. Повторите действие." else: value = json.loads(process.stdout) if type(value.get("ok")) is not bool: raise ValueError("Unexpected environment result") if value.get("login_uri") and not LOGIN.fullmatch(value["login_uri"]): raise ValueError("Unexpected local login") except (OSError, ValueError, TypeError, subprocess.SubprocessError): value = {"ok": False, "error": "Не удалось завершить настройку окружения. Повторите действие."} GLib.idle_add(self.environment_finished, value) threading.Thread(target=work, daemon=True).start() def environment_finished(self, value): self.pending = False if self.environment_timer: GLib.source_remove(self.environment_timer) self.environment_timer = None uri = value.pop("login_uri", None) value["reloading"] = bool(uri) if self.window: script = "window.dispatchEvent(new CustomEvent('mission-core-environment-result', {detail: " + json.dumps(value) + "}));" self.view.evaluate_javascript(script, -1, None, None, None, None, None) if uri: self.view.load_uri(uri) return False def network_action(self, action): if self.pending: self.network_result({"action": action, "ok": False, "error": "Другая операция ещё выполняется."}, completed=False) return self.pending = True def work(): value = {"action": action, "ok": False} try: # The action is selected from the allowlist above. No command, # URL, credential, path or network option is accepted from JS. process = subprocess.run(["/usr/bin/pkexec", "/usr/lib/mission-core-node/" + action], capture_output=True, text=True) if process.returncode: value["error"] = "Системное подтверждение отменено или недоступно. Повторите действие." else: result = json.loads(process.stdout) if not isinstance(result, dict) or type(result.get("ok")) is not bool: raise ValueError("Unexpected helper response") value["ok"] = result["ok"] if result.get("url"): uri = result["url"] if not re.fullmatch(r"https://login\.tailscale\.com/a/[A-Za-z0-9_-]{1,256}", uri): raise ValueError("Unexpected login destination") # Keep the provider credential inside the native process; # no auth URL is persisted or returned to the web API/JS. value["login_uri"] = uri if not value["ok"]: value["error"] = str(result.get("error", "Настройка Tailscale не завершена."))[:1024] except (OSError, ValueError, TypeError, subprocess.SubprocessError): value = {"action": action, "ok": False, "error": "Не удалось выполнить настройку Tailscale. Повторите действие."} GLib.idle_add(self.network_result, value) threading.Thread(target=work, daemon=True).start() def network_result(self, value, completed=True): if completed: self.pending = False uri = value.pop("login_uri", None) if not self.window: return False if uri: try: Gio.AppInfo.launch_default_for_uri(uri, None) value["browser_opened"] = True except GLib.Error: value["ok"] = False value["error"] = "Не удалось открыть браузер. Проверьте системный браузер по умолчанию и повторите вход." script = "window.dispatchEvent(new CustomEvent('mission-core-network-result', {detail: " + json.dumps(value) + "}));" self.view.evaluate_javascript(script, -1, None, None, None, None, None) return False def login(self): if self.pending: return self.pending = True def work(): try: uri = authorize(self.development_socket) GLib.idle_add(self.login_ready, uri) except (OSError, ValueError, KeyError, http.client.HTTPException, subprocess.SubprocessError): GLib.idle_add(self.problem, "Не удалось подтвердить доступ. Повторите вход и подтвердите системный запрос.") finally: GLib.idle_add(self.login_finished) threading.Thread(target=work, daemon=True).start() def login_ready(self, uri): if self.window: self.view.load_uri(uri) return False def login_finished(self): self.pending = False return False def problem(self, message): if not self.window: return False dialog = Gtk.MessageDialog(transient_for=self.window, modal=True, message_type=Gtk.MessageType.ERROR, buttons=Gtk.ButtonsType.CLOSE, text="Mission Core Node") dialog.format_secondary_text(message) dialog.connect("response", lambda d, _: d.destroy()) dialog.show() return False def load_failed(self, _view, _event, uri, _error): if local_url(uri): self.problem("Служба ноды недоступна. Повторно откройте приложение после восстановления службы.") return True def process_failed(self, *_): self.problem("Окно приложения остановилось. Закройте и повторно откройте Mission Core Node. Служба борта продолжает работать отдельно.") def deny_permission(self, _view, permission): permission.deny() return True def decide_policy(self, _view, decision, kind): if kind in (WebKit2.PolicyDecisionType.NAVIGATION_ACTION, WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION): uri = decision.get_navigation_action().get_request().get_uri() if kind == WebKit2.PolicyDecisionType.NEW_WINDOW_ACTION or not local_url(uri): decision.ignore() return True elif kind == WebKit2.PolicyDecisionType.RESPONSE: uri = decision.get_request().get_uri() if not local_url(uri): decision.ignore() return True if urlsplit(uri).path == "/api/report" and decision.get_response().get_status_code() == 200: decision.download() return True return False def download_started(self, _context, download): uri = download.get_request().get_uri() if not local_url(uri) or urlsplit(uri).path != "/api/report": download.cancel() return download.connect("decide-destination", self.download_destination) download.connect("failed", self.download_failed) def download_failed(self, download, _error): if download in self.cancelled_downloads: self.cancelled_downloads.discard(download) return False return self.problem("Не удалось сохранить отчёт.") def download_destination(self, download, _suggested): chooser = Gtk.FileChooserNative.new("Сохранить отчёт", self.window, Gtk.FileChooserAction.SAVE, "Сохранить", "Отмена") chooser.set_current_name("mission-core-node-report.json") chooser.set_do_overwrite_confirmation(True) if chooser.run() == Gtk.ResponseType.ACCEPT: download.set_allow_overwrite(True) download.set_destination(Path(chooser.get_filename()).as_uri()) else: self.cancelled_downloads.add(download) download.cancel() chooser.destroy() return True if __name__ == "__main__": parser = argparse.ArgumentParser() parser.add_argument("--development-socket", help="Engineering-only: private socket of an unprivileged development service") arguments = parser.parse_args() if os.geteuid() == 0: raise SystemExit("Run the desktop application as your normal system user") raise SystemExit(NodeApplication(arguments.development_socket).run([]))