feat(node): package Ubuntu desktop setup and trusted access
This commit is contained in:
@@ -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
|
||||
@@ -0,0 +1,2 @@
|
||||
#!/bin/sh
|
||||
exec /usr/lib/mission-core-node/node-agent authorize
|
||||
@@ -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")
|
||||
@@ -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
|
||||
@@ -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
|
||||
@@ -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>
|
||||
@@ -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
|
||||
@@ -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.
|
||||
@@ -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
|
||||
@@ -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()
|
||||
Reference in New Issue
Block a user