Files
NODEDC_MISSION_CORE/apps/node-agent/packaging/launcher.py
T

332 lines
15 KiB
Python

#!/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
import syslog
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):
syslog.openlog('mission-core-node-ui')
syslog.syslog(syslog.LOG_ERR, 'node-ui-load-failed')
if local_url(uri):
self.problem("Служба ноды недоступна. Повторно откройте приложение после восстановления службы.")
return True
def process_failed(self, _view, reason):
syslog.openlog('mission-core-node-ui')
syslog.syslog(syslog.LOG_ERR, 'node-ui-process-terminated reason=' + str(int(reason)))
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([]))