feat(node): package Ubuntu desktop setup and trusted access

This commit is contained in:
DCCONSTRUCTIONS
2026-09-05 17:27:59 +03:00
parent 57f2537af3
commit d696842f5d
52 changed files with 4457 additions and 0 deletions
@@ -0,0 +1,5 @@
# Node-managed public keys supplement existing per-user authorized_keys.
# The command can only return GUI-enrolled Ed25519 keys for local sudo users.
AuthorizedKeysCommand /usr/lib/mission-core-node/node-agent ssh-keys %u
AuthorizedKeysCommandUser mission-core-node
PermitEmptyPasswords no
+2
View File
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/lib/mission-core-node/node-agent authorize
+60
View File
@@ -0,0 +1,60 @@
#!/usr/bin/env python3
"""Engineering-only, sequential build. Never run by an Ubuntu operator."""
import argparse
import hashlib
import json
import os
from pathlib import Path
import shutil
import subprocess
import sys
from build_deb import build, VERSION, BRAND_SHA256
ROOT = Path(__file__).resolve().parents[1]
DG_COMMIT = "8a79dfe84d895c9f1d42b8d285bc6670114f939f"
def guideline_sources():
dg = ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE"
paths = list((dg / "packages/ui-react/src").glob("*"))
paths += list((dg / "packages/ui-react/dist").glob("*"))
paths += [dg / "packages/ui-core/styles.css", dg / "packages/tokens/tokens.css", dg / "packages/tokens/themes.css"]
return {str(p.relative_to(dg)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(paths) if p.is_file()}
def provenance():
files = {str(p.relative_to(ROOT)): hashlib.sha256(p.read_bytes()).hexdigest()
for p in sorted(ROOT.rglob("*")) if p.is_file()
and not any(x in p.relative_to(ROOT).parts for x in ("node_modules", "build", "__pycache__"))}
return {"package": "mission-core-node", "version": VERSION,
"brand_mark_sha256": BRAND_SHA256,
"base_commit": subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=ROOT, text=True).strip(),
"design_guideline_commit": DG_COMMIT,
"design_guideline_files": guideline_sources(),
"toolchain": json.loads((ROOT / "toolchain.json").read_text()), "files": files}
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--go", type=Path, required=True)
args = parser.parse_args()
go = args.go.resolve()
expected = json.loads((ROOT / "toolchain.json").read_text())["version"]
if subprocess.check_output([str(go), "version"], text=True).split()[2] != expected:
sys.exit("Go version does not match toolchain.json")
dg = ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE"
if subprocess.check_output(["git", "rev-parse", "HEAD"], cwd=dg, text=True).strip() != DG_COMMIT:
sys.exit("Design Guideline revision does not match the admitted build")
subprocess.run(["npm", "run", "build"], cwd=ROOT / "ui", check=True)
assets = ROOT / "web/dist"
if assets.exists():
shutil.rmtree(assets)
shutil.copytree(ROOT / "ui/dist", assets)
output = ROOT / "build"
output.mkdir(exist_ok=True)
env = dict(os.environ, GOMAXPROCS="2", CGO_ENABLED="0", GOOS="linux", GOARCH="amd64")
subprocess.run([str(go), "build", "-trimpath", f"-ldflags=-s -w -X main.version={VERSION}", "-o",
str(output / "node-agent-linux-amd64"), "./cmd/node-agent"], cwd=ROOT, env=env, check=True)
(output / "provenance.json").write_text(json.dumps(provenance(), indent=2) + "\n")
build(output / "node-agent-linux-amd64", output / f"mission-core-node_{VERSION}_amd64.deb")
+106
View File
@@ -0,0 +1,106 @@
#!/usr/bin/env python3
"""Build a deterministic Debian package on macOS/Linux from reviewed artifacts.
No install operation, sudo, container, package-manager mutation or network I/O.
"""
import argparse
import gzip
import hashlib
import io
import json
from pathlib import Path
import tarfile
ROOT = Path(__file__).resolve().parents[1]
VERSION = "0.3.0"
BRAND_SHA256 = "8bfee8ca9f98e0db48d98aae3af4b32493b8593e18b064a0239d513d824182af"
def desktop_icon(brand):
"""Give desktop loaders a square canvas without distorting the brand mark.
The canonical SVG remains an unchanged nested document. Its default
xMidYMid meet preserves the mark's aspect ratio inside this square viewport.
Explicit intrinsic dimensions also keep GTK's pixbuf square.
"""
if hashlib.sha256(brand).hexdigest() != BRAND_SHA256:
raise ValueError("Brand mark differs from the admitted Design Guideline asset")
return (b'<svg xmlns="http://www.w3.org/2000/svg" width="256" height="256" '
b'viewBox="0 0 256 256" preserveAspectRatio="xMidYMid meet">\n'
+ brand + b'</svg>\n')
def tarball(files):
stream = io.BytesIO()
with tarfile.open(fileobj=stream, mode="w", format=tarfile.USTAR_FORMAT) as archive:
directories = {str(parent) for name, _, _ in files for parent in Path(name).parents if str(parent) != "."}
for name in sorted(directories):
item = tarfile.TarInfo(name + "/")
item.type, item.mode = tarfile.DIRTYPE, 0o755
item.uname = item.gname = "root"
archive.addfile(item)
for name, data, mode in sorted(files):
item = tarfile.TarInfo(name)
item.size, item.mode, item.uid, item.gid = len(data), mode, 0, 0
item.uname = item.gname = "root"
archive.addfile(item, io.BytesIO(data))
return gzip.compress(stream.getvalue(), mtime=0)
def ar_member(name, data):
header = f"{name + '/':<16}{0:<12}{0:<6}{0:<6}{'100644':<8}{len(data):<10}`\n".encode()
assert len(header) == 60
return header + data + (b"\n" if len(data) % 2 else b"")
def build(binary, destination):
payload = binary.read_bytes()
if payload[:4] != b"\x7fELF" or payload[4:6] != b"\x02\x01" or payload[18:20] != b"\x3e\x00":
raise ValueError("Expected a Linux amd64 ELF binary")
p = ROOT / "packaging"
control = f"""Package: mission-core-node
Version: {VERSION}
Architecture: amd64
Maintainer: NODE.DC local build <noreply@example.invalid>
Section: admin
Priority: optional
Depends: adduser, systemd, openssh-server, python3, python3-gi, gir1.2-gtk-3.0, gir1.2-webkit2-4.1, pkexec, polkitd, ca-certificates, hicolor-icon-theme
Description: Mission Core onboard computer configuration
Local graphical setup, host inventory, SSH access and persistent node identity.
Ubuntu 24.04 LTS Desktop amd64 qualification candidate.
""".encode()
controls = [("control", control, 0o644)]
controls += [(name, (p / name).read_bytes(), 0o755) for name in ["preinst", "postinst", "prerm", "postrm"]]
files = [("usr/lib/mission-core-node/node-agent", payload, 0o755)]
brand = (ROOT.parents[2] / "NODEDC_DESIGN_GUIDELINE/apps/catalog/public/nodedc-mark.svg").read_bytes()
files.append(("usr/share/icons/hicolor/scalable/apps/org.nodedc.MissionCoreNode.svg", desktop_icon(brand), 0o644))
for source, path, mode in [
("launcher.py", "usr/bin/mission-core-node", 0o755),
("authorize", "usr/lib/mission-core-node/authorize", 0o755),
("mission-core-node.desktop", "usr/share/applications/org.nodedc.MissionCoreNode.desktop", 0o644),
("mission-core-node.service", "usr/lib/systemd/system/mission-core-node.service", 0o644),
("org.nodedc.mission-core-node.policy", "usr/share/polkit-1/actions/org.nodedc.mission-core-node.policy", 0o644),
("60-mission-core-node.conf", "usr/share/mission-core-node/60-mission-core-node.conf", 0o644),
("network_helper.py", "usr/lib/mission-core-node/network_helper.py", 0o644),
("install-tailscale", "usr/lib/mission-core-node/install-tailscale", 0o755),
("connect-tailscale", "usr/lib/mission-core-node/connect-tailscale", 0o755),
("tailscale-release.json", "usr/share/mission-core-node/tailscale-release.json", 0o644),
]:
files.append((path, (p / source).read_bytes(), mode))
if (ROOT / "build/provenance.json").exists():
files.append(("usr/share/doc/mission-core-node/provenance.json", (ROOT / "build/provenance.json").read_bytes(), 0o644))
archive = b"!<arch>\n" + ar_member("debian-binary", b"2.0\n") + ar_member("control.tar.gz", tarball(controls)) + ar_member("data.tar.gz", tarball(files))
destination.parent.mkdir(parents=True, exist_ok=True)
destination.write_bytes(archive)
digest = hashlib.sha256(archive).hexdigest()
destination.with_suffix(destination.suffix + ".sha256").write_text(f"{digest} {destination.name}\n")
print(json.dumps({"file": str(destination), "bytes": len(archive), "sha256": digest}))
if __name__ == "__main__":
parser = argparse.ArgumentParser()
parser.add_argument("--binary", type=Path, required=True)
parser.add_argument("--output", type=Path, required=True)
args = parser.parse_args()
build(args.binary, args.output)
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/bin/python3 -I /usr/lib/mission-core-node/network_helper.py connect
@@ -0,0 +1,2 @@
#!/bin/sh
exec /usr/bin/python3 -I /usr/lib/mission-core-node/network_helper.py install
+262
View File
@@ -0,0 +1,262 @@
#!/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()
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})});",
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 in ("install-tailscale", "connect-tailscale"):
self.network_action(action)
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"] = "Не удалось открыть браузер. Проверьте браузер по умолчанию в Ubuntu и повторите вход."
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, "Не удалось подтвердить доступ. Повторите вход и подтвердите системный запрос Ubuntu.")
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 Ubuntu user")
raise SystemExit(NodeApplication(arguments.development_socket).run([]))
@@ -0,0 +1,10 @@
[Desktop Entry]
Version=1.0
Type=Application
Name=Mission Core Node
Comment=Настройка и диагностика бортового компьютера
Exec=/usr/bin/mission-core-node
Icon=org.nodedc.MissionCoreNode
Terminal=false
Categories=System;
StartupNotify=true
@@ -0,0 +1,34 @@
[Unit]
Description=Mission Core Node local device host
After=network.target
[Service]
Type=simple
User=mission-core-node
Group=mission-core-node
ExecStart=/usr/lib/mission-core-node/node-agent
StateDirectory=mission-core-node
StateDirectoryMode=0700
RuntimeDirectory=mission-core-node
RuntimeDirectoryMode=0700
UMask=0077
Restart=on-failure
RestartSec=3
NoNewPrivileges=yes
ProtectSystem=strict
ProtectHome=yes
PrivateTmp=yes
PrivateDevices=yes
ProtectKernelTunables=yes
ProtectKernelModules=yes
ProtectControlGroups=yes
RestrictSUIDSGID=yes
RestrictAddressFamilies=AF_UNIX AF_INET
CapabilityBoundingSet=
LockPersonality=yes
LimitNOFILE=1024
TasksMax=64
MemoryMax=256M
[Install]
WantedBy=multi-user.target
+219
View File
@@ -0,0 +1,219 @@
#!/usr/bin/python3
"""Fixed polkit operations for the optional Tailscale provider. Never a shell API."""
import fcntl
import hashlib
import json
import os
from pathlib import Path
import re
import subprocess
import sys
import tempfile
import stat
import urllib.request
from urllib.parse import urlsplit
TAILSCALE = "/usr/bin/tailscale"
RELEASE = Path("/usr/share/mission-core-node/tailscale-release.json")
ENV = {"PATH": "/usr/sbin:/usr/bin:/sbin:/bin", "LANG": "C.UTF-8", "DEBIAN_FRONTEND": "noninteractive"}
TRANSPORT_DIRECTORY = Path("/etc/systemd/system/tailscaled.service.d")
TRANSPORT_NAME = "60-mission-core-node-https.conf"
TRANSPORT_CONFIG = b"# Mission Core Node: provider control transport; preserve on Node removal.\n[Service]\nEnvironment=TS_FORCE_NOISE_443=true\n"
class SetupError(Exception):
pass
def login_url(value):
if not isinstance(value, str) or len(value) > 512:
return False
try:
u = urlsplit(value)
return (u.scheme == "https" and u.netloc == "login.tailscale.com"
and not u.query and not u.fragment
and re.fullmatch(r"/a/[A-Za-z0-9_-]+", u.path) is not None)
except ValueError:
return False
def status():
result = subprocess.run([TAILSCALE, "status", "--json", "--peers=false"],
env=ENV, capture_output=True, text=True, timeout=8)
if result.returncode or len(result.stdout) > 1024 * 1024:
raise SetupError("Служба Tailscale пока не отвечает. Подождите и повторите подключение.")
value = json.loads(result.stdout)
if not isinstance(value, dict):
raise SetupError("Не удалось прочитать состояние Tailscale.")
return value
def checked(command):
# Do not kill dpkg mid-transaction if the desktop window is closed. APT has
# bounded network/lock waits; the fixed root process completes independently.
result = subprocess.run(command, env=ENV, stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL)
if result.returncode:
raise SetupError("Установка не завершена. Проверьте интернет и завершение других установок Ubuntu, затем повторите.")
def control_transport():
"""Use the pinned provider's HTTPS underlay on networks that stall port 80.
Only a Node-owned systemd drop-in is written; keys, profiles, DNS, routes
and other provider settings are never edited. An active connected provider
is left untouched by callers. Never replace a custom file at our path.
"""
TRANSPORT_DIRECTORY.mkdir(mode=0o755, exist_ok=True)
info = TRANSPORT_DIRECTORY.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022:
raise SetupError("Небезопасные права каталога службы Tailscale. Требуется проверить настройку системы.")
destination = TRANSPORT_DIRECTORY / TRANSPORT_NAME
if destination.is_symlink():
raise SetupError("Обнаружена другая настройка транспорта Tailscale; она сохранена без изменений.")
if destination.exists():
info = destination.stat()
if (not stat.S_ISREG(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o022
or destination.read_bytes() != TRANSPORT_CONFIG):
raise SetupError("Обнаружена другая настройка транспорта Tailscale; она сохранена без изменений.")
return
# Root-only operation lock serializes our own setup. Publish a complete
# file atomically; systemd must never see a half-written configuration.
with tempfile.NamedTemporaryFile(dir=TRANSPORT_DIRECTORY, prefix=".node-https-", delete=False) as output:
temporary = Path(output.name)
try:
output.write(TRANSPORT_CONFIG)
output.flush()
os.fchmod(output.fileno(), 0o644)
os.fsync(output.fileno())
os.replace(temporary, destination)
finally:
temporary.unlink(missing_ok=True)
checked(["/usr/bin/systemctl", "daemon-reload"])
def https_transport_active():
result = subprocess.run(["/usr/bin/systemctl", "show", "--property=MainPID", "--value", "tailscaled.service"],
env=ENV, capture_output=True, text=True, timeout=5)
pid = result.stdout.strip()
if result.returncode or not re.fullmatch(r"[1-9][0-9]{0,9}", pid):
return False
try:
# Read only to check this one nonsensitive flag; never emit the process
# environment (which can contain unrelated administrator credentials).
return b"TS_FORCE_NOISE_443=true" in Path(f"/proc/{pid}/environ").read_bytes().split(b"\0")
except OSError:
return False
def install():
if not Path(TAILSCALE).exists():
release = json.loads(RELEASE.read_text())
expected = release["sha256"]
url = release["url"]
if (not isinstance(expected, str) or not isinstance(url, str)
or not re.fullmatch(r"[a-f0-9]{64}", expected)
or not re.fullmatch(r"https://pkgs\.tailscale\.com/stable/tailscale_[0-9.]+_amd64\.deb", url)):
raise SetupError("Повреждены сведения об установочном пакете Tailscale.")
with tempfile.TemporaryDirectory(prefix="mission-core-tailscale-", dir="/var/tmp") as directory:
package = Path(directory) / "tailscale.deb"
digest = hashlib.sha256()
size = 0
with urllib.request.urlopen(url, timeout=30) as response, package.open("xb") as output:
while chunk := response.read(1024 * 1024):
size += len(chunk)
if size > 64 * 1024 * 1024:
raise SetupError("Размер пакета Tailscale не соответствует ожидаемому.")
digest.update(chunk)
output.write(chunk)
if digest.hexdigest() != expected:
raise SetupError("Контрольная сумма Tailscale не совпала. Пакет не установлен; повторите загрузку.")
checked(["/usr/bin/apt-get", "-o", "DPkg::Lock::Timeout=30", "-o", "Acquire::Retries=1",
"-o", "Acquire::http::Timeout=30", "-o", "Acquire::https::Timeout=30",
"--no-remove", "--no-install-recommends", "install", "-y", str(package)])
control_transport()
# The vendor package may already have started its daemon during APT.
checked(["/usr/bin/systemctl", "restart", "tailscaled.service"])
checked(["/usr/bin/systemctl", "enable", "--now", "tailscaled.service"])
if not Path(TAILSCALE).is_file():
raise SetupError("Установщик завершился, но Tailscale не найден.")
return {"ok": True}
def connect_command(state):
if state == "Stopped":
# Up with absolutely no flags is the upstream preserve-all-preferences
# resume operation. Even --json counts as a flag in the pinned CLI.
return [TAILSCALE, "up"]
if state == "NeedsLogin":
# Fresh onboard setup keeps the current LAN DNS/routes. No exit node,
# route advertisement, Tailscale SSH, reset, or forced reauthentication.
return [TAILSCALE, "up", "--json", "--timeout=12s", "--accept-dns=false", "--accept-routes=false"]
raise SetupError("Tailscale ещё запускается. Подождите и повторите подключение.")
def connect():
if not Path(TAILSCALE).is_file():
raise SetupError("Сначала установите Tailscale через приложение.")
checked(["/usr/bin/systemctl", "enable", "--now", "tailscaled.service"])
current = status()
state = current.get("BackendState")
if state in ("Running", "NeedsMachineAuth"):
return {"ok": True}
control_transport()
if not https_transport_active():
checked(["/usr/bin/systemctl", "daemon-reload"])
checked(["/usr/bin/systemctl", "restart", "tailscaled.service"])
if not https_transport_active():
raise SetupError("Другие настройки службы мешают восстановить соединение Tailscale. Они сохранены; требуется проверить конфигурацию системы.")
current = status()
state = current.get("BackendState")
if state in ("Running", "NeedsMachineAuth"):
return {"ok": True}
# A pending provider login is reused; never force a second authentication.
if state == "NeedsLogin" and login_url(current.get("AuthURL")):
return {"ok": True, "url": current["AuthURL"]}
try:
result = subprocess.run(connect_command(state), env=ENV, capture_output=True, timeout=18)
success = result.returncode == 0
except subprocess.TimeoutExpired:
success = False
# Read the daemon's actual outcome, not the CLI's progress text. AuthURL and
# any other provider credentials are never written to disk or the journal.
current = status()
if current.get("BackendState") in ("Running", "NeedsMachineAuth"):
return {"ok": True}
if login_url(current.get("AuthURL")):
return {"ok": True, "url": current["AuthURL"]}
if success:
return {"ok": True}
raise SetupError("Tailscale не завершил подключение. Проверьте интернет и повторите; существующие нестандартные настройки требуют отдельной проверки.")
def main():
if os.geteuid() != 0 or sys.argv[1:] not in (["install"], ["connect"]):
raise SystemExit("Use the installed application and its system authorization dialog")
os.environ.clear()
os.environ.update(ENV)
os.umask(0o077)
try:
directory = Path("/run/mission-core-node-system")
directory.mkdir(mode=0o700, exist_ok=True)
info = directory.lstat()
if not stat.S_ISDIR(info.st_mode) or info.st_uid != 0 or info.st_mode & 0o077:
raise SetupError("Небезопасные права системного каталога Node. Требуется восстановить установку.")
fd = os.open(directory / "tailscale.lock", os.O_CREAT | os.O_RDWR | os.O_NOFOLLOW, 0o600)
with os.fdopen(fd, "w") as lock:
try:
fcntl.flock(lock, fcntl.LOCK_EX | fcntl.LOCK_NB)
except BlockingIOError:
raise SetupError("Операция с Tailscale уже выполняется. Подождите и обновите состояние.")
result = install() if sys.argv[1] == "install" else connect()
except SetupError as error:
result = {"ok": False, "error": str(error)}
except (OSError, ValueError, KeyError, subprocess.SubprocessError):
result = {"ok": False, "error": "Не удалось завершить настройку Tailscale. Проверьте подключение к интернету и повторите."}
print(json.dumps(result))
if __name__ == "__main__":
main()
@@ -0,0 +1,29 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE policyconfig PUBLIC "-//freedesktop//DTD PolicyKit Policy Configuration 1.0//EN" "http://www.freedesktop.org/standards/PolicyKit/1/policyconfig.dtd">
<policyconfig>
<vendor>NODE.DC</vendor>
<action id="org.nodedc.mission-core-node.open">
<description>Open Mission Core Node</description>
<description xml:lang="ru">Открыть Mission Core Node</description>
<message>Authenticate to manage this onboard computer.</message>
<message xml:lang="ru">Подтвердите доступ к управлению этим бортовым компьютером.</message>
<defaults><allow_any>no</allow_any><allow_inactive>no</allow_inactive><allow_active>auth_admin</allow_active></defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mission-core-node/authorize</annotate>
</action>
<action id="org.nodedc.mission-core-node.install-tailscale">
<description>Install Tailscale for Mission Core Node</description>
<description xml:lang="ru">Установить Tailscale для Mission Core Node</description>
<message>Install the verified Tailscale package and enable its system service.</message>
<message xml:lang="ru">Установить проверенный пакет Tailscale и включить его системную службу.</message>
<defaults><allow_any>no</allow_any><allow_inactive>no</allow_inactive><allow_active>auth_admin</allow_active></defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mission-core-node/install-tailscale</annotate>
</action>
<action id="org.nodedc.mission-core-node.connect-tailscale">
<description>Connect this computer to Tailscale</description>
<description xml:lang="ru">Подключить борт к Tailscale</description>
<message>Enable Tailscale and open its sign-in page if authentication is required.</message>
<message xml:lang="ru">Включить Tailscale и открыть страницу входа, если требуется авторизация.</message>
<defaults><allow_any>no</allow_any><allow_inactive>no</allow_inactive><allow_active>auth_admin</allow_active></defaults>
<annotate key="org.freedesktop.policykit.exec.path">/usr/lib/mission-core-node/connect-tailscale</annotate>
</action>
</policyconfig>
+34
View File
@@ -0,0 +1,34 @@
#!/bin/sh
set -eu
case "$1" in
configure)
if ! getent passwd mission-core-node >/dev/null; then
adduser --system --group --home /var/lib/mission-core-node --no-create-home --disabled-login mission-core-node
fi
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
mc_node_ssh_template=/usr/share/mission-core-node/60-mission-core-node.conf
if [ -L "$mc_node_ssh_snippet" ] || { [ -e "$mc_node_ssh_snippet" ] && ! cmp -s "$mc_node_ssh_template" "$mc_node_ssh_snippet"; }; then
echo "Mission Core Node: existing custom SSH snippet preserved; configuration conflict." >&2
exit 1
fi
install -D -m 0644 "$mc_node_ssh_template" "$mc_node_ssh_snippet"
if [ -d /run/systemd/system ]; then
install -d -m 0755 /run/sshd
/usr/sbin/sshd -t
mc_node_ssh_config=$(/usr/sbin/sshd -T)
if ! printf '%s\n' "$mc_node_ssh_config" | grep -Fx 'authorizedkeyscommand /usr/lib/mission-core-node/node-agent ssh-keys %u' >/dev/null; then
echo "Mission Core Node: another AuthorizedKeysCommand overrides Node SSH access. Existing configuration was preserved; resolve this conflict before accepting setup." >&2
exit 1
fi
if ! printf '%s\n' "$mc_node_ssh_config" | grep -Fx 'authorizedkeyscommanduser mission-core-node' >/dev/null; then
echo "Mission Core Node: conflicting AuthorizedKeysCommandUser; existing configuration was preserved." >&2
exit 1
fi
systemctl daemon-reload
systemctl enable --now ssh.service
systemctl try-reload-or-restart ssh.service
systemctl enable mission-core-node.service
systemctl restart mission-core-node.service
fi
;;
esac
+7
View File
@@ -0,0 +1,7 @@
#!/bin/sh
set -eu
if [ -d /run/systemd/system ]; then
systemctl daemon-reload
fi
# Preserve identity and ownership on remove/purge. A future explicit UI factory
# reset must distinguish local deletion from revoking remote Core authorization.
+9
View File
@@ -0,0 +1,9 @@
#!/bin/sh
set -eu
if [ "$1" = install ] || [ "$1" = upgrade ]; then
. /etc/os-release
if [ "${ID:-}" != ubuntu ] || [ "${VERSION_ID:-}" != 24.04 ]; then
echo "Mission Core Node: this package requires Ubuntu 24.04 LTS Desktop amd64." >&2
exit 1
fi
fi
+21
View File
@@ -0,0 +1,21 @@
#!/bin/sh
set -eu
case "$1" in
remove|deconfigure)
mc_node_ssh_snippet=/etc/ssh/sshd_config.d/60-mission-core-node.conf
if [ -e "$mc_node_ssh_snippet" ]; then
if cmp -s /usr/share/mission-core-node/60-mission-core-node.conf "$mc_node_ssh_snippet"; then
rm "$mc_node_ssh_snippet"
else
mc_node_saved_snippet=$(mktemp /etc/ssh/sshd_config.d/mission-core-node-removed.XXXXXX)
mv "$mc_node_ssh_snippet" "$mc_node_saved_snippet"
fi
fi
if [ -d /run/systemd/system ]; then
/usr/sbin/sshd -t
systemctl try-reload-or-restart ssh.service
systemctl stop mission-core-node.service
systemctl disable mission-core-node.service
fi
;;
esac
@@ -0,0 +1,6 @@
{
"version": "1.102.3",
"url": "https://pkgs.tailscale.com/stable/tailscale_1.102.3_amd64.deb",
"sha256": "88e1b0319da94a52ea409a1a5935e4e7215065a25cd99bc509b6dcbb73737fae",
"checksum_source": "https://pkgs.tailscale.com/stable/tailscale_1.102.3_amd64.deb.sha256"
}
@@ -0,0 +1,102 @@
"""Security/continuity checks. No installation, OS mutation, or real login."""
import hashlib
import io
import json
from pathlib import Path
import tempfile
import unittest
from unittest.mock import patch
import network_helper as helper
class NetworkHelperTests(unittest.TestCase):
def test_only_provider_login_destinations_are_accepted(self):
self.assertTrue(helper.login_url("https://login.tailscale.com/a/synthetic-login"))
for value in [None, "http://login.tailscale.com/a/x", "https://login.tailscale.com.evil.test/a/x",
"https://login.tailscale.com@evil.test/a/x", "https://login.tailscale.com/a/x?q=x",
"https://login.tailscale.com/a/../admin", "https://login.tailscale.com/a/x#fragment",
"https://login.tailscale.com:443/a/x", "file:///tmp/x"]:
self.assertFalse(helper.login_url(value), value)
def test_resume_keeps_existing_preferences_and_new_login_keeps_lan(self):
self.assertEqual(helper.connect_command("Stopped"), [helper.TAILSCALE, "up"])
fresh = helper.connect_command("NeedsLogin")
self.assertIn("--accept-dns=false", fresh)
self.assertIn("--accept-routes=false", fresh)
for flag in ("--reset", "--force-reauth", "--ssh", "--advertise-routes", "--exit-node"):
self.assertFalse(any(arg.startswith(flag) for arg in fresh))
with self.assertRaises(helper.SetupError):
helper.connect_command("Unknown")
def test_checksum_failure_never_reaches_apt_or_service_mutation(self):
original_tempdir = tempfile.TemporaryDirectory
with original_tempdir() as directory:
release = Path(directory) / "release.json"
release.write_text(json.dumps({"url": "https://pkgs.tailscale.com/stable/tailscale_1.102.3_amd64.deb",
"sha256": hashlib.sha256(b"expected").hexdigest()}))
with patch.object(helper, "TAILSCALE", str(Path(directory) / "missing")), \
patch.object(helper, "RELEASE", release), \
patch.object(helper.tempfile, "TemporaryDirectory", side_effect=lambda **kwargs: original_tempdir(dir=directory)), \
patch.object(helper.urllib.request, "urlopen", return_value=io.BytesIO(b"tampered")), \
patch.object(helper, "checked") as mutation:
with self.assertRaises(helper.SetupError):
helper.install()
mutation.assert_not_called()
def test_existing_provider_is_not_reinstalled(self):
with tempfile.NamedTemporaryFile() as existing, \
patch.object(helper, "TAILSCALE", existing.name), \
patch.object(helper.urllib.request, "urlopen") as download, \
patch.object(helper, "checked") as mutation:
self.assertTrue(helper.install()["ok"])
download.assert_not_called()
mutation.assert_called_once_with(["/usr/bin/systemctl", "enable", "--now", "tailscaled.service"])
def test_cli_timeout_uses_daemon_outcome_and_does_not_retry_login(self):
with tempfile.NamedTemporaryFile() as existing, \
patch.object(helper, "TAILSCALE", existing.name), \
patch.object(helper, "checked"), \
patch.object(helper, "control_transport"), \
patch.object(helper, "https_transport_active", return_value=True), \
patch.object(helper, "status", side_effect=[{"BackendState": "NeedsLogin"}, {"BackendState": "NeedsLogin", "AuthURL": "https://login.tailscale.com/a/synthetic"}]), \
patch.object(helper.subprocess, "run", side_effect=helper.subprocess.TimeoutExpired("tailscale", 18)) as run:
self.assertEqual(helper.connect(), {"ok": True, "url": "https://login.tailscale.com/a/synthetic"})
self.assertEqual(run.call_count, 1)
def test_connected_provider_is_never_reconfigured(self):
with tempfile.NamedTemporaryFile() as existing, \
patch.object(helper, "TAILSCALE", existing.name), \
patch.object(helper, "checked"), \
patch.object(helper, "status", return_value={"BackendState": "Running"}), \
patch.object(helper, "control_transport") as transport:
self.assertEqual(helper.connect(), {"ok": True})
transport.assert_not_called()
def test_transport_recovery_uses_saved_profile_without_login_when_possible(self):
with tempfile.NamedTemporaryFile() as existing, \
patch.object(helper, "TAILSCALE", existing.name), \
patch.object(helper, "checked") as system, \
patch.object(helper, "control_transport"), \
patch.object(helper, "https_transport_active", side_effect=[False, True]), \
patch.object(helper, "status", side_effect=[{"BackendState": "NeedsLogin"}, {"BackendState": "Running"}]), \
patch.object(helper.subprocess, "run") as cli:
self.assertEqual(helper.connect(), {"ok": True})
self.assertIn(unittest.mock.call(["/usr/bin/systemctl", "restart", "tailscaled.service"]), system.call_args_list)
cli.assert_not_called()
def test_transport_conflict_does_not_start_another_login(self):
with tempfile.NamedTemporaryFile() as existing, \
patch.object(helper, "TAILSCALE", existing.name), \
patch.object(helper, "checked"), \
patch.object(helper, "control_transport"), \
patch.object(helper, "https_transport_active", return_value=False), \
patch.object(helper, "status", return_value={"BackendState": "NeedsLogin"}), \
patch.object(helper.subprocess, "run") as cli:
with self.assertRaises(helper.SetupError):
helper.connect()
cli.assert_not_called()
if __name__ == "__main__":
unittest.main()