[PATCH 08/12] qemu_system_units: render three ssh_config stanzas with vsock default

Daniel Gomez <[email protected]> Thu, 23 Apr 2026 13:31:00 +0200
Newsgroups dev.linux.lists.kdevops
Message-ID <20260423-kdevops-series-c-qemu-system-units-v1-8-b7bab3225a36@samsung.com>
From: Daniel Gomez <[email protected]>

ssh qsu wrote a single TCP-over-SLIRP stanza and nothing for
AF_VSOCK. Users got the VSOCK path only by typing the raw
vsock/<cid> URL with an explicit -i <key>; the test harness and
any muscle-memory ssh <vm> still paid SLIRP overhead even
though the guest had sshd-vsock.socket listening (via
systemd-ssh-generator when CONFIG_VIRTIO_VSOCKETS=y) and
machined knew the CID.

Teach update_ssh_config_nixos.py an optional --vsock-cid and
--default-transport pair. When --vsock-cid is given the script
emits three stanzas: <host>-vsock via systemd-ssh-proxy,
<host>-tcp via the SLIRP hostfwd, and the bare <host> grouped
with whichever --default-transport selects. Without --vsock-cid
the single-stanza layout stays, so the libvirt-backed NIXOS
path is unchanged. The -vsock/-tcp suffixes name the socket
family's transport: vsock (AF_VSOCK) and tcp (AF_INET TCP).

Generated-by: Claude AI
Signed-off-by: Daniel Gomez <[email protected]>
---
 scripts/update_ssh_config_nixos.py | 239 +++++++++++++++++++++++--------------
 1 file changed, 152 insertions(+), 87 deletions(-)

diff --git a/scripts/update_ssh_config_nixos.py b/scripts/update_ssh_config_nixos.py
index 206c69cc..8aca6133 100755
--- a/scripts/update_ssh_config_nixos.py
+++ b/scripts/update_ssh_config_nixos.py
@@ -8,121 +8,186 @@ via native QEMU virtualization (not libvirt). It handles both adding
 and removing SSH config entries.
 
 Usage:
-    update_ssh_config_nixos.py update <hostname> <host> <port> <user> <ssh_config> <privkey> <tag>
-    update_ssh_config_nixos.py remove <hostname> '' '' '' <ssh_config> '' <tag>
+    update_ssh_config_nixos.py update <hostname> <host> <port> <user>
+        <ssh_config> <privkey> <tag>
+        [--vsock-cid <N>] [--default-transport vsock|tcp]
+    update_ssh_config_nixos.py remove <hostname> '' '' '' <ssh_config>
+        '' <tag>
+
+When --vsock-cid is given, emit three Host stanzas:
+    <hostname>-vsock    : ProxyCommand systemd-ssh-proxy vsock/<cid> 22
+    <hostname>-tcp      : TCP via <host>:<port>
+    <hostname>          : alias of whichever --default-transport picks
+                          (defaults to vsock when --vsock-cid is set).
+
+Without --vsock-cid, one <hostname> stanza is emitted over TCP
+(backward-compatible with the libvirt NIXOS caller).
 """
 
+import argparse
 import os
-import sys
 import re
-from pathlib import Path
+import sys
 
 
-def update_ssh_config(
-    action, hostname, host_ip, port, username, ssh_config_path, ssh_key_path, tag
-):
-    """Update or remove SSH config entries for NixOS VMs.
-
-    Args:
-        action: 'update' to add/update entry, 'remove' to remove entry
-        hostname: VM hostname
-        host_ip: Host IP (usually localhost for NixOS VMs)
-        port: SSH port number
-        username: SSH username
-        ssh_config_path: Path to SSH config file
-        ssh_key_path: Path to SSH private key
-        tag: Tag to identify entries (e.g., 'NixOS VM')
+SYSTEMD_SSH_PROXY = "/usr/lib/systemd/systemd-ssh-proxy"
+
+
+def render_vsock_stanza(host_tokens, user, key, cid):
+    return (
+        f"Host {host_tokens}\n"
+        f"    User {user}\n"
+        f"    IdentityFile {key}\n"
+        f"    ProxyCommand {SYSTEMD_SSH_PROXY} vsock/{cid} 22\n"
+        f"    ProxyUseFdpass yes\n"
+        f"    CheckHostIP no\n"
+        f"    StrictHostKeyChecking no\n"
+        f"    UserKnownHostsFile /dev/null\n"
+        f"    LogLevel ERROR\n"
+    )
+
+
+def render_tcp_stanza(host_tokens, host_ip, port, user, key):
+    return (
+        f"Host {host_tokens}\n"
+        f"    HostName {host_ip}\n"
+        f"    Port {port}\n"
+        f"    User {user}\n"
+        f"    IdentityFile {key}\n"
+        f"    StrictHostKeyChecking no\n"
+        f"    UserKnownHostsFile /dev/null\n"
+        f"    LogLevel ERROR\n"
+    )
+
+
+def build_entries(hostname, host_ip, port, user, key, vsock_cid, default_transport):
+    """Return a list of (alias, block_body) pairs to write.
+
+    Each block starts with its own `# kdevops-managed: {tag} - {alias}`
+    marker so remove-on-update can match and drop the old block via
+    regex.
     """
