diff options
Diffstat (limited to 'working')
| -rw-r--r-- | working/keyring-crash/deploy-ratio.sh | 65 | ||||
| -rw-r--r-- | working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch | 275 | ||||
| -rw-r--r-- | working/keyring-crash/probe.py | 300 | ||||
| -rwxr-xr-x | working/keyring-crash/run-alias-only.sh | 23 | ||||
| -rwxr-xr-x | working/keyring-crash/run-package-test.sh | 24 | ||||
| -rwxr-xr-x | working/keyring-crash/run-regression.sh | 50 | ||||
| -rwxr-xr-x | working/keyring-crash/run-upstream-tests.sh | 11 | ||||
| -rw-r--r-- | working/keyring-crash/setup-guest.sh | 40 | ||||
| -rw-r--r-- | working/keyring-crash/status.org | 118 | ||||
| -rwxr-xr-x | working/keyring-crash/vm-run.sh | 18 |
10 files changed, 924 insertions, 0 deletions
diff --git a/working/keyring-crash/deploy-ratio.sh b/working/keyring-crash/deploy-ratio.sh new file mode 100644 index 0000000..f86e290 --- /dev/null +++ b/working/keyring-crash/deploy-ratio.sh @@ -0,0 +1,65 @@ +#!/bin/bash +# Apply the patched gnome-keyring package to a live host (ratio, later velox). +# +# Runs only after the VM gates passed and only when Craig says go. Every step +# is reversible: the encrypted keyring files are backed up with their modes, +# the stock package stays in the pacman cache for a one-command rollback, and +# only the keyring service is restarted (one unlock prompt is expected, to be +# answered in the normal system dialog, never in chat). +# +# deploy-ratio.sh build build the package from the repo recipe (as the user) +# deploy-ratio.sh install back up, install, restart the daemon, verify +# deploy-ratio.sh rollback reinstall the cached stock 1:51.1-1 and restart +# deploy-ratio.sh verify show versions, owner, restart counter, recent crashes +set -euo pipefail + +repo="${ARCHSETUP_DIR:-$HOME/code/archsetup}" +recipe="$repo/packages/gnome-keyring" +build_dir="${BUILD_DIR:-$HOME/.cache/archsetup-build/gnome-keyring}" +stock_pkg="/var/cache/pacman/pkg/gnome-keyring-1:51.1-1-x86_64.pkg.tar.zst" +backup_root="$HOME/.local/share/keyrings-backup" + +verify() { + echo "host: $(uname -n)" + pacman -Q gnome-keyring glib2 + systemctl --user show gnome-keyring-daemon.service -p ActiveState -p NRestarts -p MainPID -p ExecMainStartTimestamp + busctl --user status org.freedesktop.secrets 2>/dev/null | grep -E '^(PID|Comm)=' || echo "no Secret Service owner" + echo "aborts in the last hour: $(journalctl --user -u gnome-keyring-daemon.service --since '1 hour ago' --no-pager 2>/dev/null | grep -c 'Scheduled restart job')" + sha256sum /usr/bin/gnome-keyring-daemon +} + +case "${1:-}" in +build) + [ -f "$recipe/PKGBUILD" ] || { echo "recipe missing: $recipe" >&2; exit 1; } + rm -rf "$build_dir"; mkdir -p "$build_dir"; cp -a "$recipe/." "$build_dir/" + (cd "$build_dir" && makepkg --noconfirm -s) + ls -la "$build_dir"/*.pkg.tar.zst + ;; +install) + pkg=$(ls "$build_dir"/gnome-keyring-1:51.1-1.1-x86_64.pkg.tar.zst) + [ -f "$stock_pkg" ] || { echo "stock package not in cache for rollback: $stock_pkg" >&2; exit 1; } + # Permission-protected backup of the encrypted keyring files, modes kept. + stamp=$(date +%Y%m%d-%H%M%S) + mkdir -p "$backup_root"; chmod 700 "$backup_root" + cp -a "$HOME/.local/share/keyrings" "$backup_root/keyrings-$stamp" + chmod -R go-rwx "$backup_root" + echo "backup: $backup_root/keyrings-$stamp" + sudo pacman -U --noconfirm "$pkg" + # Only the keyring service restarts; the socket unit stays. One unlock + # prompt is expected afterwards. + systemctl --user restart gnome-keyring-daemon.service + sleep 2 + verify + ;; +rollback) + sudo pacman -U --noconfirm "$stock_pkg" + systemctl --user restart gnome-keyring-daemon.service + sleep 2 + verify + ;; +verify) + verify + ;; +*) + sed -n '2,13p' "$0"; exit 2 ;; +esac diff --git a/working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch b/working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch new file mode 100644 index 0000000..9ef298b --- /dev/null +++ b/working/keyring-crash/gnome-keyring-51.1-secret-service-missing-client.patch @@ -0,0 +1,275 @@ +diff -ruN a/daemon/dbus/gkd-secret-objects.c b/daemon/dbus/gkd-secret-objects.c +--- a/daemon/dbus/gkd-secret-objects.c 2026-09-17 15:41:00.000000000 -0500 ++++ b/daemon/dbus/gkd-secret-objects.c 2026-10-17 06:47:19.275009074 -0500 +@@ -430,7 +430,11 @@ + + /* The session we're using to access the object */ + session = gkd_secret_service_get_pkcs11_session (self->service, sender); +- g_return_val_if_fail (session, FALSE); ++ if (session == NULL) { ++ g_free (c_ident); ++ g_free (i_ident); ++ goto out; ++ } + + if (i_ident) { + gck_builder_add_ulong (&builder, CKA_CLASS, CKO_SECRET_KEY); +@@ -1055,17 +1059,23 @@ + if (!gkd_secret_util_parse_path (path, &identifier, NULL)) + return NULL; + +- if (g_str_has_prefix (path, SECRET_ALIAS_PREFIX)) +- real_identifier = gkd_secret_service_get_alias (self->service, identifier); +- else +- real_identifier = identifier; +- +- /* The session we're using to access the object */ ++ /* The session we're using to access the object. Resolve it before the ++ * alias: fetching a caller's session may register the caller, which ++ * refreshes the default alias and frees the string the alias table ++ * held, so an alias looked up earlier would dangle here. */ + if (caller == NULL) + session = gkd_secret_service_internal_pkcs11_session (self->service); + else + session = gkd_secret_service_get_pkcs11_session (self->service, caller); +- g_return_val_if_fail (session, NULL); ++ if (session == NULL) { ++ g_free (identifier); ++ return NULL; ++ } ++ ++ if (g_str_has_prefix (path, SECRET_ALIAS_PREFIX)) ++ real_identifier = gkd_secret_service_get_alias (self->service, identifier); ++ else ++ real_identifier = identifier; + + gck_builder_add_ulong (&builder, CKA_CLASS, CKO_G_COLLECTION); + gck_builder_add_string (&builder, CKA_ID, real_identifier); +diff -ruN a/daemon/dbus/gkd-secret-service.c b/daemon/dbus/gkd-secret-service.c +--- a/daemon/dbus/gkd-secret-service.c 2026-09-17 15:41:00.000000000 -0500 ++++ b/daemon/dbus/gkd-secret-service.c 2026-10-17 06:47:19.274715149 -0500 +@@ -137,6 +137,9 @@ + GHashTable *clients; + GkdSecretObjects *objects; + GHashTable *aliases; ++ /* The message filter reads aliases on the GDBus worker thread while ++ * the main thread updates them; every access goes through this lock. */ ++ GMutex aliases_lock; + GckSession *internal_session; + gchar *default_path; + }; +@@ -190,6 +193,18 @@ + return g_build_filename (alias_directory, "default", NULL); + } + ++/* An owned copy of an alias, safe to call from any thread. */ ++static gchar * ++lookup_alias_copy (GkdSecretService *self, const gchar *alias) ++{ ++ gchar *copy; ++ ++ g_mutex_lock (&self->aliases_lock); ++ copy = g_strdup (g_hash_table_lookup (self->aliases, alias)); ++ g_mutex_unlock (&self->aliases_lock); ++ return copy; ++} ++ + static void + update_default (GkdSecretService *self) + { +@@ -206,21 +221,31 @@ + /* Default to to 'login' if no default keyring */ + if (contents == NULL) + contents = g_strdup ("login"); +- g_hash_table_replace (self->aliases, g_strdup ("default"), contents); ++ ++ /* Replace only on a real change: callers on the main thread hold the ++ * previous value borrowed, and the common case (another client ++ * registering) must not free it out from under them. */ ++ g_mutex_lock (&self->aliases_lock); ++ if (g_strcmp0 (g_hash_table_lookup (self->aliases, "default"), contents) == 0) ++ g_free (contents); ++ else ++ g_hash_table_replace (self->aliases, g_strdup ("default"), contents); ++ g_mutex_unlock (&self->aliases_lock); + } + + static void + store_default (GkdSecretService *self) + { + GError *error = NULL; +- const gchar *identifier; ++ gchar *identifier; + +- identifier = g_hash_table_lookup (self->aliases, "default"); ++ identifier = lookup_alias_copy (self, "default"); + if (!identifier) + return; + + if (!g_file_set_contents (self->default_path, identifier, -1, &error)) + g_message ("couldn't store default keyring: %s", egg_error_message (error)); ++ g_free (identifier); + } + + static gboolean +@@ -331,7 +356,7 @@ + GDBusMessage *message) + { + const char *path = g_dbus_message_get_path (message); +- const char *replace; ++ char *replace; + char *collection = NULL, *item = NULL; + char *collection_path, *item_path; + GDBusMessage *rewritten; +@@ -346,7 +371,9 @@ + if (!gkd_secret_util_parse_path (path, &collection, &item)) + return message; + +- replace = gkd_secret_service_get_alias (self, collection); ++ /* This runs on the GDBus worker thread: take a copy under the lock ++ * rather than borrowing from a table the main thread may be updating. */ ++ replace = lookup_alias_copy (self, collection); + if (!replace) { + g_free (item); + g_free (collection); +@@ -356,6 +383,9 @@ + rewritten = g_dbus_message_copy (message, &error); + if (error != NULL) { + g_error_free (error); ++ g_free (replace); ++ g_free (item); ++ g_free (collection); + return message; + } + +@@ -372,6 +402,7 @@ + } + + g_free (collection_path); ++ g_free (replace); + g_free (item); + g_free (collection); + g_object_unref (message); +@@ -424,18 +455,30 @@ + GError *error = NULL; + const gchar *caller; + GVariant *input_payload; ++ gboolean opened; + + caller = g_dbus_method_invocation_get_sender (invocation); + + /* Now we can create a session with this information */ + session = gkd_secret_session_new (self, caller); + input_payload = g_variant_get_variant (input); +- gkd_secret_session_handle_open (session, algorithm, input_payload, +- &output, &result, +- &error); ++ opened = gkd_secret_session_handle_open (session, algorithm, input_payload, ++ &output, &result, ++ &error); + g_variant_unref (input_payload); + +- if (error != NULL) { ++ /* Branch on the return value, not on the error alone: a negotiate helper ++ * that bails out through g_return_val_if_fail() reports failure without ++ * ever setting one, and completing the call with a NULL body makes ++ * g_variant_new() abort the whole daemon. */ ++ if (!opened || output == NULL || result == NULL) { ++ if (error == NULL) ++ g_set_error_literal (&error, G_DBUS_ERROR, ++ G_DBUS_ERROR_FAILED, ++ "Couldn't open a session for this caller."); ++ if (output != NULL) ++ g_variant_unref (g_variant_ref_sink (output)); ++ g_free (result); + g_dbus_method_invocation_take_error (invocation, error); + } else { + gkd_secret_service_publish_dispatch (self, caller, +@@ -986,6 +1029,7 @@ + static void + gkd_secret_service_init_aliases (GkdSecretService *self) + { ++ g_mutex_init (&self->aliases_lock); + self->aliases = g_hash_table_new_full (g_str_hash, g_str_equal, g_free, g_free); + g_hash_table_insert (self->aliases, g_strdup ("session"), g_strdup ("session")); + /* TODO: We should be using CKA_G_LOGIN_COLLECTION */ +@@ -1151,6 +1195,7 @@ + + g_hash_table_destroy (self->aliases); + self->aliases = NULL; ++ g_mutex_clear (&self->aliases_lock); + + g_free (self->default_path); + self->default_path = NULL; +@@ -1283,7 +1328,18 @@ + g_return_val_if_fail (caller, NULL); + + client = g_hash_table_lookup (self->clients, caller); +- g_return_val_if_fail (client, NULL); ++ if (client == NULL) { ++ /* The record is normally created by a high priority idle queued from ++ * the message filter, but that idle is not ordered against the method ++ * call that queued it: a peer which connects and calls straight away ++ * can reach us first. Create it here instead of failing the call -- ++ * it is a hash table insert, not I/O. Callers must not hold borrowed ++ * alias strings across this call: registering a client refreshes the ++ * default alias. */ ++ gkd_secret_service_ensure_client (self, caller); ++ client = g_hash_table_lookup (self->clients, caller); ++ g_return_val_if_fail (client, NULL); ++ } + + /* Open a new session if necessary */ + if (!client->pkcs11_session) { +@@ -1399,7 +1455,9 @@ + g_return_if_fail (GKD_SECRET_IS_SERVICE (self)); + g_return_if_fail (alias); + ++ g_mutex_lock (&self->aliases_lock); + g_hash_table_replace (self->aliases, g_strdup (alias), g_strdup (identifier)); ++ g_mutex_unlock (&self->aliases_lock); + + if (g_str_equal (alias, "default")) + store_default (self); +@@ -1418,6 +1476,10 @@ + + /* Take ownership of the session */ + client = g_hash_table_lookup (self->clients, caller); ++ if (client == NULL) { ++ gkd_secret_service_ensure_client (self, caller); ++ client = g_hash_table_lookup (self->clients, caller); ++ } + g_return_if_fail (client); + path = gkd_secret_dispatch_get_object_path (object); + g_return_if_fail (!g_hash_table_lookup (client->dispatch, path)); +diff -ruN a/daemon/dbus/gkd-secret-session.c b/daemon/dbus/gkd-secret-session.c +--- a/daemon/dbus/gkd-secret-session.c 2026-09-17 15:41:00.000000000 -0500 ++++ b/daemon/dbus/gkd-secret-session.c 2026-10-17 06:47:19.270295281 -0500 +@@ -183,7 +183,12 @@ + gboolean ret; + + session = gkd_secret_service_get_pkcs11_session (self->service, self->caller); +- g_return_val_if_fail (session, FALSE); ++ if (session == NULL) { ++ g_set_error_literal (error_out, G_DBUS_ERROR, ++ G_DBUS_ERROR_FAILED, ++ "Couldn't open a session for this caller."); ++ return FALSE; ++ } + + if (!aes_create_dh_keys (session, "ietf-ike-grp-modp-1024", &pub, &priv)) { + g_set_error_literal (error_out, G_DBUS_ERROR, +@@ -250,7 +255,12 @@ + GckSession *session; + + session = gkd_secret_service_get_pkcs11_session (self->service, self->caller); +- g_return_val_if_fail (session, FALSE); ++ if (session == NULL) { ++ g_set_error_literal (error_out, G_DBUS_ERROR, ++ G_DBUS_ERROR_FAILED, ++ "Couldn't open a session for this caller."); ++ return FALSE; ++ } + + gck_builder_add_ulong (&builder, CKA_CLASS, CKO_SECRET_KEY); + gck_builder_add_ulong (&builder, CKA_KEY_TYPE, CKK_G_NULL); diff --git a/working/keyring-crash/probe.py b/working/keyring-crash/probe.py new file mode 100644 index 0000000..b18f7a7 --- /dev/null +++ b/working/keyring-crash/probe.py @@ -0,0 +1,300 @@ +#!/usr/bin/env python3 +"""Secret Service stress probe for the disposable keyring guest only. + +Every mode drives real GIO D-Bus clients against org.freedesktop.secrets and +reports what the daemon actually answered, not just that messages were sent. +The guard at the top refuses to run anywhere but the disposable guest. + +Modes: + plain OpenSession(plain) then Close, synchronous, one per client + dh OpenSession(dh-ietf1024-sha256-aes128-cbc-pkcs7) then Close + invalid OpenSession with an unsupported algorithm; expects NotSupported + disconnect fire --burst OpenSession calls and drop the connection, no reply read + pipeline fire --burst OpenSession calls, await and validate every reply, + then Close every session + alias a fresh client's first calls are Unlock, Lock and ReadAlias on the + default alias path (the lazy client registration happens inside + these calls; exercises the alias-string lifetime) + isolation client A opens a session; client B must not be able to Close it; + A then closes it and B's own session still works + +Each batch of --batch clients is followed by a daemon round trip (ReadAlias plus +the collection's Locked property), so the counters reflect requests the daemon +actually processed. Exit status is 1 on any error or on a change of bus owner. +""" +import argparse +import collections +import concurrent.futures +import json +import os +import socket +import sys +import time + +import gi + +gi.require_version("Gio", "2.0") +from gi.repository import Gio, GLib # noqa: E402 + +SERVICE = "org.freedesktop.secrets" +SERVICE_PATH = "/org/freedesktop/secrets" +SERVICE_IFACE = "org.freedesktop.Secret.Service" +SESSION_IFACE = "org.freedesktop.Secret.Session" +COLLECTION_IFACE = "org.freedesktop.Secret.Collection" +ALIAS_PATH = "/org/freedesktop/secrets/aliases/default" +LOGIN_PATH = "/org/freedesktop/secrets/collection/login" +DH_ALGORITHM = "dh-ietf1024-sha256-aes128-cbc-pkcs7" +TIMEOUT_MS = 10000 + + +def connect(): + conn = Gio.DBusConnection.new_for_address_sync( + os.environ["DBUS_SESSION_BUS_ADDRESS"], + Gio.DBusConnectionFlags.AUTHENTICATION_CLIENT + | Gio.DBusConnectionFlags.MESSAGE_BUS_CONNECTION, + None, + None, + ) + conn.set_exit_on_close(False) + return conn + + +def call(conn, path, iface, method, args, reply_type): + return conn.call_sync( + SERVICE, path, iface, method, args, + GLib.VariantType.new(reply_type) if reply_type else None, + Gio.DBusCallFlags.NONE, TIMEOUT_MS, None, + ) + + +def owner(conn): + return conn.call_sync( + "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + "GetNameOwner", GLib.Variant("(s)", (SERVICE,)), + GLib.VariantType.new("(s)"), Gio.DBusCallFlags.NONE, 5000, None, + ).unpack()[0] + + +def barrier(conn): + """Await the daemon itself, not only the message bus, after each batch.""" + path = call(conn, SERVICE_PATH, SERVICE_IFACE, "ReadAlias", + GLib.Variant("(s)", ("default",)), "(o)").unpack()[0] + if path != LOGIN_PATH: + raise RuntimeError("Synthetic login collection disappeared") + locked = conn.call_sync( + SERVICE, path, "org.freedesktop.DBus.Properties", "Get", + GLib.Variant("(ss)", (COLLECTION_IFACE, "Locked")), + GLib.VariantType.new("(v)"), Gio.DBusCallFlags.NONE, TIMEOUT_MS, None, + ).unpack()[0] + if locked: + raise RuntimeError("Synthetic login collection became locked") + + +def open_session_body(mode): + algorithm = "plain" if mode in ("plain", "disconnect", "pipeline", "isolation") else DH_ALGORITHM + payload = GLib.Variant("s", "") if algorithm == "plain" else GLib.Variant("ay", [2]) + if mode == "invalid": + algorithm = "intentionally-unsupported" + return GLib.Variant("(sv)", (algorithm, payload)) + + +def check_open_reply(reply): + """An OpenSession reply must carry a session object path under the service.""" + _output, path = reply.unpack() + if not isinstance(path, str) or not path.startswith(SERVICE_PATH + "/session/"): + raise RuntimeError(f"OpenSession returned an invalid session path: {path!r}") + return path + + +def close_session(conn, path): + call(conn, path, SESSION_IFACE, "Close", None, None) + + +def run_sync_client(conn, mode): + try: + reply = call(conn, SERVICE_PATH, SERVICE_IFACE, "OpenSession", + open_session_body(mode), "(vo)") + except GLib.Error as exc: + if mode == "invalid" and Gio.DBusError.get_remote_error(exc) == "org.freedesktop.DBus.Error.NotSupported": + return "expected-not-supported" + raise + if mode == "invalid": + raise RuntimeError("Unsupported algorithm unexpectedly accepted") + close_session(conn, check_open_reply(reply)) + return "ok" + + +def run_disconnect_client(conn, burst): + for _ in range(burst): + message = Gio.DBusMessage.new_method_call(SERVICE, SERVICE_PATH, SERVICE_IFACE, "OpenSession") + message.set_body(open_session_body("disconnect")) + conn.send_message(message, Gio.DBusSendMessageFlags.NONE) + conn.flush_sync(None) + return "ok" + + +def run_pipeline_client(conn, burst): + """Queue --burst calls without waiting, then collect and validate every reply.""" + loop = GLib.MainLoop() + pending = {"left": burst, "paths": [], "errors": []} + + def done(source, result): + try: + pending["paths"].append(check_open_reply(source.call_finish(result))) + except Exception as exc: # noqa: BLE001 - recorded, then raised below + pending["errors"].append(str(exc)) + pending["left"] -= 1 + if pending["left"] == 0: + loop.quit() + + for _ in range(burst): + conn.call(SERVICE, SERVICE_PATH, SERVICE_IFACE, "OpenSession", + open_session_body("pipeline"), GLib.VariantType.new("(vo)"), + Gio.DBusCallFlags.NONE, TIMEOUT_MS, None, done) + GLib.timeout_add(TIMEOUT_MS + 2000, loop.quit) + loop.run() + if pending["left"]: + raise RuntimeError(f"{pending['left']} pipelined OpenSession calls never answered") + if pending["errors"]: + raise RuntimeError("pipelined OpenSession failed: " + pending["errors"][0]) + if len(set(pending["paths"])) != burst: + raise RuntimeError("pipelined sessions did not get distinct paths") + for path in pending["paths"]: + close_session(conn, path) + return "ok" + + +def run_alias_client(conn): + """First calls on a fresh connection all go through the alias path. + + Unlock on an already-unlocked collection is a no-op that still resolves + the alias inside the daemon (the alias-in-arguments path the message + filter does not rewrite). Lock is deliberately not used: it would lock the + fixture's login collection, and unlocking again needs a password prompt + the headless guest cannot show. + """ + unlocked, prompt = call(conn, SERVICE_PATH, SERVICE_IFACE, "Unlock", + GLib.Variant("(ao)", ([ALIAS_PATH],)), "(aoo)").unpack() + if unlocked != [ALIAS_PATH] or prompt != "/": + raise RuntimeError(f"Unlock via alias answered {unlocked!r}, prompt {prompt!r}") + path = call(conn, SERVICE_PATH, SERVICE_IFACE, "ReadAlias", + GLib.Variant("(s)", ("default",)), "(o)").unpack()[0] + if path != LOGIN_PATH: + raise RuntimeError(f"ReadAlias answered {path!r}") + # A property read addressed to the alias path: the filter rewrites the + # object path, then the property getter looks the collection up. + locked = conn.call_sync( + SERVICE, ALIAS_PATH, "org.freedesktop.DBus.Properties", "Get", + GLib.Variant("(ss)", (COLLECTION_IFACE, "Locked")), + GLib.VariantType.new("(v)"), Gio.DBusCallFlags.NONE, TIMEOUT_MS, None, + ).unpack()[0] + if locked: + raise RuntimeError("login collection reported locked through the alias path") + return "ok" + + +def run_isolation_client(conn_a): + conn_b = connect() + try: + path_a = check_open_reply(call(conn_a, SERVICE_PATH, SERVICE_IFACE, "OpenSession", + open_session_body("isolation"), "(vo)")) + path_b = check_open_reply(call(conn_b, SERVICE_PATH, SERVICE_IFACE, "OpenSession", + open_session_body("isolation"), "(vo)")) + if path_a == path_b: + raise RuntimeError("two clients received the same session path") + try: + close_session(conn_b, path_a) + except GLib.Error: + pass + else: + raise RuntimeError("client B was allowed to Close client A's session") + # A's session must still be alive after B's attempt: a second Close by A + # must succeed once (the session exists), and B's own session still closes. + close_session(conn_a, path_a) + close_session(conn_b, path_b) + return "ok" + finally: + conn_b.close_sync(None) + + +def run_client(mode, burst): + conn = connect() + try: + if mode in ("plain", "dh", "invalid"): + return run_sync_client(conn, mode) + if mode == "disconnect": + return run_disconnect_client(conn, burst) + if mode == "pipeline": + return run_pipeline_client(conn, burst) + if mode == "alias": + return run_alias_client(conn) + if mode == "isolation": + return run_isolation_client(conn) + raise ValueError(mode) + finally: + conn.close_sync(None) + + +def daemon_rss_kib(conn): + """RSS of the current bus owner, read via its PID; None when unavailable.""" + try: + pid = conn.call_sync( + "org.freedesktop.DBus", "/org/freedesktop/DBus", "org.freedesktop.DBus", + "GetConnectionUnixProcessID", GLib.Variant("(s)", (SERVICE,)), + GLib.VariantType.new("(u)"), Gio.DBusCallFlags.NONE, 5000, None, + ).unpack()[0] + with open(f"/proc/{pid}/status") as status: + for line in status: + if line.startswith("VmRSS:"): + return int(line.split()[1]) + except (GLib.Error, OSError): + return None + return None + + +def main(): + parser = argparse.ArgumentParser(description=__doc__, formatter_class=argparse.RawDescriptionHelpFormatter) + parser.add_argument("mode", choices=("plain", "dh", "invalid", "disconnect", "pipeline", "alias", "isolation")) + parser.add_argument("--clients", type=int, default=200) + parser.add_argument("--workers", type=int, default=16) + parser.add_argument("--burst", type=int, default=1) + parser.add_argument("--batch", type=int, default=100) + args = parser.parse_args() + if socket.gethostname() != "archsetup-test" or os.getuid() != 1000: + raise SystemExit("Refusing to run outside the disposable archsetup-test guest as UID 1000") + monitor = connect() + initial_owner = owner(monitor) + rss_before = daemon_rss_kib(monitor) + results = collections.Counter() + examples = [] + start = time.monotonic() + with concurrent.futures.ThreadPoolExecutor(max_workers=args.workers) as pool: + for offset in range(0, args.clients, args.batch): + futures = [pool.submit(run_client, args.mode, args.burst) + for _ in range(min(args.batch, args.clients - offset))] + for future in concurrent.futures.as_completed(futures): + try: + results[future.result()] += 1 + except Exception as exc: # noqa: BLE001 - counted and reported + results[type(exc).__name__] += 1 + if len(examples) < 5: + examples.append(str(exc)) + barrier(monitor) + try: + final_owner = owner(monitor) + except GLib.Error as exc: + final_owner = str(exc) + rss_after = daemon_rss_kib(monitor) + monitor.close_sync(None) + print(json.dumps({ + "mode": args.mode, "clients": args.clients, "workers": args.workers, + "burst": args.burst, "seconds": round(time.monotonic() - start, 2), + "initial_owner": initial_owner, "final_owner": final_owner, + "rss_kib_before": rss_before, "rss_kib_after": rss_after, + "results": dict(results), "errors": examples, + }), flush=True) + return 1 if examples or initial_owner != final_owner else 0 + + +if __name__ == "__main__": + sys.exit(main()) diff --git a/working/keyring-crash/run-alias-only.sh b/working/keyring-crash/run-alias-only.sh new file mode 100755 index 0000000..7816066 --- /dev/null +++ b/working/keyring-crash/run-alias-only.sh @@ -0,0 +1,23 @@ +#!/bin/bash +# The sshpass password below is the disposable test guest's throwaway root +# password (scripts/testing/archsetup-test.conf), reachable only on loopback. +# It is not a credential for anything real. +# Alias-path focus run: reset the fixture with the given daemon and drive only +# the alias mode hard (the lazy-registration race under Lock/Unlock via alias). +set -uo pipefail +label="$1"; daemon="$2"; asan="${3:-}"; clients="${4:-8000}" +here="$(cd "$(dirname "$0")" && pwd)" +ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no) +g() { sshpass -p archsetup ssh "${ssho[@]}" -p 2237 root@127.0.0.1 "$@"; } +gcp() { sshpass -p archsetup scp "${ssho[@]}" -P 2237 "$@"; } +as_user='runuser -u keyring-test -- env XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus' +echo "=== $label alias-only daemon=$daemon asan=${asan:-none} $(date -Is)" +gcp "$here/probe.py" root@127.0.0.1:/opt/keyring-vm/probe.py >/dev/null +g "rm -f /tmp/asan.log*; KEYRING_DAEMON_BINARY='$daemon' ASAN_OPTIONS='$asan' /root/setup-guest.sh" >/dev/null || { echo "FIXTURE FAILED"; exit 1; } +since=$(g 'date "+%Y-%m-%d %H:%M:%S"') +g "$as_user python3 /opt/keyring-vm/probe.py alias --clients $clients --workers 32 --batch 100" 2>&1 | tail -2; rc=${PIPESTATUS[0]} +echo "alias rc=$rc" +secret=$(g "$as_user secret-tool lookup purpose keyring-vm" 2>/dev/null); echo "secret after alias: ${secret:-empty}" +echo "--- journal"; g "journalctl --since '$since' --no-pager -o short-iso 2>/dev/null | grep -E 'gnome-keyring' | grep -iE 'assert|abort|critical|sanitizer|dumped' | head -20" +[ -n "$asan" ] && { echo "--- asan"; g 'cat /tmp/asan.log* 2>/dev/null | head -60 || echo "no asan log"'; } +echo "=== $label alias-only result: $([ "$rc" = 0 ] && [ "$secret" = synthetic-vm-secret ] && echo PASS || echo FAIL)" diff --git a/working/keyring-crash/run-package-test.sh b/working/keyring-crash/run-package-test.sh new file mode 100755 index 0000000..06a661c --- /dev/null +++ b/working/keyring-crash/run-package-test.sh @@ -0,0 +1,24 @@ +#!/bin/bash +# The sshpass password below is the disposable test guest's throwaway root +# password (scripts/testing/archsetup-test.conf), reachable only on loopback. +# It is not a credential for anything real. +# Build the repo's gnome-keyring package in the guest with makepkg, install it +# with pacman, and run the regression through the installed daemon. +set -uo pipefail +here="$(cd "$(dirname "$0")" && pwd)" +ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no) +g() { sshpass -p archsetup ssh "${ssho[@]}" -p 2237 root@127.0.0.1 "$@"; } +gcp() { sshpass -p archsetup scp "${ssho[@]}" -P 2237 "$@"; } +echo "=== package build $(date -Is)" +g 'rm -rf /home/keyring-test/pkgbuild; mkdir -p /home/keyring-test/pkgbuild' +gcp -r "$here/../../packages/gnome-keyring/." root@127.0.0.1:/home/keyring-test/pkgbuild/ >/dev/null +# the tarball is already on the guest; makepkg verifies its checksum anyway +g 'cp /root/gnome-keyring-51.1.tar.xz /home/keyring-test/pkgbuild/; chown -R keyring-test: /home/keyring-test/pkgbuild +pacman -S --noconfirm --needed base-devel docbook-xsl git glib2-devel meson p11-kit python >/tmp/deps.log 2>&1 || { echo "DEPS FAILED"; tail -5 /tmp/deps.log; exit 1; } +cd /home/keyring-test/pkgbuild && runuser -u keyring-test -- makepkg --noconfirm --skippgpcheck >/tmp/makepkg.log 2>&1 || { echo "MAKEPKG FAILED"; tail -30 /tmp/makepkg.log; exit 1; } +ls -la /home/keyring-test/pkgbuild/*.pkg.tar.zst +grep -E "^(Ok|Fail|Timeout|Expected Fail|Unexpected Pass|Skipped):" /tmp/makepkg.log | tr "\n" " "; echo +pacman -U --noconfirm /home/keyring-test/pkgbuild/gnome-keyring-1:51.1-1.1-x86_64.pkg.tar.zst >/tmp/pacman-u.log 2>&1 || { echo "PACMAN -U FAILED"; tail -5 /tmp/pacman-u.log; exit 1; } +pacman -Q gnome-keyring; sha256sum /usr/bin/gnome-keyring-daemon' +echo "=== regression via installed daemon" +"$here/run-regression.sh" installed /usr/bin/gnome-keyring-daemon diff --git a/working/keyring-crash/run-regression.sh b/working/keyring-crash/run-regression.sh new file mode 100755 index 0000000..a853e40 --- /dev/null +++ b/working/keyring-crash/run-regression.sh @@ -0,0 +1,50 @@ +#!/bin/bash +# The sshpass password below is the disposable test guest's throwaway root +# password (scripts/testing/archsetup-test.conf), reachable only on loopback. +# It is not a credential for anything real. +# Host-side regression driver for the disposable keyring guest. +# run-regression.sh <label> <daemon-binary-in-guest> [ASAN_OPTIONS] +# Resets the guest fixture with that daemon, runs every probe mode, verifies +# the synthetic secret after each mode, and prints a summary. Output goes to +# stdout; the caller redirects it to a log. Exit 1 on any failure. +set -uo pipefail +label="$1"; daemon="$2"; asan="${3:-}" +here="$(cd "$(dirname "$0")" && pwd)" +PORT="${PORT:-2237}" +ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no) +g() { sshpass -p archsetup ssh "${ssho[@]}" -p "$PORT" root@127.0.0.1 "$@"; } +gcp() { sshpass -p archsetup scp "${ssho[@]}" -P "$PORT" "$@"; } +as_user='runuser -u keyring-test -- env XDG_RUNTIME_DIR=/run/user/1000 DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus' +fail=0 +echo "=== $label: daemon=$daemon asan=${asan:-none} $(date -Is)" +gcp "$here/probe.py" root@127.0.0.1:/opt/keyring-vm/probe.py >/dev/null +gcp "$here/setup-guest.sh" root@127.0.0.1:/root/setup-guest.sh >/dev/null +g "chmod +x /root/setup-guest.sh; KEYRING_DAEMON_BINARY='$daemon' ASAN_OPTIONS='$asan' /root/setup-guest.sh" || { echo "FIXTURE FAILED"; exit 1; } +since=$(g 'date "+%Y-%m-%d %H:%M:%S"') +owner0=$(g "$as_user busctl --user status org.freedesktop.secrets 2>/dev/null | grep -E '^(PID|Comm)='" | tr '\n' ' ') +echo "owner before: $owner0" +run_mode() { # mode clients workers burst + local out + out=$(g "$as_user python3 /opt/keyring-vm/probe.py $1 --clients $2 --workers $3 --burst $4 --batch 100" 2>&1) + local rc=$? + echo "$out" | tail -3 + [ $rc -eq 0 ] || { echo "MODE $1 FAILED (rc=$rc)"; fail=1; } + local secret + secret=$(g "$as_user secret-tool lookup purpose keyring-vm" 2>/dev/null) + if [ "$secret" = "synthetic-vm-secret" ]; then echo "secret after $1: ok"; else echo "secret after $1: FAILED (${secret:-empty})"; fail=1; fi +} +run_mode plain 4000 32 8 +run_mode dh 4000 32 8 +run_mode invalid 4000 32 8 +run_mode disconnect 4000 32 8 +run_mode pipeline 4000 32 8 +run_mode alias 2000 32 1 +run_mode isolation 2000 32 1 +owner1=$(g "$as_user busctl --user status org.freedesktop.secrets 2>/dev/null | grep -E '^(PID|Comm)='" | tr '\n' ' ') +echo "owner after: $owner1" +[ "$owner0" = "$owner1" ] || { echo "OWNER CHANGED"; fail=1; } +echo "--- daemon journal since $since (assertions/aborts/criticals)" +g "journalctl --since '$since' --no-pager -o short-iso 2>/dev/null | grep -E 'gnome-keyring|secrets|asan|AddressSanitizer' | grep -iE 'assert|abort|SIGABRT|critical|sanitizer|ERROR|dumped' | head -40" +if [ -n "$asan" ]; then echo "--- asan logs"; g 'ls /tmp/asan.log* 2>/dev/null && cat /tmp/asan.log* | head -80 || echo "no asan log files"'; fi +echo "=== $label result: $([ $fail -eq 0 ] && echo PASS || echo FAIL) $(date -Is)" +exit $fail diff --git a/working/keyring-crash/run-upstream-tests.sh b/working/keyring-crash/run-upstream-tests.sh new file mode 100755 index 0000000..055dc7b --- /dev/null +++ b/working/keyring-crash/run-upstream-tests.sh @@ -0,0 +1,11 @@ +#!/bin/bash +# The sshpass password below is the disposable test guest's throwaway root +# password (scripts/testing/archsetup-test.conf), reachable only on loopback. +# It is not a credential for anything real. +# Upstream meson test suites, stock vs patched, identical options, each in its +# own private session bus. Prints the summary lines and the failing test names. +set -uo pipefail +ssho=(-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null -o ConnectTimeout=10 -o LogLevel=ERROR -o PreferredAuthentications=password -o PubkeyAuthentication=no) +g() { sshpass -p archsetup ssh "${ssho[@]}" -p 2237 root@127.0.0.1 "$@"; } +g 'set -e; cd /root; if [ ! -d build-stock-cmp ]; then rm -rf src-stock-cmp; mkdir src-stock-cmp; tar -xJf gnome-keyring-51.1.tar.xz -C src-stock-cmp; meson setup build-stock-cmp src-stock-cmp/gnome-keyring-51.1 -Dbuildtype=debugoptimized -Dprefix=/usr -Dlibdir=lib -Ddebug-mode=false -Dmanpage=false >/tmp/stock-cmp-setup.log 2>&1; ninja -C build-stock-cmp >/tmp/stock-cmp-build.log 2>&1; fi +set +e; for b in build-stock-cmp build-v6; do echo "=== $b"; dbus-run-session -- meson test -C $b --no-rebuild --print-errorlogs >/tmp/mtest-$b.log 2>&1; grep -E "^(Ok|Fail|Timeout|Expected Fail|Unexpected Pass|Skipped):" /tmp/mtest-$b.log | tr "\n" " "; echo; grep -E "(FAIL|TIMEOUT) " /tmp/mtest-$b.log | awk "{print \$2, \$3, \$4, \$5}" | sort -u; done' diff --git a/working/keyring-crash/setup-guest.sh b/working/keyring-crash/setup-guest.sh new file mode 100644 index 0000000..0da7c69 --- /dev/null +++ b/working/keyring-crash/setup-guest.sh @@ -0,0 +1,40 @@ +#!/bin/bash +# Exploration fixture: run as root only in the disposable VM. +set -euo pipefail +[[ $(uname -n) == archsetup-test && $EUID == 0 ]] || exit 2 +if ! id keyring-test >/dev/null 2>&1; then + useradd -m -u 1000 -s /bin/bash keyring-test + printf '%s\n' 'keyring-test:synthetic-vm-password' | chpasswd +fi +loginctl enable-linger keyring-test +systemctl start user@1000.service +runuser -u keyring-test -- env \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + systemctl --user stop gnome-keyring-daemon.service gnome-keyring-daemon.socket +pkill -KILL -u 1000 -x gnome-keyring-d || true +runuser -u keyring-test -- env \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + systemctl --user enable gnome-keyring-daemon.socket +printf '%s\n' 'synthetic-vm-password' | runuser -u keyring-test -- env \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + LD_LIBRARY_PATH="${KEYRING_DAEMON_LIBRARY_PATH:-}" \ + ASAN_OPTIONS="${ASAN_OPTIONS:-}" \ + "${KEYRING_DAEMON_BINARY:-gnome-keyring-daemon}" --unlock +printf '%s\n' 'synthetic-vm-secret' | runuser -u keyring-test -- env \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + secret-tool store --label='Disposable VM fixture' purpose keyring-vm +observed=$(runuser -u keyring-test -- env \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + secret-tool lookup purpose keyring-vm) +[[ $observed == synthetic-vm-secret ]] +echo 'Synthetic encrypted-secret round trip passed.' +pacman -Q gnome-keyring glib2 libsecret dbus dbus-broker dbus-broker-units systemd +runuser -u keyring-test -- env \ + XDG_RUNTIME_DIR=/run/user/1000 \ + DBUS_SESSION_BUS_ADDRESS=unix:path=/run/user/1000/bus \ + systemctl --user show gnome-keyring-daemon.service -p MainPID -p NRestarts diff --git a/working/keyring-crash/status.org b/working/keyring-crash/status.org new file mode 100644 index 0000000..aa0a952 --- /dev/null +++ b/working/keyring-crash/status.org @@ -0,0 +1,118 @@ +#+TITLE: Keyring crash: cause, fix, validation, deployment +#+AUTHOR: Craig Jennings +#+DATE: 2026-10-17 +#+STARTUP: showall + +Supersedes the status in the main checkout's status-and-plan.org from 10-16. +Everything below was done in a disposable VM and this worktree; the host +keyring, its packages and services were untouched until the deployment step. + +* Cause + +gnome-keyring aborts when a Secret Service method call is dispatched before +the daemon has created the caller's per-client record. The record is created +by a G_PRIORITY_HIGH idle queued from a GDBus message filter on the worker +thread; that idle is not reliably ordered against the method dispatch, so +under concurrent bursts a call occasionally arrives first, get_pkcs11_session +asserts, the negotiate helper returns FALSE without a GError, OpenSession +completes with a NULL body, and g_variant_new aborts the daemon. systemd +restarts it, the unlocked state is gone, and the next request shows the +keyring password dialog. + +This is upstream bug 190 (also 194, 195, 196, 199), present in 48 through +51.1, unmerged fix in merge request !112 since 2026-08-25. On ratio the first +abort was 2026-10-11 19:03:59, the evening the ChatGPT desktop app was +installed; openai/codex issue 51694 documents its bundled app-server opening +a fresh Secret Service connection per keyring read in bursts and crashing +gnome-keyring with exactly this signature. The same-day glib and keyring +upgrades were a coincidence: the VM reproduced the crash on 50.0 as well. + +A second, pre-existing defect surfaced while testing the fix: the filter's +alias rewrite reads the alias table on the worker thread while the main +thread replaces and frees its entries on every client registration. +AddressSanitizer confirmed it as a heap-use-after-free read in +gkd_secret_util_build_path from rewrite_default_alias, freed by +g_hash_table_replace in update_default. Stock and !112 both carry it. + +* Fix (packages/gnome-keyring, patch 0002) + +- Register the client at the point of use in get_pkcs11_session and + publish_dispatch (as !112). +- aes_negotiate and plain_negotiate return G_DBUS_ERROR_FAILED instead of + asserting; OpenSession branches on the return value and never completes + with a NULL body (as !112). +- lookup_collection takes the caller's PKCS#11 session before resolving an + alias, so the lazy registration cannot free a borrowed alias string (the + earlier reviewer's use-after-free finding). +- The property-path lookup returns NO_SUCH_OBJECT instead of asserting when + there is no session (upstream 195). +- A mutex guards the alias table; the worker-thread filter and store_default + take owned copies under it; the default alias is replaced only when its + value changes so main-thread borrowers stay valid. + +Packaged as Arch's own recipe for 1:51.1-1 (Cinnamon patch kept, arch-meson, +selinux disabled) built from GNOME's release tarball (published sha256 +dfe7adc5876ef0670e7eba2907799bdbb8875ce58bb4208e1a52169bd16a47ca) plus the +patch, at pkgrel 1.1: above stock, below any future 1:51.1-2 or 51.2, so a +normal upgrade replaces it when a real fix lands. Nothing pins or holds it. + +* Validation + +Probe (working/keyring-crash/probe.py): real GIO clients, seven modes, every +reply validated, a daemon round trip after each batch of 100, the synthetic +secret read back after each mode, bus owner and RSS recorded. Counts: 4000 +clients at 32 workers with bursts of 8 for plain, dh, invalid, disconnect and +pipeline; 2000 for alias and isolation; alias also at 8000. + +| Daemon | Result | +|-----------------------------+-----------------------------------------------------| +| stock 51.1 | aborts on the first plain batch, collection locked, | +| | ratio's exact journal signature (three runs) | +|-----------------------------+-----------------------------------------------------| +| v4 (lazy registration only) | passes all modes; alias at 8000 returns 9 bad paths | +| | and the filter asserts on a malformed object path | +|-----------------------------+-----------------------------------------------------| +| v4 under ASan | heap-use-after-free in rewrite_default_alias | +|-----------------------------+-----------------------------------------------------| +| v5 and v6, normal and ASan | all modes pass, alias at 8000 passes, no sanitizer | +| | report, stable owner, RSS flat, secret intact | +|-----------------------------+-----------------------------------------------------| +| v6 package via pacman -U | all modes pass through the installed /usr/bin daemon| +|-----------------------------+-----------------------------------------------------| + +Independent review of v5 (patch, pristine and patched trees, prior reviewer's +requirements, no rationale): approve, no memory-safety defect after checking +every caller. Two minor notes taken in v6 (store_default under the lock, plain +NULL return); two declined as pre-existing and bounded (a late dispatch after +NameOwnerChanged leaks one PKCS#11 session per vanished peer, unique names +never recur; the remove_filter shutdown race). + +Upstream meson suites, identical options and a private bus, run back to back: +stock and v6 both 56 ok, 1 fail, 5 timeouts, the same names in both +(gnome2-store import fails intermittently; stock scored 57 ok and 0 fail on +its first run; the five dbus fixture tests time out identically). No +regression attributable to the patch. Arch's own recipe ignores this suite's +failures, which is why it is a comparison here and not a gate. + +Installer: install_gnome_keyring_fix runs after pacman_install gnome-keyring, +builds and installs the recipe with makepkg as the user when stock 51.1 (or +older) is installed, is a no-op on the rebuild, and skips with a visible +warning when something newer is installed. Eleven tests in +tests/installer-steps/test_install_gnome_keyring_fix.py; the fast suite (619 +Python plus 17 ERT tests) passes. The false autologin comment on the +keyring-dir block is corrected. + +* Deployment + +working/keyring-crash/deploy-ratio.sh: build (done on ratio 06:53, package at +~/.cache/archsetup-build/gnome-keyring/), install (permission-protected copy +of ~/.local/share/keyrings, pacman -U, restart only +gnome-keyring-daemon.service, verify), rollback (pacman -U of the cached stock +1:51.1-1, restart), verify. One unlock prompt is expected after the restart; +answer it in the system dialog. The same script applies to velox once it is +online, after confirming its versions match. + +Observation period: the stock daemon aborted about three times a day on +ratio. A restart counter that stays flat for several days of normal ChatGPT +and Claude desktop use is the evidence of lasting stability; a short check +after install is not. diff --git a/working/keyring-crash/vm-run.sh b/working/keyring-crash/vm-run.sh new file mode 100755 index 0000000..482ab3d --- /dev/null +++ b/working/keyring-crash/vm-run.sh @@ -0,0 +1,18 @@ +#!/bin/bash +# Boot the retained disposable keyring guest headlessly. SSH is forwarded on +# loopback only. Runtime files (pid, monitor socket, serial log) live under +# the caller's RUN dir; the overlay and firmware file are the retained ones. +set -euo pipefail +OVERLAY="${OVERLAY:?path to guest.qcow2}" +VARS="${VARS:?path to OVMF_VARS.fd}" +RUN="${RUN:?runtime dir}" +PORT="${PORT:-2237}" +mkdir -p "$RUN" +exec qemu-system-x86_64 -name keyring-vm -machine q35,accel=kvm -cpu host \ + -smp 2 -m 4096 \ + -drive if=pflash,format=raw,readonly=on,file=/usr/share/edk2/x64/OVMF_CODE.4m.fd \ + -drive "if=pflash,format=raw,file=$VARS" \ + -drive "file=$OVERLAY,format=qcow2,if=virtio" \ + -netdev "user,id=net0,hostfwd=tcp:127.0.0.1:${PORT}-:22" -device virtio-net-pci,netdev=net0 \ + -monitor "unix:$RUN/monitor.sock,server,nowait" -pidfile "$RUN/qemu.pid" \ + -serial "file:$RUN/serial.log" -display none -boot c |
