Repository: systemd/mkosi

Issue:
Streamline setting up SSH access to QEMU vm
While --qemu-headless is a perfect solution to work interactively with a vm launched from a user's terminal, the serial connection can't be "multiplexed" to allow usage by other tools. More specifically, it's currently not possible to give VSCode access to a shell in the virtual machine. With SSH access to a remote host, VSCode makes remote debugging possible without having to install any tools on the local host. See https://code.visualstudio.com/docs/cpp/pipe-transport.

The most obvious way to give other tools access to shells in the VM is to setup SSH in the final image. Currently, this requires installing and enabling SSH in the final image and either modifying sshd_config to allow root login with a password or setup an ssh key and copy it to the final image. Logging in with a password is annoying as it requires entering the password every time so the second solution seems more attractive.

This seems like something we could automate in mkosi though. We could add an --ssh option that takes care of installing ssh, enabling sshd and generating an SSH key on the host which is copied to the final image. The password for the SSH key would be the root password provided to mkosi.

This would allow users to specify the --ssh option, boot the QEMU vm and immediately have SSH access to it while only having to enter the root password once if ssh-agent is setup correctly.


Discussion:

Resolving patch:
diff --git a/mkosi/__init__.py b/mkosi/__init__.py
--- a/mkosi/__init__.py
+++ b/mkosi/__init__.py
@@ -13,6 +13,7 @@
 import fcntl
 import getpass
 import glob
+import time
 import hashlib
 import os
 import platform
@@ -25,6 +26,7 @@
 import subprocess
 import sys
 import tempfile
+import json
 import urllib.request
 import urllib.parse
 import uuid
@@ -70,9 +72,9 @@
     TempDir = tempfile.TemporaryDirectory
 
 