+    if vsock_cid is None:
+        # Legacy single-stanza form used by libvirt NIXOS path.
+        return [
+            (hostname, render_tcp_stanza(hostname, host_ip, port, user, key)),
+        ]
+
+    # Three-stanza form: default alias groups with either -vsock or -tcp.
+    if default_transport == "tcp":
+        default_alias = f"{hostname} {hostname}-tcp"
+        bare_stanza = render_tcp_stanza(default_alias, host_ip, port, user, key)
+        return [
+            (hostname, bare_stanza),
+            (f"{hostname}-vsock",
+             render_vsock_stanza(f"{hostname}-vsock", user, key, vsock_cid)),
+        ]
+
+    # default_transport == "vsock"
+    default_alias = f"{hostname} {hostname}-vsock"
+    bare_stanza = render_vsock_stanza(default_alias, user, key, vsock_cid)
+    return [
+        (hostname, bare_stanza),
+        (f"{hostname}-tcp",
+         render_tcp_stanza(f"{hostname}-tcp", host_ip, port, user, key)),
+    ]
+
+
+def managed_block_regex(tag, hostname):
+    """Match every `# kdevops-managed: {tag} - <alias>` block whose alias
+    is either the base hostname or starts with it followed by a dash.
+    This catches qsu, qsu-vsock, qsu-tcp in one sweep without disturbing
+    unrelated entries that share the tag prefix.
+    """
+    return re.compile(
+        rf"^# kdevops-managed: {re.escape(tag)} - "
+        rf"{re.escape(hostname)}(?:-[\w-]+)?\n"
+        r"Host [^\n]+\n"
+        r"(?:[ \t]+[^\n]+\n)*",
+        re.MULTILINE,
+    )
 
-    ssh_config_path = os.path.expanduser(ssh_config_path)
 
-    # Ensure SSH config directory exists
+def update_ssh_config(
+    action, hostname, host_ip, port, username, ssh_config_path, ssh_key_path,
+    tag, vsock_cid, default_transport,
+):
+    ssh_config_path = os.path.expanduser(ssh_config_path)
     os.makedirs(os.path.dirname(ssh_config_path), exist_ok=True)
 
-    # Read existing config
     config_content = ""
     if os.path.exists(ssh_config_path):
         with open(ssh_config_path, "r") as f:
             config_content = f.read()
 
-    # Pattern to match our managed entries
-    entry_pattern = re.compile(
-        rf"^# kdevops-managed: {re.escape(tag)} - {re.escape(hostname)}\n"
-        r"Host [^\n]+\n"
-        r"(?:[ \t]+[^\n]+\n)*",
-        re.MULTILINE,
-    )
+    entry_pattern = managed_block_regex(tag, hostname)
 
-    if action == "remove":
-        # Remove existing entry
-        config_content = entry_pattern.sub("", config_content)
-        print(f"Removed SSH config entry for {hostname}")
+    # Always drop prior managed blocks for this base hostname.
+    config_content = entry_pattern.sub("", config_content)
 
+    if action == "remove":
+        print(f"Removed SSH config entries for {hostname}")
     elif action == "update":
