332 lines
15 KiB
Python
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([]))
|