-MKOSI_COMMANDS_CMDLINE = ("shell", "boot", "qemu")
-MKOSI_COMMANDS_NEED_BUILD = MKOSI_COMMANDS_CMDLINE
-MKOSI_COMMANDS_SUDO = ("build", "clean") + MKOSI_COMMANDS_CMDLINE
+MKOSI_COMMANDS_CMDLINE = ("shell", "boot", "qemu", "ssh")
+MKOSI_COMMANDS_NEED_BUILD = ("shell", "boot", "qemu")
+MKOSI_COMMANDS_SUDO = ("build", "clean", "shell", "boot", "qemu")
 MKOSI_COMMANDS = ("build", "clean", "help", "summary") + MKOSI_COMMANDS_CMDLINE
 
 DRACUT_SYSTEMD_EXTRAS = [
@@ -1497,8 +1499,13 @@ def prepare_tree(args: CommandLineArguments, root: str, do_run_build_script: boo
             cmdline.write(" ".join(args.kernel_command_line))
             cmdline.write("\n")
 
-    if do_run_build_script:
+    if do_run_build_script or args.ssh:
         os.mkdir(os.path.join(root, "root"), 0o750)
+
+    if args.ssh and not do_run_build_script:
+        os.mkdir(os.path.join(root, "root/.ssh"), 0o700)
+
+    if do_run_build_script:
         os.mkdir(os.path.join(root, "root/dest"), 0o755)
 
         if args.include_dir is not None:
@@ -1508,6 +1515,10 @@ def prepare_tree(args: CommandLineArguments, root: str, do_run_build_script: boo
         if args.build_dir is not None:
             os.mkdir(os.path.join(root, "root/build"), 0o755)
 
+    if args.network_veth and not do_run_build_script:
+        os.mkdir(os.path.join(root, "etc/systemd"), 0o755)
+        os.mkdir(os.path.join(root, "etc/systemd/network"), 0o755)
+
 
 def patch_file(filepath: str, line_rewriter: Callable[[str], str]) -> None:
     temp_new_filepath = filepath + ".tmp.new"
@@ -1640,7 +1651,7 @@ def reenable_kernel_install(args: CommandLineArguments, root: str) -> None:
     make_executable(hook_path)
 
 
-def make_rpm_list(args: argparse.Namespace, packages: Set[str]) -> Set[str]:
+def make_rpm_list(args: argparse.Namespace, packages: Set[str], do_run_build_script: bool) -> Set[str]:
     packages = packages.copy()
 
     if args.bootable:
@@ -1663,6 +1674,9 @@ def make_rpm_list(args: argparse.Namespace, packages: Set[str]) -> Set[str]:
             else:
                 packages.add("grub2-pc")
 
+    if not do_run_build_script and args.ssh:
+        packages.add("openssh-server")
+
     return packages
 
 
@@ -1729,10 +1743,12 @@ def clean_tdnf_metadata(root: str) -> None:
         remove_glob(root + "/var/log/tdnf.*", root + "/var/cache/tdnf")
 
 
-def invoke_dnf(args: CommandLineArguments, root: str, repositories: List[str], packages: Set[str]) -> None:
+def invoke_dnf(
+    args: CommandLineArguments, root: str, repositories: List[str], packages: Set[str], do_run_build_script: bool
+) -> None:
     repos = ["--enablerepo=" + repo for repo in repositories]
     config_file = os.path.join(workspace(root), "dnf.conf")
-    packages = make_rpm_list(args, packages)
+    packages = make_rpm_list(args, packages, do_run_build_script)
 
     cmdline = [
         "dnf",
@@ -1764,11 +1780,16 @@ def invoke_dnf(args: CommandLineArguments, root: str, repositories: List[str], p
 
 
 def invoke_tdnf(
-    args: CommandLineArguments, root: str, repositories: List[str], packages: Set[str], gpgcheck: bool
+    args: CommandLineArguments,
+    root: str,
+    repositories: List[str],
+    packages: Set[str],
+    gpgcheck: bool,
+    do_run_build_script: bool,
 ) -> None:
     repos = ["--enablerepo=" + repo for repo in repositories]
     config_file = os.path.join(workspace(root), "dnf.conf")
-    packages = make_rpm_list(args, packages)
+    packages = make_rpm_list(args, packages, do_run_build_script)
 
     cmdline = [
         "tdnf",
@@ -1856,7 +1877,14 @@ def install_photon(args: CommandLineArguments, root: str, do_run_build_script: b
     if not do_run_build_script and args.bootable:
         packages |= {"linux", "initramfs"}
 
-    invoke_tdnf(args, root, args.repositories or ["photon", "photon-updates"], packages, os.path.exists(gpgpath))
+    invoke_tdnf(
+        args,
+        root,
+        args.repositories or ["photon", "photon-updates"],
+        packages,
+        os.path.exists(gpgpath),
+        do_run_build_script,
+    )
 
 
 @completestep("Installing Clear Linux")
@@ -1871,6 +1899,8 @@ def install_clear(args: CommandLineArguments, root: str, do_run_build_script: bo
         packages.update(args.build_packages)
     if not do_run_build_script and args.bootable:
         packages.add("kernel-native")
+    if not do_run_build_script and args.ssh:
+        packages.add("openssh-server")
 
     swupd_extract = shutil.which("swupd-extract")
 
@@ -1961,7 +1991,9 @@ def install_fedora(args: CommandLineArguments, root: str, do_run_build_script: b
         configure_dracut(args, root)
     if do_run_build_script:
         packages.update(args.build_packages)
-    invoke_dnf(args, root, args.repositories or ["fedora", "updates"], packages)
+    if not do_run_build_script and args.network_veth:
+        packages.add("systemd-networkd")
+    invoke_dnf(args, root, args.repositories or ["fedora", "updates"], packages, do_run_build_script)
 
     with open(os.path.join(root, "etc/locale.conf"), "w") as f:
         f.write("LANG=C.UTF-8\n")
@@ -2001,7 +2033,7 @@ def install_mageia(args: CommandLineArguments, root: str, do_run_build_script: b
             f.write('omit_dracutmodules=""\n')
     if do_run_build_script:
         packages.update(args.build_packages)
-    invoke_dnf(args, root, args.repositories or ["mageia", "updates"], packages)
+    invoke_dnf(args, root, args.repositories or ["mageia", "updates"], packages, do_run_build_script)
 
     disable_pam_securetty(root)
 
@@ -2044,15 +2076,17 @@ def install_openmandriva(args: CommandLineArguments, root: str, do_run_build_scr
         configure_dracut(args, root)
     if do_run_build_script:
         packages.update(args.build_packages)
-    invoke_dnf(args, root, args.repositories or ["openmandriva", "updates"], packages)
+    invoke_dnf(args, root, args.repositories or ["openmandriva", "updates"], packages, do_run_build_script)
 
     disable_pam_securetty(root)
 
 
-def invoke_yum(args: CommandLineArguments, root: str, repositories: List[str], packages: Set[str]) -> None:
+def invoke_yum(
+    args: CommandLineArguments, root: str, repositories: List[str], packages: Set[str], do_run_build_script: bool
+) -> None:
     repos = ["--enablerepo=" + repo for repo in repositories]
     config_file = os.path.join(workspace(root), "dnf.conf")
-    packages = make_rpm_list(args, packages)
+    packages = make_rpm_list(args, packages, do_run_build_script)
 
     cmdline = [
         "yum",
@@ -2077,11 +2111,13 @@ def invoke_yum(args: CommandLineArguments, root: str, repositories: List[str], p
         run(cmdline)
 
 
-def invoke_dnf_or_yum(args: CommandLineArguments, root: str, repositories: List[str], packages: Set[str]) -> None:
+def invoke_dnf_or_yum(
+    args: CommandLineArguments, root: str, repositories: List[str], packages: Set[str], do_run_build_script: bool
+) -> None:
     if shutil.which("dnf") is None:
-        invoke_yum(args, root, repositories, packages)
+        invoke_yum(args, root, repositories, packages, do_run_build_script)
     else:
-        invoke_dnf(args, root, repositories, packages)
+        invoke_dnf(args, root, repositories, packages, do_run_build_script)
 
 
 def install_centos_old(args: CommandLineArguments, root: str, epel_release: int) -> List[str]:
@@ -2211,7 +2247,10 @@ def install_centos(args: CommandLineArguments, root: str, do_run_build_script: b
     if do_run_build_script:
         packages.update(args.build_packages)
 
-    invoke_dnf_or_yum(args, root, repos, packages)
+    if not do_run_build_script and args.distribution == Distribution.centos_epel and args.network_veth:
+        packages.add("systemd-networkd")
+
+    invoke_dnf_or_yum(args, root, repos, packages, do_run_build_script)
 
 
 def debootstrap_knows_arg(arg: str) -> bool:
@@ -2270,6 +2309,9 @@ def install_debian_or_ubuntu(args: CommandLineArguments, root: str, *, do_run_bu
         if args.output_format == OutputFormat.gpt_btrfs:
             extra_packages.add("btrfs-progs")
 
+    if not do_run_build_script and args.ssh:
+        extra_packages.add("openssh-server")
+
     # Debian policy is to start daemons by default. The policy-rc.d script can be used choose which ones to
     # start. Let's install one that denies all daemon startups.
     # See https://people.debian.org/~hmh/invokerc.d-policyrc.d-specification.txt for more information.
@@ -2637,6 +2679,9 @@ def install_arch(args: CommandLineArguments, root: str, do_run_build_script: boo
     if do_run_build_script:
         packages.update(args.build_packages)
 
+    if not do_run_build_script and args.ssh:
+        packages.add("openssh")
+
     def run_pacman(packages: Set[str]) -> None:
         conf = ["--config", pacman_conf]
 
@@ -2726,6 +2771,9 @@ def install_opensuse(args: CommandLineArguments, root: str, do_run_build_script:
     if do_run_build_script:
         packages.update(args.build_packages)
 
+    if not do_run_build_script and args.ssh:
+        packages.update("openssh-server")
+
     cmdline = [
         "zypper",
         "--root",
@@ -3964,6 +4012,15 @@ def link_output_bmap(args: CommandLineArguments, bmap: Optional[str]) -> None:
         _link_output(args, bmap, args.output_bmap)
 
 
+def link_output_sshkey(args: CommandLineArguments, sshkey: Optional[str]) -> None:
+    if sshkey is None:
+        return
+
+    with complete_step("Linking private ssh key file", f"Successfully linked {args.output_sshkey}"):
+        _link_output(args, sshkey, args.output_sshkey)
+        os.chmod(args.output_sshkey, 0o600)
+
+
 def dir_size(path: str) -> int:
     dir_sum = 0
     for entry in os.scandir(path):
@@ -4518,6 +4575,9 @@ def create_parser() -> ArgumentParserMkosi:
         help="If specified, the container/VM is run with a temporary snapshot of the output image that is "
         "removed immediately when the container/VM terminates",
     )
+    group.add_argument(
+        "--ssh", action=BooleanAction, help="Set up SSH access from the host to the final image via `mkosi ssh`"
+    )
 
     group = parser.add_argument_group("Additional Configuration")
     group.add_argument("-C", "--directory", help="Change to specified directory before doing anything", metavar="PATH")
@@ -4849,6 +4909,9 @@ def unlink_output(args: CommandLineArguments) -> None:
             if args.nspawn_settings is not None:
                 unlink_try_hard(args.output_nspawn_settings)
 
+        if args.ssh:
+            unlink_try_hard(args.output_sshkey)
+
     # We remove any cached images if either the user used --force
     # twice, or he/she called "clean" with it passed once. Let's also
     # remove the downloaded package cache if the user specified one
@@ -5232,6 +5295,9 @@ def load_args(args: CommandLineArguments) -> CommandLineArguments:
         args.nspawn_settings = os.path.abspath(args.nspawn_settings)
         args.output_nspawn_settings = build_nspawn_settings_path(args.output)
 
+    # We want this set even if --ssh is not specified so we can find the SSH key when verb == "ssh".
+    args.output_sshkey = os.path.join(os.path.dirname(args.output), "id_rsa")
+
     if args.build_script is not None:
         check_valid_script(args.build_script)
         args.build_script = os.path.abspath(args.build_script)
@@ -5357,6 +5423,9 @@ def load_args(args: CommandLineArguments) -> CommandLineArguments:
     if args.skip_final_phase and args.verb != "build":
         die("--skip-final-phase can only be used when building an image using `mkosi build`")
 
+    if args.ssh and not args.network_veth:
+        die("--ssh cannot be used without --network-veth")
+
     return args
 
 
@@ -5371,6 +5440,7 @@ def check_output(args: CommandLineArguments) -> None:
         args.output_bmap if args.bmap else None,
         args.output_nspawn_settings if args.nspawn_settings is not None else None,
         args.output_root_hash_file if args.verity else None,
+        args.output_sshkey if args.ssh else None,
     ):
 
         if f is None:
@@ -5451,6 +5521,7 @@ def print_summary(args: CommandLineArguments) -> None:
         "    Output nspawn Settings: "
         + none_to_na(args.output_nspawn_settings if args.nspawn_settings is not None else None)
     )  # NOQA: E501
+    MkosiPrinter.info("            Output SSH key: " + none_to_na(args.output_sshkey if args.ssh else None))
     MkosiPrinter.info("               Incremental: " + yes_no(args.incremental))
 
     MkosiPrinter.info("                 Read-only: " + yes_no(args.read_only))
@@ -5584,20 +5655,85 @@ def make_build_dir(args: CommandLineArguments) -> None:
     mkdir_last(args.build_dir, 0o755)
 
 
+def setup_ssh(args: CommandLineArguments, root: str, do_run_build_script: bool, for_cache: bool) -> Optional[TextIO]:
+    if do_run_build_script or for_cache or not args.ssh:
+        return None
+
+    if args.distribution in (Distribution.debian, Distribution.ubuntu):
+        unit = "ssh"
+    else:
+        unit = "sshd"
+
+    run(["systemctl", "--root", root, "enable", unit])
+
+    f: TextIO = cast(
+        TextIO,
+        tempfile.NamedTemporaryFile(
+            mode="w+", prefix=".mkosi-", encoding="utf-8", dir=os.path.dirname(args.output_sshkey)
+        ),
+    )
+
+    with complete_step("Generating SSH keypair"):
+        # Write a 'y' to confirm to overwrite the file.
+        run(
+            ["ssh-keygen", "-f", f.name, "-N", args.password or "", "-C", "mkosi", "-t", "ed25519"],
+            input=f"y\n",
+            text=True,
+        )
+
+    copy_file(f"{f.name}.pub", os.path.join(root, "root/.ssh/authorized_keys"))
+    os.remove(f"{f.name}.pub")
+
+    os.chmod(os.path.join(root, "root/.ssh/authorized_keys"), 0o600)
+
+    return f
+
+
+def setup_network_veth(args: CommandLineArguments, root: str, do_run_build_script: bool, for_cache: bool) -> None:
+    if do_run_build_script or for_cache or not args.network_veth:
+        return
+
+    network_file = os.path.join(root, "etc/systemd/network/80-mkosi-network-veth.network")
+    with open(network_file, "w") as f:
+        # Adapted from https://github.com/systemd/systemd/blob/v247/network/80-container-host0.network
+        f.write(
+            dedent(
+                """\
+                [Match]
+                Virtualization=!container
+                Type=ether
+
+                [Network]
+                DHCP=yes
+                LinkLocalAddressing=yes
+                LLDP=yes
+                EmitLLDP=customer-bridge
+
+                [DHCP]
+                UseTimezone=yes
+                """
+            )
+        )
+
+    os.chmod(network_file, 0o644)
+
+    run(["systemctl", "--root", root, "enable", "systemd-networkd"])
+
+
 def build_image(
     args: CommandLineArguments, root: str, *, do_run_build_script: bool, for_cache: bool = False, cleanup: bool = False
-) -> Tuple[Optional[BinaryIO], Optional[BinaryIO], Optional[str]]:
+) -> Tuple[Optional[BinaryIO], Optional[BinaryIO], Optional[str], Optional[TextIO]]:
     # If there's no build script set, there's no point in executing
     # the build script iteration. Let's quit early.
     if args.build_script is None and do_run_build_script:
-        return None, None, None
+        return None, None, None, None
 
     make_build_dir(args)
 
     raw, cached = reuse_cache_image(args, root, do_run_build_script, for_cache)
     if for_cache and cached:
         # Found existing cache image, exiting build_image
-        return None, None, None
+        return None, None, None, None
 
     if not cached:
         raw = create_image(args, root, for_cache)
@@ -5662,6 +5798,8 @@ def build_image(
                     set_root_password(args, root, do_run_build_script, for_cache)
                     set_serial_terminal(args, root, do_run_build_script, for_cache)
                     set_autologin(args, root, do_run_build_script, for_cache)
+                    sshkey = setup_ssh(args, root, do_run_build_script, for_cache)
+                    setup_network_veth(args, root, do_run_build_script, for_cache)
                     run_postinst_script(args, root, loopdev, do_run_build_script, for_cache)
 
                 if cleanup:
@@ -5697,7 +5835,7 @@ def build_image(
 
     tar = make_tar(args, root, do_run_build_script, for_cache)
 
-    return raw or generated_root, tar, root_hash
+    return raw or generated_root, tar, root_hash, sshkey
 
 
 def workspace(root: str) -> str:
@@ -5845,14 +5983,14 @@ def build_stuff(args: CommandLineArguments) -> None:
             if args.build_script:
                 with complete_step("Running first (development) stage to generate cached copy"):
                     # Generate the cache version of the build image, and store it as "cache-pre-dev"
-                    raw, tar, root_hash = build_image(args, root, do_run_build_script=True, for_cache=True)
+                    raw, tar, root_hash, sshkey = build_image(args, root, do_run_build_script=True, for_cache=True)
                     save_cache(args, root, raw.name if raw is not None else None, args.cache_pre_dev)
 
                     remove_artifacts(args, root, raw, tar, do_run_build_script=True)
 
             with complete_step("Running second (final) stage to generate cached copy"):
                 # Generate the cache version of the build image, and store it as "cache-pre-inst"
-                raw, tar, root_hash = build_image(args, root, do_run_build_script=False, for_cache=True)
+                raw, tar, root_hash, sshkey = build_image(args, root, do_run_build_script=False, for_cache=True)
                 if raw:
                     save_cache(args, root, raw.name, args.cache_pre_inst)
                     remove_artifacts(args, root, raw, tar, do_run_build_script=False)
@@ -5860,7 +5998,7 @@ def build_stuff(args: CommandLineArguments) -> None:
         if args.build_script:
             with complete_step("Running first (development) stage"):
                 # Run the image builder for the first (development) stage in preparation for the build script
-                raw, tar, root_hash = build_image(args, root, do_run_build_script=True)
+                raw, tar, root_hash, sshkey = build_image(args, root, do_run_build_script=True)
 
                 run_build_script(args, root, raw)
                 remove_artifacts(args, root, raw, tar, do_run_build_script=True)
@@ -5868,7 +6006,7 @@ def build_stuff(args: CommandLineArguments) -> None:
         # Run the image builder for the second (final) stage
         if not args.skip_final_phase:
             with complete_step("Running second (final) stage"):
-                raw, tar, root_hash = build_image(args, root, do_run_build_script=False, cleanup=True)
+                raw, tar, root_hash, sshkey = build_image(args, root, do_run_build_script=False, cleanup=True)
         else:
             MkosiPrinter.print_step("Skipping (second) final image build phase.")
 
@@ -5886,6 +6024,7 @@ def build_stuff(args: CommandLineArguments) -> None:
         link_output_signature(args, signature.name if signature is not None else None)
         link_output_bmap(args, bmap.name if bmap is not None else None)
         link_output_nspawn_settings(args, settings.name if settings is not None else None)
+        link_output_sshkey(args, sshkey.name if sshkey is not None else None)
 
         if root_hash is not None:
             MkosiPrinter.print_step(f"Root hash is {root_hash}.")
@@ -5910,6 +6049,56 @@ def suppress_stacktrace() -> Generator[None, None, None]:
         raise MkosiException(e)
 
 
+def virt_name(args: CommandLineArguments) -> str:
+    name = args.hostname or os.path.splitext(os.path.basename(args.output))[0]
+    # Shorten to 13 characters so we can prefix with ve- or vt- for the network veth ifname which is limited
+    # to 16 characters.
+    return cast(str, name[:13])
+
+
+def has_networkd_vm_vt() -> bool:
+    for path in ["/usr/lib/systemd/network", "/lib/systemd/network", "/etc/systemd/network"]:
+        if os.path.exists(os.path.join(path, "80-vm-vt.network")):
+            return True
+
+    return False
+
+
+def ensure_networkd(args: CommandLineArguments) -> None:
+    networkd_is_running = run(["systemctl", "is-active", "--quiet", "systemd-networkd"], check=False).returncode == 0
+    if not networkd_is_running:
+        die("--network-veth requires systemd-networkd to be running (`systemctl enable --now systemd-networkd`)")
+
+    if args.verb == "qemu" and not has_networkd_vm_vt():
+        die(
+            r"""
+            mkosi didn't find 80-vm-vt.network. This is one of systemd's built-in systemd-networkd config
+            files which configures vt-* interfaces. mkosi needs this file in order for --network-veth to work
+            properly for QEMU virtual machines. The file likely cannot be found because the systemd version
+            on the host is too old (< 246) and it isn't included yet.
+
+            As a workaround until the file is shipped by the systemd package of your distro, add a user
+            network file /etc/systemd/network/80-vm-vt.network with the following contents:
+
+            ```
+            [Match]
+            Name=vt-*
+            Driver=tun
+
+            [Network]
+            # Default to using a /28 prefix, giving up to 13 addresses per VM.
+            Address=0.0.0.0/28
+            LinkLocalAddressing=yes
+            DHCPServer=yes
+            IPMasquerade=yes
+            LLDP=yes
+            EmitLLDP=customer-bridge
+            IPv6PrefixDelegation=yes
+            ```
+            """
+        )
+
+
 def run_shell(args: CommandLineArguments) -> None:
     if args.output_format in (OutputFormat.directory, OutputFormat.subvolume):
         target = f"--directory={args.output}"
@@ -5932,11 +6121,14 @@ def run_shell(args: CommandLineArguments) -> None:
         cmdline.append("--volatile=overlay")
 
     if args.network_veth:
+        ensure_networkd(args)
         cmdline.append("--network-veth")
 
     if args.ephemeral:
         cmdline.append("--ephemeral")
 
+    cmdline += ["--machine", virt_name(args)]
+
     if args.cmdline:
         # If the verb is shell, args.cmdline contains the command to run. Otherwise (boot), we assume
         # args.cmdline contains nspawn arguments.
@@ -6017,8 +6209,9 @@ def run_qemu(args: CommandLineArguments) -> None:
         cmdline += ["-vga", "virtio"]
 
     if args.network_veth:
-        # On Linux, interface names are limited to 16 characters so we do the same.
-        ifname = f"vt-{os.path.splitext(os.path.basename(args.output))[0][:13]}"
+        ensure_networkd(args)
+        # Use vt- prefix so we can take advantage of systemd-networkd's builtin network file for VMs.
+        ifname = f"vt-{virt_name(args)}"
         # vt-<image-name> is the ifname on the host and is automatically picked up by systemd-networkd which
         # starts a DHCP server on that interface. This gives IP connectivity to the VM. By default, QEMU
         # itself tries to bring up the vt network interface which conflicts with systemd-networkd which is
@@ -6042,6 +6235,71 @@ def run_qemu(args: CommandLineArguments) -> None:
             run(cmdline, stdout=sys.stdout, stderr=sys.stderr)
 
 
+def interface_exists(dev: str) -> bool:
+    return run(["ip", "link", "show", dev], stdout=DEVNULL, stderr=DEVNULL, check=False).returncode == 0
+
+
+def find_address(args: CommandLineArguments) -> Tuple[str, str]:
+    name = virt_name(args)
+
+    if interface_exists(f"ve-{name}"):
+        dev = f"ve-{name}"
+    elif interface_exists(f"vt-{name}"):
+        dev = f"vt-{name}"
+    else:
+        die("Container/VM interface not found")
+
+    link = json.loads(run(["ip", "-j", "link", "show", "dev", dev], stdout=PIPE, text=True).stdout)[0]
+    if link["operstate"] == "DOWN":
+        die(f"{dev} is not enabled. Make sure systemd-networkd is running so it can manage the interface.")
+
+    # Trigger IPv6 neighbor discovery of which we can access the results via `ip neighbor`. This allows us to
+    # find out the link-local IPv6 address of the container/VM via which we can connect to it.
+    run(["ping", "-c", "1", "-w", "15", f"ff02::1%{dev}"], stdout=DEVNULL)
+
+    for _ in range(50):
+        neighbors = json.loads(run(["ip", "-j", "neighbor", "show", "dev", dev], stdout=PIPE, text=True).stdout)
+
+        for neighbor in neighbors:
+            dst = cast(str, neighbor["dst"])
+            if dst.startswith("fe80"):
+                return dev, dst
+
+        time.sleep(0.4)
+
+    die("Container/VM address not found")
+
+
+def run_ssh(args: CommandLineArguments) -> None:
+    if not os.path.exists(args.output_sshkey):
+        die(
+            f"SSH key not found at {args.output_sshkey}. Are you running from the project's root directory "
+            "and did you build with the --ssh option?"
+        )
+
+    dev, address = find_address(args)
+
+    with suppress_stacktrace():
+        run(
+            [
+                "ssh",
+                "-i",
+                args.output_sshkey,
+                # Silence known hosts file errors/warnings.
+                "-o",
+                "UserKnownHostsFile=/dev/null",
+                "-o",
+                "StrictHostKeyChecking=no",
+                "-o",
+                "LogLevel ERROR",
+                f"root@{address}%{dev}",
+                *args.cmdline,
+            ],
+            stdout=sys.stdout,
+            stderr=sys.stderr,
+        )
+
+
 def expand_paths(paths: List[str]) -> List[str]:
     if not paths:
         return []
@@ -6109,3 +6367,6 @@ def run_verb(args: CommandLineArguments) -> None:
 
     if args.verb == "qemu":
         run_qemu(args)
+
+    if args.verb == "ssh":
+        run_ssh(args)


Test patch:
diff --git a/tests/pexpect/boot.py b/tests/pexpect/boot.py
--- a/tests/pexpect/boot.py
+++ b/tests/pexpect/boot.py
@@ -2,6 +2,7 @@
 
 import pexpect
 import sys
+import time
 
 
 def run() -> None:
@@ -10,6 +11,11 @@ def run() -> None:
     p.expect("login:")
     p.sendline("root")
 
+    time.sleep(15)
+
+    s = pexpect.spawnu("mkosi ssh", logfile=sys.stdout)
+    s.expect("#")
+
     p.expect("#")
     p.sendline("systemctl poweroff")
 
diff --git a/tests/test_config_parser.py b/tests/test_config_parser.py
--- a/tests/test_config_parser.py
+++ b/tests/test_config_parser.py
@@ -115,6 +115,7 @@ def add_reference_config(self, job_name=DEFAULT_JOB_NAME):
             "ephemeral": False,
             "with_unified_kernel_images": True,
             "hostonly_initrd": False,
+            "ssh": False,
         }
 
     def __eq__(self, other: [mkosi.CommandLineArguments]) -> bool:
@@ -313,6 +314,8 @@ def _update_ref_from_file(self, mk_config: dict, job_name=DEFAULT_JOB_NAME) -> N
                 self.reference_config[job_name]["network_veth"] = mk_config_host["NetworkVeth"]
             if "Ephemeral" in mk_config_host:
                 self.reference_config[job_name]["ephemeral"] = mk_config_host["Ephemeral"]
+            if "Ssh" in mk_config_host:
+                self.reference_config[job_name]["ssh"] = mk_config_host["Ssh"]
 
 
 class MkosiConfigOne(MkosiConfig):