-        # Remove existing entry first
-        config_content = entry_pattern.sub("", config_content)
-
-        # Create new entry
-        new_entry = f"""# kdevops-managed: {tag} - {hostname}
-Host {hostname}
-    HostName {host_ip}
-    Port {port}
-    User {username}
-    IdentityFile {ssh_key_path}
-    StrictHostKeyChecking no
-    UserKnownHostsFile /dev/null
-    LogLevel ERROR
+        blocks = []
+        for alias, body in build_entries(
+            hostname, host_ip, port, username, ssh_key_path,
+            vsock_cid, default_transport,
+        ):
+            blocks.append(
+                f"# kdevops-managed: {tag} - {alias}\n{body}"
+            )
+        new_entries = "\n".join(blocks)
+        config_content = config_content.rstrip() + "\n\n" + new_entries + "\n"
+        if vsock_cid is None:
+            print(f"Updated SSH config entry for {hostname} (port {port})")
+        else:
+            print(
+                f"Updated SSH config entries for {hostname}, "
+                f"{hostname}-vsock, {hostname}-tcp "
+                f"(default={default_transport}, vsock CID={vsock_cid}, "
+                f"TCP port={port})"
+            )
 
-"""
-
-        # Add new entry at the end
-        config_content = config_content.rstrip() + "\n\n" + new_entry
-        print(f"Updated SSH config entry for {hostname} (port {port})")
-
-    # Write updated config
     with open(ssh_config_path, "w") as f:
         f.write(config_content)
 
 
 def main():
-    """Main function to handle command line arguments."""
-    if len(sys.argv) < 8:
-        print(
-            "Usage: update_ssh_config_nixos.py <action> <hostname> <host> <port> <user> <ssh_config> <privkey> <tag>"
-        )
-        print("  action: 'update' or 'remove'")
-        print("  hostname: VM hostname")
-        print("  host: Host IP (use 'localhost' for local VMs)")
-        print("  port: SSH port number")
-        print("  user: SSH username")
-        print("  ssh_config: Path to SSH config file")
-        print("  privkey: Path to SSH private key")
-        print("  tag: Tag to identify entries (e.g., 'NixOS VM')")
-        sys.exit(1)
-
-    action = sys.argv[1]
-    hostname = sys.argv[2]
-    host_ip = sys.argv[3] if sys.argv[3] else "localhost"
-    port = sys.argv[4] if sys.argv[4] else "22"
-    username = sys.argv[5] if sys.argv[5] else "kdevops"
-    ssh_config_path = sys.argv[6]
-    ssh_key_path = sys.argv[7] if len(sys.argv) > 7 else ""
-    tag = sys.argv[8] if len(sys.argv) > 8 else "NixOS VM"
-
-    if action not in ["update", "remove"]:
-        print(f"Error: Invalid action '{action}'. Use 'update' or 'remove'.")
-        sys.exit(1)
+    parser = argparse.ArgumentParser(
+        description="Manage SSH config entries for NixOS-on-QEMU VMs.",
+    )
+    parser.add_argument("action", choices=["update", "remove"])
+    parser.add_argument("hostname")
+    parser.add_argument("host_ip", nargs="?", default="localhost")
+    parser.add_argument("port", nargs="?", default="22")
+    parser.add_argument("username", nargs="?", default="kdevops")
+    parser.add_argument("ssh_config_path")
+    parser.add_argument("ssh_key_path", nargs="?", default="")
+    parser.add_argument("tag", nargs="?", default="NixOS VM")
+    parser.add_argument(
+        "--vsock-cid", type=int, default=None,
+        help="VSOCK CID of the VM. When set, emit qsu-style three-stanza "
+             "layout (host, host-vsock, host-tcp).",
+    )
+    parser.add_argument(
+        "--default-transport", choices=["vsock", "tcp"], default="vsock",
+        help="Which transport the bare <hostname> alias groups with. Only "
+             "meaningful when --vsock-cid is set.",
+    )
+    args = parser.parse_args()
 
     try:
         update_ssh_config(
-            action,
-            hostname,
-            host_ip,
-            port,
-            username,
-            ssh_config_path,
-            ssh_key_path,
-            tag,
+            args.action,
+            args.hostname,
+            args.host_ip or "localhost",
+            args.port or "22",
+            args.username or "kdevops",
+            args.ssh_config_path,
+            args.ssh_key_path,
+            args.tag,
+            args.vsock_cid,
+            args.default_transport,
         )
     except Exception as e:
         print(f"Error: {e}")

-- 
2.53.0