#!/usr/bin/env python3 """The operator's tool for changing a frozen base image. WHY THIS EXISTS AND THE AGENT DOES NOT GET ONE Every VM session starts from a read-only base image, so nothing an agent installs survives it. That is the guarantee, and it is the right one - but it means there has to be some way to put software into the base, and it has to be a way the agent cannot reach. This is it. It starts units that run as a different user and are authorised by polkit against an active session of the operator: owuivm-maint@ to boot one, owuivm-thaw@ and owuivm-freeze@ to change whether it is writable. Those two are the only things on this host that touch the write bit on a base image. The agent's `request_unfreeze` produces a sentence in a report that a person then reads; it does not produce a writable disk. Frozen is literally the owner write bit, so the window can just show `ls -l` and be telling the truth. """ from __future__ import annotations import gi gi.require_version("Gtk", "4.0") from gi.repository import Gio, GLib, Gtk # noqa: E402 import fnmatch # noqa: E402 import json # noqa: E402 import os # noqa: E402 import shutil # noqa: E402 import subprocess # noqa: E402 import threading # noqa: E402 import time # noqa: E402 import tomllib # noqa: E402 BASE = os.environ.get("OWUIVM_BASE_DIR", "/var/lib/owuivm/base") ISOS = os.environ.get("OWUIVM_ISO_DIR", "/var/lib/owuivm/iso") WARM = os.environ.get("OWUIVM_WARM_DIR", "/var/lib/owuivm/warm") # Where a disc waits between the operator choosing it and the unit # checking it. The operator OWNS this directory, which is the whole # trick: the copy out of ~/Downloads needs no privilege, and the # privileged unit never reads the operator's home. See the header of # owuivm-insert-media. INCOMING = os.environ.get("OWUIVM_INCOMING_DIR", "/var/lib/owuivm/incoming") REGISTRY = os.environ.get("OWUIVM_REGISTRY", "/var/lib/owuivm/registry/vms.toml") NAME_OVERRIDES = os.environ.get("OWUIVM_NAME_OVERRIDES", "/var/lib/owuivm/registry/vm-names.toml") VNC_SOCKET = os.environ.get("OWUIVM_VNC_SOCKET", "/run/owuivm-maint/vnc.sock") VNC = os.environ.get("OWUIVM_VNC", "127.0.0.1:5999") # No DISK_SIZE and no OVMF_VARS here. Both were constants this tool used # when it created disks itself, and a copy of a value that really lives in # owuivm-maint-prepare is a copy that will disagree with it one day. # The shipped registry, used only when /etc/owuivm/vms.toml cannot be # read. This window is how an operator finds out their machines are in a # bad way, so it has to open and say so rather than fail to start. _FALLBACK = [{"id": "ubuntu", "name": "Ubuntu", "iso": "ubuntu*.iso"}, {"id": "windows", "name": "Windows", "iso": "[Ww]in*.iso"}] def machines() -> list[dict]: """The machines, in registry order, with the operator's renames applied. `id` is identity - the disk filename, the unit instance, the value the assistant passes as `os` - and is never touched here. `name` is a label and is the only thing Rename changes; it lives in a separate override file so that renaming a machine cannot rewrite the registry and lose the comments that are most of its value. """ try: with open(REGISTRY, "rb") as handle: entries = tomllib.load(handle).get("machine", []) or _FALLBACK except (OSError, tomllib.TOMLDecodeError): entries = _FALLBACK try: with open(NAME_OVERRIDES, "rb") as handle: renamed = tomllib.load(handle).get("names", {}) except (OSError, tomllib.TOMLDecodeError): renamed = {} found = [] for entry in entries: machine_id = str(entry.get("id") or "").strip() if not machine_id: continue found.append({ "id": machine_id, "name": str(renamed.get(machine_id) or entry.get("name") or machine_id), "iso": str(entry.get("iso") or f"{machine_id}*.iso"), }) return found or _FALLBACK def ids() -> list[str]: return [m["id"] for m in machines()] def unit_for(name: str) -> str: return f"owuivm-maint@{name}.service" def run(*args: str, timeout: float = 20.0) -> tuple[int, str]: """Blocking. Only for things that finish in well under a second.""" try: result = subprocess.run(list(args), capture_output=True, text=True, timeout=timeout, check=False) return result.returncode, (result.stdout + result.stderr).strip() except Exception as exc: # noqa: BLE001 return 1, str(exc) def run_async(args: list[str], timeout: float, on_done) -> None: """Run something slow off the main thread and report back on it. GTK repaints from the main loop, so a `subprocess.run` there freezes the window for as long as it takes - and stopping a machine now takes as long as the guest needs to shut down, up to the unit's TimeoutStopSec. Pressing Shut down locked the interface for over a minute, which looks exactly like a crash. The worker touches nothing but its own locals; the result comes back through GLib.idle_add, which runs the callback on the main loop where it is safe to touch widgets. """ def worker() -> None: code, out = run(*args, timeout=timeout) GLib.idle_add(on_done, code, out) threading.Thread(target=worker, daemon=True).start() def image_state(name: str) -> dict: path = os.path.join(BASE, f"{name}.qcow2") running = run("systemctl", "is-active", "--quiet", unit_for(name))[0] == 0 installing = os.path.exists(os.path.join(BASE, f"{name}.install")) if not os.path.exists(path): return {"exists": False, "frozen": True, "size": 0, "running": running, "installing": installing} mode = os.stat(path).st_mode return { "exists": True, # 0200 is the owner write bit. Nothing else is consulted, because # nothing else is what the freeze consists of. "frozen": not bool(mode & 0o200), "size": os.path.getsize(path), "running": running, "installing": installing, } def snapshot_state(name: str) -> str: """Whether this image has a usable fast-start snapshot, in a word. Read from the files rather than asked of vmd: this tool starts units and reads `ls -l`, and adding a socket client to it would make the window depend on a daemon being up to tell the operator what is on their own disk. """ warm = os.path.join(WARM, name) manifest = os.path.join(warm, "manifest.json") if not os.path.isfile(manifest): return "none" try: with open(manifest, encoding="utf-8") as handle: recorded = str(json.load(handle).get("base_sha256") or "") except (OSError, ValueError): return "unreadable" current = "" try: with open(os.path.join(BASE, f"{name}.sha256"), encoding="utf-8") as handle: for line in handle: digest, _, path = line.partition(" ") if path.strip().endswith(f"{name}.qcow2"): current = digest.strip() except OSError: pass if not current or not recorded: return "unreadable" return "usable" if current == recorded else "stale" def media_for(name: str) -> str: """The installation media the unit will pick for this image, or "". THE SAME RULE AS owuivm-maint-prepare, ON PURPOSE. This tool does not choose the media any more - it reports what the unit is going to do. The rule is the newest ISO matching the image's own glob, and that glob is `[Ww]in*.iso` for Windows rather than `*.iso` because the newest ISO in that directory, with an Ubuntu one sitting next to it, is how a Windows install would quietly boot the Ubuntu installer. The glob now comes from the registry rather than from a prefix test here, so there is one statement of it - and a machine added to configs/vms.toml brings its own. """ pattern = next((m["iso"] for m in machines() if m["id"] == name), f"{name}*.iso") try: candidates = [f for f in os.listdir(ISOS) if fnmatch.fnmatch(f, pattern)] except OSError: return "" if not candidates: return "" return max(candidates, key=lambda f: os.path.getmtime(os.path.join(ISOS, f))) # THERE IS NO prepare_install() HERE ANY MORE, AND THAT IS THE FIX. # # This tool used to create the blank disk, copy the firmware variables # and write the install marker itself, as the operator. It failed with a # bare permission error the first time it was used for Windows, and the # reason is the trap written at the top of owuivm-maint-prepare: `operator` # IS a member of owuivmadm in /etc/group, but a unix group only reaches a # process at its NEXT login, and the desktop session running this tool # predates the usermod that added it. So the group is there, `id` agrees, # and no running process has it. # # It was also quietly wrong for Windows in a second way: it copied the # plain OVMF variables for both images, where Windows needs the store # with Microsoft's keys already enrolled (OVMF_VARS_4M.ms.fd) or Secure # Boot is on with nothing it trusts and setup refuses the machine - a # failure that would have shown up much later and looked like hardware. # # The unit's ExecStartPre does all of it, as owuivmadm, and has done it # correctly the whole time. This tool starts units and touches nothing. class Window(Gtk.ApplicationWindow): def __init__(self, app: Gtk.Application) -> None: super().__init__(application=app, title="Tool Server VM Maintenance") self.set_default_size(680, 360) outer = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=12) outer.set_margin_top(16); outer.set_margin_bottom(16) outer.set_margin_start(16); outer.set_margin_end(16) self.set_child(outer) intro = Gtk.Label(xalign=0, wrap=True) intro.set_markup( "Base images for the VM sub-agent.\n" "Every session runs from a read-only copy of these, so nothing the agent " "installs survives. Start boots a frozen image the same way, on a " "throwaway copy — use it to check that an image still comes up. " "Unfreeze when you actually mean to change one, and freeze it again " "afterwards: no session can start while an image is writable." ) outer.append(intro) # The console bridge, started on demand and stopped with the VM. self._socat: subprocess.Popen | None = None # Set while a start or stop is in flight. The periodic refresh # must not re-enable buttons underneath an operation that has not # finished, and nothing may be pressed twice. self._busy = False self.rows: dict[str, dict] = {} for machine in machines(): outer.append(self._build_row(machine)) self.status = Gtk.Label(xalign=0, wrap=True) self.status.add_css_class("dim-label") outer.append(self.status) self.refresh() GLib.timeout_add_seconds(3, self._tick) def _build_row(self, machine: dict) -> Gtk.Widget: name = machine["id"] frame = Gtk.Frame() box = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=12) box.set_margin_top(10); box.set_margin_bottom(10) box.set_margin_start(12); box.set_margin_end(12) frame.set_child(box) labels = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=2, hexpand=True) title = Gtk.Label(xalign=0) title.set_markup(f"{GLib.markup_escape_text(machine['name'])}") detail = Gtk.Label(xalign=0) detail.add_css_class("dim-label") labels.append(title); labels.append(detail) box.append(labels) # The label is the operator's, the id is not. Renaming writes an # override file through a unit; nothing about the disk, the unit # instance or the `os` the assistant passes ever changes, which is # why this is safe to offer at all. rename = Gtk.Button(label="Rename") rename.connect("clicked", self.on_rename, name) box.append(rename) # FREEZING IS NOT A SIDE EFFECT OF STARTING. # # It used to be: ExecStartPre unfroze and ExecStopPost re-froze, # so the only way to look at an image was to make it writable # first, and there was no unfreeze button because there was # nothing for it to do. That is backwards for the most useful # thing here - booting the image exactly as the agent will get # it, to see that it comes up, with no way to change it by being # there. # # So this button owns the write bit and nothing else does. Start # then boots whatever mode the disk is in: frozen boots a # throwaway copy, unfrozen boots the image itself. mode = Gtk.Button(label="Unfreeze") mode.connect("clicked", self.on_mode, name) box.append(mode) # Buttons that mean one thing each, rather than one button whose # meaning depends on state. Shutting a machine down and starting # one are different enough that the operator should not have to # read the label to find out which they are about to do. start = Gtk.Button(label="Start") start.connect("clicked", self.on_start, name) box.append(start) stop = Gtk.Button(label="Shut down") stop.add_css_class("destructive-action") stop.connect("clicked", self.on_stop, name) box.append(stop) stop.set_tooltip_text("Asks the guest to power off and waits for it.") # SAVING A MACHINE IS AN OPERATOR DECISION, AND IT HAS TO BE VISIBLE. # # A saved machine is tied to the image it was taken from by a # checksum, so changing the image silently retires it - which is # correct, and would otherwise mean every session quietly went # back to booting from cold with nobody knowing why. The row says # which state it is in, and this is how it gets back to `usable`. snap = Gtk.Button(label="Save fast start") snap.connect("clicked", self.on_snapshot, name) box.append(snap) console = Gtk.Button(label="Console") console.connect("clicked", self.on_console, name) box.append(console) # Only ever visible while an install is in progress, which is the # only time the question it answers can be asked. Pressing it is # the operator saying "this image is installed now" - nothing on # this host can work that out for itself, and the one time # something tried to (a disk over 512 MB was taken for a finished # install) it declared a half-finished Ubuntu done and left the # machine at an EFI shell with no way back to the installer. detach = Gtk.Button(label="Installed — remove disc") detach.set_visible(False) detach.connect("clicked", self.on_detach, name) box.append(detach) # Only while there is no disk yet, because that is the only time # it answers a question. Before this existed, a machine with no # media said "not installed, and no ubuntu ISO in # /var/lib/owuivm/iso" with a dead Start button and no way # forward that did not involve a terminal. insert = Gtk.Button(label="Insert disc…") insert.set_visible(False) insert.connect("clicked", self.on_insert, name) box.append(insert) self.rows[name] = {"title": title, "detail": detail, "mode": mode, "start": start, "stop": stop, "console": console, "rename": rename, "detach": detach, "snap": snap, "insert": insert} return frame # -- state ----------------------------------------------------------- def _tick(self) -> bool: self.refresh() return True def refresh(self) -> None: for machine in machines(): name = machine["id"] state = image_state(name) row = self.rows[name] fresh = not state["exists"] and not state["running"] # Re-read every tick so a rename shows up without a restart, # and so does one made by editing the override file by hand. row["title"].set_markup(f"{GLib.markup_escape_text(machine['name'])}") row["rename"].set_sensitive(not self._busy) row["start"].set_sensitive(not state["running"] and not self._busy) row["stop"].set_sensitive(state["running"] and not self._busy) row["console"].set_sensitive(state["running"] and not self._busy) # One button, two verbs, and the label is the state: if it # says Unfreeze, the image is frozen. Dead while the machine # is up, because qemu already holds the disk open - a chmod # then would change what the mode SAYS and nothing else. row["mode"].set_label("Unfreeze" if state["frozen"] else "Freeze") row["mode"].set_sensitive( state["exists"] and not state["running"] and not self._busy) row["mode"].set_tooltip_text( "Shut the machine down first." if state["running"] else "Make the base image writable, so changes are kept." if state["frozen"] else "Make the base image read-only again. No session can start until it is.") if state["frozen"]: row["mode"].remove_css_class("destructive-action") else: row["mode"].add_css_class("destructive-action") # Deliberately dead while the machine is running: marking an # install finished is a claim about a machine that has been # shut down and frozen, and the disc is what a half-installed # one has to boot to be finished. snap = snapshot_state(name) row["snap"].set_sensitive( state["exists"] and state["frozen"] and not state["running"] and not state["installing"] and not self._busy) row["snap"].set_label({"usable": "Re-save fast start", "stale": "Fast start is STALE", "none": "Save fast start"}.get(snap, "Save fast start")) row["snap"].set_tooltip_text( "Shut the machine down first." if state["running"] else "Freeze the image first - a saved machine is tied to the image it " "was taken from." if not state["frozen"] else "Boot this image once and save it, so sessions resume in seconds " "instead of booting. Takes a few minutes.") if snap == "stale": row["snap"].add_css_class("destructive-action") else: row["snap"].remove_css_class("destructive-action") row["detach"].set_visible(state["installing"]) row["detach"].set_sensitive( state["installing"] and not state["running"] and not self._busy) row["detach"].set_tooltip_text( "Shut the machine down first." if state["running"] else "Stop booting the installer disc. The machine boots itself from now on.") # Offering a disc is only meaningful while there is no disk: # once a machine is installed, media is chosen by the unit and # changing it would mean reinstalling. row["insert"].set_visible(fresh) row["insert"].set_sensitive(fresh and not self._busy) if fresh: iso = media_for(name) row["detail"].set_text( f"not installed — will install from {iso}" if iso else f"not installed. Choose an installation disc with " f"Insert disc…, or put one in {ISOS}.") row["insert"].set_tooltip_text( f"Replace the disc this will install from ({iso})." if iso else "Choose an .iso to install this machine from.") row["start"].set_label("Install from ISO") row["start"].set_sensitive(bool(iso) and not self._busy) continue row["start"].set_label("Start") size = f"{state['size'] / 1e9:.1f} GB" if state["exists"] else "installing" if state["running"] and state["installing"]: row["detail"].set_markup( f"{size} — INSTALLING. Drive the installer in the console. " "When it has finished, press Shut down.") elif state["running"] and state["frozen"]: row["detail"].set_markup( f"{size} — running from a throwaway copy. This is what the " "agent gets. Nothing you do in here is kept.") elif state["running"]: row["detail"].set_markup( f"{size} — running UNFROZEN. Changes are kept. Shut down, " "then press Freeze.") elif state["installing"] and state["frozen"]: # An install into a throwaway overlay is never what anyone # meant. It happened: a test left this image frozen, the # operator pressed Start, and a 16 GB Windows install went # into a disk that was going to be deleted - on the disk # array, which is four spinning drives, so it was also # much slower than it should have been. row["detail"].set_markup( f"{size} — frozen, with an installer disc attached. Starting it " "now would install into a throwaway copy and lose it. Press " "Unfreeze first.") elif state["installing"]: row["detail"].set_markup( f"{size} — unfrozen, with the installer disc attached. If the install " "is finished, press Installed — remove disc.") elif state["frozen"]: fast = {"usable": "Sessions resume from a saved machine in seconds.", "stale": "The saved machine is out of date - the image has " "changed since. Sessions are booting from cold until it " "is saved again.", "none": "Sessions boot this from cold."}.get(snap, "") row["detail"].set_markup( f"{size} - frozen. Start boots a throwaway copy of it, exactly " f"as the agent would; Unfreeze first to make changes that stick. " f"{fast}") else: # Loud on purpose. Nothing re-freezes an image any more, # and a session refuses to start while one is writable - # so this row is the difference between a working agent # and a confusing refusal an hour from now. row["detail"].set_markup( f"{size} — UNFROZEN. No agent session can start until this is " "frozen again.") def say(self, text: str) -> None: self.status.set_text(text) # -- actions --------------------------------------------------------- def on_stop(self, _button: Gtk.Button, name: str) -> None: """Press the power button and wait for the guest. `systemctl stop` is the whole of it: the unit's ExecStop sends an ACPI power-down over QMP and systemd waits for qemu to exit on its own, falling back to a kill after TimeoutStopSec. Before that existed, stopping the unit yanked power on the disk the operator was editing. """ if not image_state(name)["running"]: self.say(f"{name} is not running.") return self._stop_relay() self._busy = True self.refresh() self.say(f"Shutting {name} down — the guest is being asked to power off, which can " "take a minute. The window stays usable.") def done(code: int, out: str) -> bool: self._busy = False self.say(f"{name} is shut down." if code == 0 else f"Could not stop it cleanly: {out}") self.refresh() return False run_async(["systemctl", "stop", unit_for(name)], 600, done) def on_mode(self, _button: Gtk.Button, name: str) -> None: """Freeze or unfreeze, whichever the image is not. Both are one chmod in a directory this account does not own, so each is a polkit-gated unit - the same gate that starts a maintenance boot, and the same reason: a unix group would only take effect at the operator's next login. Refused while the machine is running, and the button is dead then too. qemu holds the disk open from the moment it starts, so a chmod in the middle changes what `ls -l` says without changing anything about what is running - the worst kind of control. """ state = image_state(name) if not state["exists"]: self.say(f"There is no {name} image yet.") return if state["running"]: self.say(f"Shut {name} down first — its disk is open.") return verb = "thaw" if state["frozen"] else "freeze" self._busy = True self.refresh() def done(code: int, out: str) -> bool: self._busy = False if code != 0: self.say(f"Could not {verb} {name}: {out}") elif verb == "thaw": self.say(f"{name} is unfrozen. Changes you make from now on are kept — " "and no agent session can start until you freeze it again.") else: self.say(f"{name} is frozen. Sessions can run, and starting it here now " "boots a throwaway copy.") self.refresh() return False run_async(["systemctl", "start", f"owuivm-{verb}@{name}.service"], 120, done) def on_snapshot(self, _button: Gtk.Button, name: str) -> None: """Boot the image once and save it, so sessions resume in seconds. Runs through vmd rather than here: it needs a real session, the machine it saves must be one an agent would be handed, and the state has to be written by the user that owns the saved-machine directory. This tool starts things and reports; it does not own any of that. """ state = image_state(name) if state["running"]: self.say(f"Shut {name} down first.") return if not state["frozen"]: self.say(f"Freeze {name} first - a saved machine is tied to the image it " "was taken from, and an unfrozen image is one that can still change.") return self._busy = True self.refresh() self.say(f"Booting {name} once and saving it. This takes a few minutes, and the " "window stays usable.") def done(code: int, out: str) -> bool: self._busy = False self.say(f"{name} can now start from a saved machine." if code == 0 else f"Could not save {name}: {out[-300:]}") self.refresh() return False run_async(["sudo", "-n", "-u", "owuivmd", "/usr/local/bin/vmctl", "snapshot", "--os", name], 1800, done) def on_detach(self, _button: Gtk.Button, name: str) -> None: """Say that the install is finished, so the disc stops being attached. The marker file lives in a directory this account does not own, so this goes through owuivm-detach@.service and the same polkit check that starts a maintenance boot. Doing it by hand here would need the owuivmadm group, and a unix group only takes effect at the operator's NEXT login - a trap this project has already walked into once, and one that fails confusingly rather than loudly. Nothing is lost if it is pressed too early: the disc is still in /var/lib/owuivm/iso and a wrong call is undone by deleting the disk and installing again. """ state = image_state(name) if state["running"]: self.say(f"Shut {name} down first — then the disc can be removed.") return if not state["installing"]: self.say(f"{name} is not set to boot an installer.") return self._busy = True self.refresh() def done(code: int, out: str) -> bool: self._busy = False self.say(f"{name} is installed. It boots itself from now on; the disc is " "no longer attached." if code == 0 else f"Could not remove the disc: {out}") self.refresh() return False run_async(["systemctl", "start", f"owuivm-detach@{name}.service"], 60, done) # -- naming and media ------------------------------------------------ def on_rename(self, _button: Gtk.Button, name: str) -> None: """Change what a machine is called. Only what it is called. The id stays: it is the disk filename, the unit instance, the saved-machine directory and the value the assistant passes as `os`, and it appears in regex allowlists inside the privileged launch scripts. A rename that moved it would not rename a machine, it would describe one that does not exist. """ current = next((m["name"] for m in machines() if m["id"] == name), name) entry = Gtk.Entry(text=current, activates_default=True, max_length=40) dialog = Gtk.Window(transient_for=self, modal=True, title=f"Rename {current}") box = Gtk.Box(orientation=Gtk.Orientation.VERTICAL, spacing=10) box.set_margin_top(16); box.set_margin_bottom(16) box.set_margin_start(16); box.set_margin_end(16) label = Gtk.Label(xalign=0, wrap=True) label.set_markup(f"What should this machine be called?\n" f"Its id stays {GLib.markup_escape_text(name)} — " "that is what the disk, the unit and the assistant use.") box.append(label); box.append(entry) buttons = Gtk.Box(orientation=Gtk.Orientation.HORIZONTAL, spacing=8, halign=Gtk.Align.END) cancel = Gtk.Button(label="Cancel") save = Gtk.Button(label="Rename") save.add_css_class("suggested-action") buttons.append(cancel); buttons.append(save) box.append(buttons) dialog.set_child(box) cancel.connect("clicked", lambda *_: dialog.destroy()) def commit(*_args) -> None: wanted = entry.get_text().strip() dialog.destroy() if not wanted or wanted == current: return self._rename(name, wanted) save.connect("clicked", commit) entry.connect("activate", commit) dialog.present() def _rename(self, name: str, wanted: str) -> None: """Stage the name, then let the unit record it. Same shape as inserting a disc, and for the same reason: a systemd unit instance carries one string and that string has to be the machine id, so the name travels through the staging directory the operator owns. """ try: os.makedirs(INCOMING, exist_ok=True) with open(os.path.join(INCOMING, f"{name}.name"), "w", encoding="utf-8") as handle: handle.write(wanted) except OSError as exc: self.say(f"Could not stage the new name: {exc}") return self._busy = True self.refresh() def done(code: int, out: str) -> bool: self._busy = False self.say(f"Renamed to {wanted}." if code == 0 else f"Could not rename it: {out}") self.refresh() return False run_async(["systemctl", "start", f"owuivm-rename@{name}.service"], 60, done) def on_insert(self, _button: Gtk.Button, name: str) -> None: """Choose an installation disc and put it in the library.""" dialog = Gtk.FileDialog(title=f"Installation disc for {name}") media = Gtk.FileFilter() media.set_name("Disc images") media.add_pattern("*.iso") media.add_pattern("*.ISO") filters = Gio.ListStore.new(Gtk.FileFilter) filters.append(media) dialog.set_filters(filters) dialog.set_default_filter(media) def chosen(source, result) -> None: try: picked = source.open_finish(result) except GLib.Error: return # the operator pressed Cancel path = picked.get_path() if picked else None if not path: self.say("That disc is not a local file, so it cannot be copied in.") return self._insert(name, path) dialog.open(self, None, chosen) def _insert(self, name: str, path: str) -> None: """Copy the disc in as the operator, then have the unit check it. THE COPY HAPPENS HERE, NOT IN THE UNIT, AND THAT IS DELIBERATE. The maintenance units run with ProtectHome=yes and as owuivmadm, which is not in the operator's group - so a privileged unit can neither see nor read ~/Downloads. Running the copy as root instead would turn this button into "copy any file root can read into a world-readable directory". Copying it here needs no privilege at all: it is the operator reading their own file. It is 6-8 GB, so it runs off the main thread and the row reports progress from the size of the destination. """ try: size = os.path.getsize(path) except OSError as exc: self.say(f"Could not read that file: {exc}") return try: os.makedirs(INCOMING, exist_ok=True) except OSError as exc: self.say(f"{INCOMING} is not writable ({exc}). Re-run vm/host/install.sh.") return # The staged name carries the machine id so the unit knows which # machine it is for, and keeps the original filename so the glob # in the registry still matches it. staged = os.path.join(INCOMING, f"{name}.{os.path.basename(path)}") if not staged.lower().endswith(".iso"): staged += ".iso" self._busy = True self.refresh() self.say(f"Copying {os.path.basename(path)} ({size / 1e9:.1f} GB)…") def progress() -> bool: if not self._busy: return False try: done_bytes = os.path.getsize(staged) except OSError: return True share = (done_bytes / size * 100) if size else 0 self.say(f"Copying {os.path.basename(path)} — {share:.0f}% " f"({done_bytes / 1e9:.1f} of {size / 1e9:.1f} GB)") return True GLib.timeout_add_seconds(1, progress) def copy_then_insert() -> None: try: with open(path, "rb") as src, open(staged, "wb") as dst: shutil.copyfileobj(src, dst, 8 * 1024 * 1024) except OSError as exc: GLib.idle_add(self._insert_failed, f"the copy failed: {exc}", staged) return code, out = run("systemctl", "start", f"owuivm-insert@{name}.service", timeout=180) GLib.idle_add(self._insert_done, name, code, out, staged) threading.Thread(target=copy_then_insert, daemon=True).start() def _insert_failed(self, why: str, staged: str) -> bool: self._busy = False try: os.unlink(staged) except OSError: pass self.say(f"Could not insert the disc: {why}") self.refresh() return False def _insert_done(self, name: str, code: int, out: str, staged: str) -> bool: self._busy = False if code == 0: iso = media_for(name) self.say(f"{iso or 'The disc'} is in the library. Press Install from ISO to " "create the machine and boot it.") else: # The unit deletes what it rejects, but a failure before that # leaves a part-copied file that would look like usable media. try: os.unlink(staged) except OSError: pass self.say(f"The disc was not accepted: {out}") self.refresh() return False def on_start(self, _button: Gtk.Button, name: str) -> None: state = image_state(name) if state["running"]: self.say(f"{name} is already running.") return if not state["exists"]: iso = media_for(name) if not iso: self.say(f"There is no {name} ISO in {ISOS}.") return # The unit makes the disk, the firmware variables and the # install marker, as the user that owns them. This tool used # to do it here and could not - see the note above media_for. busy = [o for o in ids() if o != name and image_state(o)["running"]] if busy: self.say(f"{busy[0].capitalize()} is open. Shut that down first — " "only one machine runs at a time.") return self.say(f"Creating a blank disk and booting {iso}… " "(you may be asked to authorise)") self._busy = True self.refresh() def installed(code: int, out: str) -> bool: self._busy = False if code != 0: self.say(f"Could not start the installer: {out}") else: self.say("The installer is booting. Opening the console…") GLib.timeout_add_seconds(4, lambda: (self.on_console(None, name), False)[1]) self.refresh() return False run_async(["systemctl", "start", unit_for(name)], 300, installed) return # Two machines at once would be two qemu processes with a base # disk each, splitting the host's memory between them. # The unit's Conflicts= catches a session overlapping maintenance; # the prepare step catches the other image; this is the friendly # version of the same refusal, before anything is attempted. busy = [other for other in ids() if other != name and image_state(other)["running"]] if busy: self.say(f"{busy[0].capitalize()} is already open. Shut that down first.") return self.say(f"Starting {name}… (you may be asked to authorise)") self._busy = True self.refresh() def started(code: int, out: str) -> bool: self._busy = False if code != 0: self.say(f"Could not start it: {out}") else: self.say(f"{name} is running. Opening the console…") GLib.timeout_add_seconds(3, lambda: (self.on_console(None, name), False)[1]) self.refresh() return False run_async(["systemctl", "start", unit_for(name)], 300, started) def _relay(self) -> bool: """Bridge the console's unix socket to a loopback port for the viewer. qemu serves the console on a unix socket rather than a TCP port, because this unit denies 127.0.0.0/8 and systemd's filter checks the SOURCE address on the way IN as well as the destination on the way out - so a loopback VNC port is dropped by the same rule that stops the guest reaching the host. See owuivm-launch. socat runs here, as the operator, outside qemu's cgroup, so the filter does not apply to it. VNC clients speak TCP and that is that. """ if self._socat and self._socat.poll() is None: return True if not os.path.exists(VNC_SOCKET): self.say("The console socket is not there yet; give it a moment.") return False if not shutil.which("socat"): self.say("socat is not installed, so the console cannot be bridged.") return False try: self._socat = subprocess.Popen( ["socat", f"TCP-LISTEN:{VNC.split(':')[1]},bind=127.0.0.1,reuseaddr,fork", f"UNIX-CONNECT:{VNC_SOCKET}"], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) except OSError as exc: self.say(f"Could not bridge the console: {exc}") return False time.sleep(0.4) return True def _stop_relay(self) -> None: if self._socat and self._socat.poll() is None: self._socat.terminate() self._socat = None def on_console(self, _button: Gtk.Button | None, name: str) -> None: if not image_state(name)["running"]: self.say(f"{name} is not running.") return if not self._relay(): return # remmina is what is installed here; the others are tried in case # it is ever removed. The console is loopback-only, so a viewer # running anywhere but this desktop cannot reach it at all. for viewer, args in ( ("remmina", ["-c", f"vnc://{VNC}"]), ("vncviewer", [VNC]), ("gvncviewer", [VNC]), ("virt-viewer", ["--connect", f"vnc://{VNC}"]), ): if shutil.which(viewer): subprocess.Popen([viewer, *args], stdout=subprocess.DEVNULL, stderr=subprocess.DEVNULL) self.say(f"Opened {viewer} on {VNC}.") return self.say(f"No VNC viewer found. Connect one to {VNC} yourself.") class App(Gtk.Application): def __init__(self) -> None: super().__init__(application_id="org.owuitools.VmMaintenance") def do_activate(self) -> None: Window(self).present() if __name__ == "__main__": raise SystemExit(App().run(None))