Repository: lima-vm/lima
Pull request #1721 (2023-08-08 00:15:08)

feat: add WSL2 driver
This PR users WSL2 to add a new VM Driver for Windows. This addresses the longstanding GitHub issue asking for Windows support (https://github.com/lima-vm/lima/discussions/391).

The traditional thinking (as seen in https://github.com/lima-vm/lima/issues/909) is to make QEMU work on Windows, and the addition of a WSL2 driver should not inhibit that effort, just provide another option (I'd also like to see a direct HyperV option 👀 ).

Considerations:
- The implementation in this PR currently does not forward the VM's SSH address to `127.0.0.1:<Instance.SSHLocalPort>`, although this is possible through the use of netsh (I couldn't get ssh port forwarding to work). Currently, the PR just uses the WSL2 instance's IP address and port 22. I can add some netsh calling code to get this working relatively easily, I just wanted to know if that's required
- The implementation requires an `ssh` binary on Windows, which isn't common. My recommendation is to have users install [Git for Windows](https://github.com/git-for-windows/git/) (specifically the MinGit release), which comes with some other helpful utilities like `cygpath`. Bundling this with Lima or just requiring users to run `winget install -e --id Git.MinGit` ([winget](https://github.com/microsoft/winget-cli) is now built into Windows) and adding it to path are both possible. The version of OpenSSH installable as a Windows optional feature is pretty old. Open to suggestions incase I missed something.
- The implementation uses WSL2's built-in network sharing and ssh forwarding of ports. It should technically be possible to use gvisor or similar, but I think that can be future work
- The implementation keeps the WSL2 instance running until stopped by executing an extremely long `sleep` command. Open to suggestions here, as I'm sure there are better ways to do this
- There might be some final debug code left in this diff. I will remove it in revisions over the coming days
- There are a lot of tests left to be added, which I'll be working in the coming days

afbjorklund: @pendo324 : thank you for your contribution, it would be have been good to open an issue for discussion about the feature itself and keep the PR for the implementation - but that can be done afterwards too, and reference this PR

Running Lima in a container is slightly different from all the other drivers, so it might need some thought (Lima itself has some similaries to WSL2). I agree that a more traditional HyperV driver would also be a nice addition, like "VZ"...
pendo324: > @pendo324 : thank you for your contribution, it would be have been good to open an issue for discussion about the feature itself and keep the PR for the implementation - but that can be done afterwards too, and reference this PR
>
> Running Lima in a container is slightly different from all the other drivers, so it might need some thought (Lima itself has some similaries to WSL2). I agree that a more traditional HyperV driver would also be a nice addition, like "VZ"...

I'll create an issue to track Windows support in general, and weigh the different options (WSL2, HyperV, qemu, etc.) as I do think there are reasons you might want to use one over the other. I wanted to come to the project with some code instead of just an issue, but it ballooned into a lot of code before I knew it 😅


AkihiroSuda: Needs rebase
afbjorklund: It is still weird to run Lima in a container, so I am a bit skeptic about it (similar to running Lima in Docker...)
pendo324: > That is why I preferred having a Hyper-V driver.

While I don't disagree that HyperV would be good to add to Lima, I don't think WSL2 is a bad thing to have as an option even after HyperV support is added. It seems like Microsoft is committed to making incremental improvements (for example, automatic allocation/reclamation of resources) to WSL2 that could be useful for users and we'd get "for free" (or with a modification of `/etc/wsl.conf`).

> Calling a WSL "distribution" a VM is not really _true_, and confuses the user. They are all using the same kernel?

Looking at the landscape: Podman, Rancher, and Docker Desktop on Windows all use WSL2 in some way or another so I don't think it breaks user expectation entirely. I can add more docs in Lima explaining how WSL2 works and how its different from other options if that helps.

Even if there was a HyperV option, I think some users might prefer WSL2 because they can more directly interact with the distro using e.g. `wsl -d lima-$instance-name` and other easy to use commands, vs the more intimidating HyperV interfaces.
AkihiroSuda: > I think some users might prefer WSL2 

I guess the most common reason to prefer WSL2 over bare Hyper-V would be to share files using 9p?


afbjorklund: It would probably be a good idea for containerd either way, e.g. Docker and Podman already use WSL2

And as far as I know, you can already use QEMU (with WHPX) so Hyper-V isn't strictly necessary either?
pendo324: Addressed comments and rebased. Also added documentation. Would be good to merge #1726 first since it's just extracted from @afbjorklund PR. I can just squash it into this PR if that's preferable though.
jandubois: > I guess the most common reason to prefer WSL2 over bare Hyper-V would be to share files using 9p?

Another one is that WSL2 is supported in Windows Home whereas Hyper-V requires at least Windows Pro. This is purely a licensing issue as WSL2 is running on top of Hyper-V.
pendo324: Not sure why the tests are failing, but it doesn't seem related. Should I keep retrying @AkihiroSuda?
AkihiroSuda: > Not sure why the tests are failing, but it doesn't seem related. Should I keep retrying @AkihiroSuda?

CI for Colima seems consistently failing after restarting several times. Maybe a regression in this PR?
pendo324: > > Not sure why the tests are failing, but it doesn't seem related. Should I keep retrying @AkihiroSuda?
> 
> CI for Colima seems consistently failing after restarting several times. Maybe a regression in this PR?

Found the bug (typo) and fixed it (tests passed locally). Should be good to go after CI passes
AkihiroSuda: Thanks, almost looks good, but needs rebase

Patch:
diff --git a/Makefile b/Makefile
index 253deab4..cb458c41 100644
--- a/Makefile
+++ b/Makefile
@@ -22,6 +22,17 @@ GO_BUILDTAGS += no_vz
 endif
 endif
 
+ifeq ($(GOOS),windows)
+WINVER_MAJOR=$(shell powershell.exe "[System.Environment]::OSVersion.Version.Major")
+ifeq ($(WINVER_MAJOR),10)
+WINVER_BUILD=$(shell powershell.exe "[System.Environment]::OSVersion.Version.Build")
+WINVER_BUILD_HIGH_ENOUGH=$(shell powershell.exe $(WINVER_BUILD) -ge 19041)
+ifeq ($(WINVER_BUILD_HIGH_ENOUGH),False)
+GO_BUILDTAGS += no_wsl
+endif
+endif
+endif
+
 PACKAGE := github.com/lima-vm/lima
 
 VERSION=$(shell git describe --match 'v[0-9]*' --dirty='.m' --always --tags)
diff --git a/cmd/lima-guestagent/daemon_linux.go b/cmd/lima-guestagent/daemon_linux.go
index 46db9dc8..ff82ecff 100644
--- a/cmd/lima-guestagent/daemon_linux.go
+++ b/cmd/lima-guestagent/daemon_linux.go
@@ -10,6 +10,7 @@ import (
 	"github.com/gorilla/mux"
 	"github.com/lima-vm/lima/pkg/guestagent"
 	"github.com/lima-vm/lima/pkg/guestagent/api/server"
+	"github.com/mdlayher/vsock"
 	"github.com/sirupsen/logrus"
 	"github.com/spf13/cobra"
 )
@@ -21,6 +22,7 @@ func newDaemonCommand() *cobra.Command {
 		RunE:  daemonAction,
 	}
 	daemonCommand.Flags().Duration("tick", 3*time.Second, "tick for polling events")
+	daemonCommand.Flags().Int("vsock-port", 0, "use vsock server instead a UNIX socket")
 	return daemonCommand
 }
 
@@ -30,6 +32,10 @@ func daemonAction(cmd *cobra.Command, _ []string) error {
 	if err != nil {
 		return err
 	}
+	vSockPort, err := cmd.Flags().GetInt("vsock-port")
+	if err != nil {
+		return err
+	}
 	if tick == 0 {
 		return errors.New("tick must be specified")
 	}
@@ -60,13 +66,25 @@ func daemonAction(cmd *cobra.Command, _ []string) error {
 	if err != nil {
 		return err
 	}
-	l, err := net.Listen("unix", socket)
-	if err != nil {
-		return err
-	}
-	if err := os.Chmod(socket, 0777); err != nil {
-		return err
+
+	var l net.Listener
+	if vSockPort != 0 {
+		vsockL, err := vsock.Listen(uint32(vSockPort), nil)
+		if err != nil {
+			return err
+		}
+		l = vsockL
+		logrus.Infof("serving the guest agent on vsock port: %d", vSockPort)
+	} else {
+		socketL, err := net.Listen("unix", socket)
+		if err != nil {
+			return err
+		}
+		if err := os.Chmod(socket, 0777); err != nil {
+			return err
+		}
+		l = socketL
+		logrus.Infof("serving the guest agent on %q", socket)
 	}
-	logrus.Infof("serving the guest agent on %q", socket)
 	return srv.Serve(l)
 }
diff --git a/cmd/lima-guestagent/install_systemd_linux.go b/cmd/lima-guestagent/install_systemd_linux.go
index 60fd5b36..112c3dd3 100644
--- a/cmd/lima-guestagent/install_systemd_linux.go
+++ b/cmd/lima-guestagent/install_systemd_linux.go
@@ -3,6 +3,7 @@ package main
 import (
 	_ "embed"
 	"errors"
+	"fmt"
 	"os"
 	"os/exec"
 	"path/filepath"
@@ -19,11 +20,16 @@ func newInstallSystemdCommand() *cobra.Command {
 		Short: "install a systemd unit (user)",
 		RunE:  installSystemdAction,
 	}
+	installSystemdCommand.Flags().Int("vsock-port", 0, "use vsock server on specified port")
 	return installSystemdCommand
 }
 
-func installSystemdAction(_ *cobra.Command, _ []string) error {
-	unit, err := generateSystemdUnit()
+func installSystemdAction(cmd *cobra.Command, _ []string) error {
+	vsockPort, err := cmd.Flags().GetInt("vsock-port")
+	if err != nil {
+		return err
+	}
+	unit, err := generateSystemdUnit(vsockPort)
 	if err != nil {
 		return err
 	}
@@ -40,11 +46,11 @@ func installSystemdAction(_ *cobra.Command, _ []string) error {
 		return err
 	}
 	logrus.Infof("Written file %q", unitPath)
-	argss := [][]string{
+	args := [][]string{
 		{"daemon-reload"},
 		{"enable", "--now", "lima-guestagent.service"},
 	}
-	for _, args := range argss {
+	for _, args := range args {
 		cmd := exec.Command("systemctl", append([]string{"--system"}, args...)...)
 		cmd.Stdout = os.Stdout
 		cmd.Stderr = os.Stderr
@@ -60,13 +66,20 @@ func installSystemdAction(_ *cobra.Command, _ []string) error {
 //go:embed lima-guestagent.TEMPLATE.service
 var systemdUnitTemplate string
 
-func generateSystemdUnit() ([]byte, error) {
+func generateSystemdUnit(vsockPort int) ([]byte, error) {
 	selfExeAbs, err := os.Executable()
 	if err != nil {
 		return nil, err
 	}
+
+	var args []string
+	if vsockPort != 0 {
+		args = append(args, fmt.Sprintf("--vsock-port %d", vsockPort))
+	}
+
 	m := map[string]string{
 		"Binary": selfExeAbs,
+		"Args":   strings.Join(args, " "),
 	}
 	return textutil.ExecuteTemplate(systemdUnitTemplate, m)
 }
diff --git a/cmd/lima-guestagent/lima-guestagent.TEMPLATE.service b/cmd/lima-guestagent/lima-guestagent.TEMPLATE.service
index ddbd24b7..dc10e21c 100644
--- a/cmd/lima-guestagent/lima-guestagent.TEMPLATE.service
+++ b/cmd/lima-guestagent/lima-guestagent.TEMPLATE.service
@@ -2,7 +2,7 @@
 Description=lima-guestagent
 
 [Service]
-ExecStart={{.Binary}} daemon
+ExecStart={{.Binary}} daemon {{.Args}}
 Type=simple
 Restart=on-failure
 
diff --git a/cmd/limactl/delete.go b/cmd/limactl/delete.go
index 55dd20c2..97359c6f 100644
--- a/cmd/limactl/delete.go
+++ b/cmd/limactl/delete.go
@@ -62,6 +62,7 @@ func deleteInstance(ctx context.Context, inst *store.Instance, force bool) error
 	if err := os.RemoveAll(inst.Dir); err != nil {
 		return fmt.Errorf("failed to remove %q: %w", inst.Dir, err)
 	}
+
 	return nil
 }
 
diff --git a/cmd/limactl/hostagent.go b/cmd/limactl/hostagent.go
index 78e6b615..e412a8da 100644
--- a/cmd/limactl/hostagent.go
+++ b/cmd/limactl/hostagent.go
@@ -98,6 +98,7 @@ func hostagentAction(cmd *cobra.Command, args []string) error {
 		return err
 	}
 	l, err := net.Listen("unix", socket)
+	logrus.Infof("hostagent socket created at %s", socket)
 	if err != nil {
 		return err
 	}
diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go
index b508c2ee..00e75be0 100644
--- a/cmd/limactl/shell.go
+++ b/cmd/limactl/shell.go
@@ -184,7 +184,7 @@ func shellAction(cmd *cobra.Command, args []string) error {
 	sshArgs = append(sshArgs, []string{
 		"-q",
 		"-p", strconv.Itoa(inst.SSHLocalPort),
-		"127.0.0.1",
+		inst.SSHAddress,
 		"--",
 		script,
 	}...)
diff --git a/docs/experimental.md b/docs/experimental.md
index 812bffc3..1a5f73e7 100644
--- a/docs/experimental.md
+++ b/docs/experimental.md
@@ -5,6 +5,7 @@ The following features are experimental and subject to change:
 - `mountType: 9p`
 - `mountType: virtiofs` on Linux
 - `vmType: vz` and relevant configurations (`mountType: virtiofs`, `rosetta`, `[]networks.vzNAT`)
+- `vmType: wsl2` and relevant configurations (`mountType: wsl2`)
 - `arch: riscv64`
 - `video.display: vnc` and relevant configuration (`video.vnc.display`)
 - `mode: user-v2` in `networks.yml` and relevant configuration in `lima.yaml`
diff --git a/docs/mount.md b/docs/mount.md
index a92313b1..3d2c4e01 100644
--- a/docs/mount.md
+++ b/docs/mount.md
@@ -106,3 +106,22 @@ mounts:
 - For macOS, the "virtiofs" mount type is supported only on macOS 13 or above with `vmType: vz` config. See also [`vmtype.md`](./vmtype.md).
 - For Linux, the "virtiofs" mount type requires the [Rust version of virtiofsd](https://gitlab.com/virtio-fs/virtiofsd).
   Using the version from QEMU (usually packaged as `qemu-virtiofsd`) will *not* work, as it requires root access to run.
+
+### wsl2
+> **Warning**
+> "wsl2" mode is experimental
+
+| :zap: Requirement | Lima >= 0.18 + (Windows >= 10 Build 19041 OR Windows 11) |
+| ----------------- | -------------------------------------------------------- |
+
+The "wsl2" mount type relies on using WSL2's navite disk sharing, where the root disk is available by default at `/mnt/$DISK_LETTER` (e.g. `/mnt/c/`).
+
+An example configuration:
+```yaml
+vmType: "wsl2"
+mountType: "wsl2"
+```
+
+#### Caveats
+- WSL2 file permissions may not work exactly as expected when accessing files that are natively on the Windows disk ([more info](https://github.com/MicrosoftDocs/WSL/blob/mattw-wsl2-explainer/WSL/file-permissions.md))
+- WSL2's disk sharing system uses a 9P protocol server, making the performance similar to [Lima's 9p](#9p) mode ([more info](https://github.com/MicrosoftDocs/WSL/blob/mattw-wsl2-explainer/WSL/wsl2-architecture.md#wsl-2-architectural-flow))
diff --git a/docs/vmtype.md b/docs/vmtype.md
index 825fce20..1d89e11e 100644
--- a/docs/vmtype.md
+++ b/docs/vmtype.md
@@ -45,3 +45,36 @@ mountType: "virtiofs"
   kernel v6.3 and later should boot, as long as it is booted via GRUB.
   https://github.com/lima-vm/lima/issues/1577#issuecomment-1565625668
   The issue is fixed in macOS 13.5.
+
+## WSL2
+> **Warning**
+> "wsl2" mode is experimental
+
+| :zap: Requirement | Lima >= 0.18 + (Windows >= 10 Build 19041 OR Windows 11) |
+| ----------------- | -------------------------------------------------------- |
+
+"wsl2" option makes use of native virtualization support provided by Windows' `wsl.exe` ([more info](https://learn.microsoft.com/en-us/windows/wsl/about)).
+
+An example configuration:
+```yaml
+# Example to run Fedora using vmType: wsl2
+vmType: wsl2
+images:
+# Source: https://github.com/runfinch/finch-core/blob/main/Dockerfile
+- location: "https://deps.runfinch.com/common/x86-64/finch-rootfs-production-amd64-1690920103.tar.zst"
+  arch: "x86_64"
+  digest: "sha256:53f2e329b8da0f6a25e025d1f6cc262ae228402ba615ad095739b2f0ec6babc9"
+mountType: wsl2 
+containerd:
+  system: true
+  user: false
+```
+
+### Caveats
+- "wsl2" option is only supported on newer versions of Windows (roughly anything since 2019)
+
+### Known Issues
+- "wsl2" currently doesn't support many of Lima's options. See [this file](../pkg/wsl2/wsl_driver_windows.go#35) for the latest supported options.
+- When running lima using "wsl2", `${LIMA_HOME}/<INSTANCE>/serial.log` will not contain kernel boot logs
+- WSL2 requires a `tar` formatted rootfs archive instead of a VM image
+- Windows doesn't ship with ssh.exe, gzip.exe, etc. which are used by Lima at various points. The easiest way around this is to run `winget install -e --id Git.MinGit` (winget is now built in to Windows as well), and add the resulting `C:\Program Files\Git\usr\bin\` directory to your path.
diff --git a/examples/experimental/wsl2.yaml b/examples/experimental/wsl2.yaml
new file mode 100644
index 00000000..95a36baa
--- /dev/null
+++ b/examples/experimental/wsl2.yaml
@@ -0,0 +1,17 @@
+# This template requires Lima v0.18.0 or later and only works on Windows versions
+# that support WSL2 (Windows 10 Build >= 19041, all Windows 11).
+vmType: wsl2
+
+images:
+# Source: https://github.com/runfinch/finch-core/blob/main/Dockerfile
+- location: "https://deps.runfinch.com/common/x86-64/finch-rootfs-production-amd64-1690920103.tar.zst"
+  arch: "x86_64"
+  digest: "sha256:53f2e329b8da0f6a25e025d1f6cc262ae228402ba615ad095739b2f0ec6babc9"
+
+mountType: wsl2
+
+# Use system because of an error when setting up RootlessKit (see https://github.com/microsoft/WSL/issues/8842)
+# There are possible workarounds, just not implemented yet.
+containerd:
+  system: true
+  user: false
diff --git a/go.mod b/go.mod
index 53eaeb40..eea2c76a 100644
--- a/go.mod
+++ b/go.mod
@@ -51,7 +51,7 @@ require (
 
 require (
 	github.com/Code-Hex/go-infinity-channel v1.0.0 // indirect
-	github.com/Microsoft/go-winio v0.6.1 // indirect
+	github.com/Microsoft/go-winio v0.6.1
 	github.com/VividCortex/ewma v1.2.0 // indirect
 	github.com/a8m/envsubst v1.4.2 // indirect
 	github.com/alecthomas/participle/v2 v2.0.0 // indirect
@@ -89,7 +89,7 @@ require (
 	github.com/mattn/go-colorable v0.1.13 // indirect
 	github.com/mattn/go-runewidth v0.0.14 // indirect
 	github.com/mdlayher/socket v0.4.1 // indirect
-	github.com/mdlayher/vsock v1.2.1 // indirect
+	github.com/mdlayher/vsock v1.2.1
 	github.com/mgutz/ansi v0.0.0-20170206155736-9520e82c474b // indirect
 	github.com/modern-go/concurrent v0.0.0-20180306012644-bacd9c7ef1dd // indirect
 	github.com/modern-go/reflect2 v1.0.2 // indirect
@@ -103,11 +103,12 @@ require (
 	go.uber.org/atomic v1.7.0 // indirect
 	go.uber.org/multierr v1.7.0 // indirect
 	golang.org/x/crypto v0.12.0 // indirect
-	golang.org/x/mod v0.10.0 // indirect
+	golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b
+	golang.org/x/mod v0.11.0 // indirect
 	golang.org/x/net v0.14.0 // indirect
 	golang.org/x/oauth2 v0.8.0 // indirect
 	golang.org/x/term v0.11.0 // indirect
-	golang.org/x/text v0.12.0 // indirect
+	golang.org/x/text v0.12.0
 	golang.org/x/time v0.3.0 // indirect
 	golang.org/x/tools v0.9.3 // indirect
 	golang.org/x/xerrors v0.0.0-20220907171357-04be3eba64a2 // indirect
diff --git a/go.sum b/go.sum
index 3a284775..1d637bad 100644
--- a/go.sum
+++ b/go.sum
@@ -264,14 +264,16 @@ golang.org/x/crypto v0.0.0-20210921155107-089bfa567519/go.mod h1:GvvjBRRGRdwPK5y
 golang.org/x/crypto v0.0.0-20211215153901-e495a2d5b3d3/go.mod h1:IxCIyHEi3zRg3s0A5j5BB6A9Jmi73HwBIUl50j+osU4=
 golang.org/x/crypto v0.12.0 h1:tFM/ta59kqch6LlvYnPa0yx5a83cL2nHflFhYKvv9Yk=
 golang.org/x/crypto v0.12.0/go.mod h1:NF0Gs7EO5K4qLn+Ylc+fih8BSTeIjAP05siRnAh98yw=
+golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b h1:r+vk0EmXNmekl0S0BascoeeoHk/L7wmaW2QF90K+kYI=
+golang.org/x/exp v0.0.0-20230801115018-d63ba01acd4b/go.mod h1:FXUEEKJgO7OQYeo8N01OfiKP8RXMtf6e8aTskBGqWdc=
 golang.org/x/lint v0.0.0-20200302205851-738671d3881b/go.mod h1:3xt1FjdF8hUf6vQPIChWIBhFzV8gjjsPE/fR3IyQdNY=
 golang.org/x/mod v0.1.1-0.20191105210325-c90efee705ee/go.mod h1:QqPTAvyqsEbceGzBzNggFXnrqF1CaUcvgkdR5Ot7KZg=
 golang.org/x/mod v0.2.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.3.0/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.4.2/go.mod h1:s0Qsj1ACt9ePp/hMypM3fl4fZqREWJwdYDEqhRiZZUA=
 golang.org/x/mod v0.6.0-dev.0.20220419223038-86c51ed26bb4/go.mod h1:jJ57K6gSWd91VN4djpZkiMVwK6gcyfeH4XE8wZrZaV4=
-golang.org/x/mod v0.10.0 h1:lFO9qtOdlre5W1jxS3r/4szv2/6iXxScdzjoBMXNhYk=
-golang.org/x/mod v0.10.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
+golang.org/x/mod v0.11.0 h1:bUO06HqtnRcc/7l71XBe4WcqTZ+3AH1J59zWDDwLKgU=
+golang.org/x/mod v0.11.0/go.mod h1:iBbtSCu2XBx23ZKBPSOrRkjjQPZFPuis4dIYUhu/chs=
 golang.org/x/net v0.0.0-20190311183353-d8887717615a/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190404232315-eb5bcb51f2a3/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
 golang.org/x/net v0.0.0-20190419010253-1f3472d942ba/go.mod h1:t9HGtf8HONx5eT2rtn7q6eTqICYqUVnKs3thJo3Qplg=
diff --git a/pkg/cidata/cidata.TEMPLATE.d/boot/02-wsl2-setup.sh b/pkg/cidata/cidata.TEMPLATE.d/boot/02-wsl2-setup.sh
new file mode 100755
index 00000000..0b6e9578
--- /dev/null
+++ b/pkg/cidata/cidata.TEMPLATE.d/boot/02-wsl2-setup.sh
@@ -0,0 +1,17 @@
+#!/bin/sh
+# This script replaces the cloud-init functionality of creating a user and setting its SSH keys
+# when using a WSL2 VM.
+[ "$LIMA_CIDATA_VMTYPE" = "wsl2" ] || exit 0
+
+# create user
+sudo useradd -u "${LIMA_CIDATA_UID}" "${LIMA_CIDATA_USER}" -d "${LIMA_CIDATA_HOME}"
+sudo mkdir "${LIMA_CIDATA_HOME}"/.ssh/
+sudo cp "${LIMA_CIDATA_MNT}"/ssh_authorized_keys "${LIMA_CIDATA_HOME}"/.ssh/authorized_keys
+sudo chown "${LIMA_CIDATA_USER}" "${LIMA_CIDATA_HOME}"/.ssh/authorized_keys
+
+# add $LIMA_CIDATA_USER to sudoers
+echo "${LIMA_CIDATA_USER} ALL=(ALL) NOPASSWD:ALL" | sudo tee -a /etc/sudoers.d/99_lima_sudoers
+
+# copy some CIDATA to the hardcoded path for requirement checks (TODO: make this not hardcoded)
+sudo mkdir -p /mnt/lima-cidata
+sudo cp "${LIMA_CIDATA_MNT}"/meta-data /mnt/lima-cidata/meta-data
diff --git a/pkg/cidata/cidata.TEMPLATE.d/boot/25-guestagent-base.sh b/pkg/cidata/cidata.TEMPLATE.d/boot/25-guestagent-base.sh
index 3276ec2a..379f9c4b 100644
--- a/pkg/cidata/cidata.TEMPLATE.d/boot/25-guestagent-base.sh
+++ b/pkg/cidata/cidata.TEMPLATE.d/boot/25-guestagent-base.sh
@@ -40,5 +40,9 @@ else
 	# Remove legacy systemd service
 	rm -f "${LIMA_CIDATA_HOME}/.config/systemd/user/lima-guestagent.service"
 
-	sudo "${LIMA_CIDATA_GUEST_INSTALL_PREFIX}"/bin/lima-guestagent install-systemd
+	if [ "$LIMA_CIDATA_VMTYPE" = "wsl2" ]; then
+		sudo "${LIMA_CIDATA_GUEST_INSTALL_PREFIX}"/bin/lima-guestagent install-systemd --vsock-port "${LIMA_CIDATA_VSOCK_PORT}"
+	else
+		sudo "${LIMA_CIDATA_GUEST_INSTALL_PREFIX}"/bin/lima-guestagent install-systemd
+	fi
 fi
diff --git a/pkg/cidata/cidata.TEMPLATE.d/lima.env b/pkg/cidata/cidata.TEMPLATE.d/lima.env
index 3838d28f..d1c7a2b0 100644
--- a/pkg/cidata/cidata.TEMPLATE.d/lima.env
+++ b/pkg/cidata/cidata.TEMPLATE.d/lima.env
@@ -39,3 +39,5 @@ LIMA_CIDATA_SKIP_DEFAULT_DEPENDENCY_RESOLUTION=1
 {{- else}}
 LIMA_CIDATA_SKIP_DEFAULT_DEPENDENCY_RESOLUTION=
 {{- end}}
+LIMA_CIDATA_VMTYPE={{ .VMType }}
+LIMA_CIDATA_VSOCK_PORT={{ .VSockPort }}
diff --git a/pkg/cidata/cidata.go b/pkg/cidata/cidata.go
index e0aa1619..8271e057 100644
--- a/pkg/cidata/cidata.go
+++ b/pkg/cidata/cidata.go
@@ -7,6 +7,7 @@ import (
 	"net"
 	"net/url"
 	"os"
+	"path"
 	"path/filepath"
 	"strconv"
 	"strings"
@@ -23,6 +24,7 @@ import (
 	"github.com/lima-vm/lima/pkg/store/filenames"
 	"github.com/lima-vm/lima/pkg/usrlocalsharelima"
 	"github.com/sirupsen/logrus"
+	"golang.org/x/exp/slices"
 )
 
 var netLookupIP = func(host string) []net.IP {
@@ -105,7 +107,7 @@ func setupEnv(y *limayaml.LimaYAML, args TemplateArgs) (map[string]string, error
 	return env, nil
 }
 
-func GenerateISO9660(instDir, name string, y *limayaml.LimaYAML, udpDNSLocalPort, tcpDNSLocalPort int, nerdctlArchive string) error {
+func GenerateISO9660(instDir, name string, y *limayaml.LimaYAML, udpDNSLocalPort, tcpDNSLocalPort int, nerdctlArchive string, vsockPort int) error {
 	if err := limayaml.Validate(*y, false); err != nil {
 		return err
 	}
@@ -128,6 +130,8 @@ func GenerateISO9660(instDir, name string, y *limayaml.LimaYAML, udpDNSLocalPort
 
 		RosettaEnabled: *y.Rosetta.Enabled,
 		RosettaBinFmt:  *y.Rosetta.BinFmt,
+		VMType:         *y.VMType,
+		VSockPort:      vsockPort,
 	}
 
 	firstUsernetIndex := limayaml.FirstUsernetIndex(y)
@@ -350,6 +354,14 @@ func GenerateISO9660(instDir, name string, y *limayaml.LimaYAML, udpDNSLocalPort
 		})
 	}
 
+	if args.VMType == limayaml.WSL2 {
+		layout = append(layout, iso9660util.Entry{
+			Path:   "ssh_authorized_keys",
+			Reader: strings.NewReader(strings.Join(args.SSHPubKeys, "\n")),
+		})
+		return writeCIDataDir(filepath.Join(instDir, filenames.CIDataISODir), layout)
+	}
+
 	return iso9660util.Write(filepath.Join(instDir, filenames.CIDataISO), "cidata", layout)
 }
 
@@ -400,3 +412,31 @@ func getBootCmds(p []limayaml.Provision) []BootCmds {
 func diskDeviceNameFromOrder(order int) string {
 	return fmt.Sprintf("vd%c", int('b')+order)
 }
+
+func writeCIDataDir(rootPath string, layout []iso9660util.Entry) error {
+	slices.SortFunc(layout, func(a, b iso9660util.Entry) int {
+		return strings.Compare(strings.ToLower(a.Path), strings.ToLower(b.Path))
+	})
+
+	if err := os.RemoveAll(rootPath); err != nil {
+		return err
+	}
+
+	for _, e := range layout {
+		if dir := path.Dir(e.Path); dir != "" && dir != "/" {
+			if err := os.MkdirAll(filepath.Join(rootPath, dir), 0700); err != nil {
+				return err
+			}
+		}
+		f, err := os.OpenFile(filepath.Join(rootPath, e.Path), os.O_CREATE|os.O_RDWR, 0700)
+		if err != nil {
+			return err
+		}
+		defer f.Close()
+		if _, err := io.Copy(f, e.Reader); err != nil {
+			return err
+		}
+	}
+
+	return nil
+}
diff --git a/pkg/cidata/template.go b/pkg/cidata/template.go
index 76410f8a..956c3663 100644
--- a/pkg/cidata/template.go
+++ b/pkg/cidata/template.go
@@ -79,6 +79,8 @@ type TemplateArgs struct {
 	RosettaEnabled                  bool
 	RosettaBinFmt                   bool
 	SkipDefaultDependencyResolution bool
+	VMType                          string
+	VSockPort                       int
 }
 
 func ValidateTemplateArgs(args TemplateArgs) error {
diff --git a/pkg/downloader/downloader.go b/pkg/downloader/downloader.go
index af46a910..39f21de7 100644
--- a/pkg/downloader/downloader.go
+++ b/pkg/downloader/downloader.go
@@ -353,7 +353,7 @@ func decompressLocal(dst, src, ext string, description string) error {
 		if description == "" {
 			description = filepath.Base(src)
 		}
-		fmt.Printf("Decompressing %s\n", description)
+		logrus.Infof("Decompressing %s\n", description)
 	}
 	bar.Start()
 	err = cmd.Run()
diff --git a/pkg/driverutil/driverutil.go b/pkg/driverutil/driverutil.go
index 1cddfd10..a0d12787 100644
--- a/pkg/driverutil/driverutil.go
+++ b/pkg/driverutil/driverutil.go
@@ -3,6 +3,7 @@ package driverutil
 import (
 	"github.com/lima-vm/lima/pkg/limayaml"
 	"github.com/lima-vm/lima/pkg/vz"
+	"github.com/lima-vm/lima/pkg/wsl2"
 )
 
 // Drivers returns the available drivers.
@@ -11,5 +12,8 @@ func Drivers() []string {
 	if vz.Enabled {
 		drivers = append(drivers, limayaml.VZ)
 	}
+	if wsl2.Enabled {
+		drivers = append(drivers, limayaml.WSL2)
+	}
 	return drivers
 }
diff --git a/pkg/driverutil/instance.go b/pkg/driverutil/instance.go
index 891153ac..09760faa 100644
--- a/pkg/driverutil/instance.go
+++ b/pkg/driverutil/instance.go
@@ -5,6 +5,7 @@ import (
 	"github.com/lima-vm/lima/pkg/limayaml"
 	"github.com/lima-vm/lima/pkg/qemu"
 	"github.com/lima-vm/lima/pkg/vz"
+	"github.com/lima-vm/lima/pkg/wsl2"
 )
 
 func CreateTargetDriverInstance(base *driver.BaseDriver) driver.Driver {
@@ -12,5 +13,8 @@ func CreateTargetDriverInstance(base *driver.BaseDriver) driver.Driver {
 	if *limaDriver == limayaml.VZ {
 		return vz.New(base)
 	}
+	if *limaDriver == limayaml.WSL2 {
+		return wsl2.New(base)
+	}
 	return qemu.New(base)
 }
diff --git a/pkg/executil/command.go b/pkg/executil/command.go
new file mode 100644
index 00000000..fb6e5949
--- /dev/null
+++ b/pkg/executil/command.go
@@ -0,0 +1,51 @@
+package executil
+
+import (
+	"bytes"
+	"context"
+	"fmt"
+	"os/exec"
+
+	"github.com/lima-vm/lima/pkg/ioutilx"
+)
+
+type options struct {
+	ctx *context.Context
+}
+
+type Opt func(*options) error
+
+// WithContext runs the command with CommandContext.
+func WithContext(ctx *context.Context) Opt {
+	return func(o *options) error {
+		o.ctx = ctx
+		return nil
+	}
+}
+
+func RunUTF16leCommand(args []string, opts ...Opt) (string, error) {
+	var o options
+	for _, f := range opts {
+		if err := f(&o); err != nil {
+			return "", err
+		}
+	}
+
+	var cmd *exec.Cmd
+	if o.ctx != nil {
+		cmd = exec.CommandContext(*o.ctx, args[0], args[1:]...)
+	} else {
+		cmd = exec.Command(args[0], args[1:]...)
+	}
+
+	outString := ""
+	out, err := cmd.CombinedOutput()
+	if out != nil {
+		s, err := ioutilx.FromUTF16leToString(bytes.NewReader(out))
+		if err != nil {
+			return "", fmt.Errorf("failed to convert output from UTF16 when running command %v, err: %w", args, err)
+		}
+		outString = s
+	}
+	return outString, err
+}
diff --git a/pkg/guestagent/api/client/client.go b/pkg/guestagent/api/client/client.go
index 285abe92..d58aaeef 100644
--- a/pkg/guestagent/api/client/client.go
+++ b/pkg/guestagent/api/client/client.go
@@ -7,7 +7,9 @@ import (
 	"context"
 	"encoding/json"
 	"fmt"
+	"net"
 	"net/http"
+	"strconv"
 
 	"github.com/lima-vm/lima/pkg/guestagent/api"
 	"github.com/lima-vm/lima/pkg/httpclientutil"
@@ -19,13 +21,39 @@ type GuestAgentClient interface {
 	Events(context.Context, func(api.Event)) error
 }
 
+type Proto = string
+
+const (
+	UNIX  Proto = "unix"
+	VSOCK Proto = "vsock"
+)
+
 // NewGuestAgentClient creates a client.
-// socketPath is a path to the UNIX socket, without unix:// prefix.
-func NewGuestAgentClient(socketPath string) (GuestAgentClient, error) {
-	hc, err := httpclientutil.NewHTTPClientWithSocketPath(socketPath)
-	if err != nil {
-		return nil, err
+// remote is a path to the UNIX socket, without unix:// prefix or a remote hostname/IP address.
+func NewGuestAgentClient(remote string, proto Proto, instanceName string) (GuestAgentClient, error) {
+	var hc *http.Client
+	switch proto {
+	case UNIX:
+		hcSock, err := httpclientutil.NewHTTPClientWithSocketPath(remote)
+		if err != nil {
+			return nil, err
+		}
+		hc = hcSock
+	case VSOCK:
+		_, p, err := net.SplitHostPort(remote)
+		if err != nil {
+			return nil, err
+		}
+		port, err := strconv.Atoi(p)
+		if err != nil {
+			return nil, err
+		}
+		hc, err = newVSockGuestAgentClient(port, instanceName)
+		if err != nil {
+			return nil, err
+		}
 	}
+
 	return NewGuestAgentClientWithHTTPClient(hc), nil
 }
 
diff --git a/pkg/guestagent/api/client/client_others.go b/pkg/guestagent/api/client/client_others.go
new file mode 100644
index 00000000..7fcbdb18
--- /dev/null
+++ b/pkg/guestagent/api/client/client_others.go
@@ -0,0 +1,16 @@
+//go:build !windows
+// +build !windows
+
+package client
+
+import (
+	"net/http"
+
+	"github.com/lima-vm/lima/pkg/httpclientutil"
+)
+
+func newVSockGuestAgentClient(port int, _ string) (*http.Client, error) {
+	hc := httpclientutil.NewHTTPClientWithVSockPort(port)
+
+	return hc, nil
+}
diff --git a/pkg/guestagent/api/client/client_windows.go b/pkg/guestagent/api/client/client_windows.go
new file mode 100644
index 00000000..7dc19658
--- /dev/null
+++ b/pkg/guestagent/api/client/client_windows.go
@@ -0,0 +1,19 @@
+//go:build windows
+// +build windows
+
+package client
+
+import (
+	"net/http"
+
+	"github.com/lima-vm/lima/pkg/httpclientutil"
+)
+
+func newVSockGuestAgentClient(port int, instanceName string) (*http.Client, error) {
+	hc, err := httpclientutil.NewHTTPClientWithVSockPort(instanceName, port)
+	if err != nil {
+		return nil, err
+	}
+
+	return hc, nil
+}
diff --git a/pkg/guestagent/guestagent_linux.go b/pkg/guestagent/guestagent_linux.go
index 1807bf6d..889a46a5 100644
--- a/pkg/guestagent/guestagent_linux.go
+++ b/pkg/guestagent/guestagent_linux.go
@@ -5,6 +5,7 @@ import (
 	"errors"
 	"reflect"
 	"sync"
+	"syscall"
 	"time"
 
 	"github.com/elastic/go-libaudit/v2"
@@ -25,9 +26,17 @@ func New(newTicker func() (<-chan time.Time, func()), iptablesIdle time.Duration
 	}
 
 	auditClient, err := libaudit.NewMulticastAuditClient(nil)
-	if err != nil {
+	switch {
+	case errors.Is(err, syscall.EPROTONOSUPPORT), errors.Is(err, syscall.EAFNOSUPPORT):
+		// system doesn't support auditing, skip
+		a.worthCheckingIPTables = true
+		go a.kubernetesServiceWatcher.Start()
+		go a.fixSystemTimeSkew()
+		return a, nil
+	case !errors.Is(err, nil):
 		return nil, err
 	}
+
 	auditStatus, err := auditClient.GetStatus()
 	if err != nil {
 		return nil, err
@@ -36,9 +45,20 @@ func New(newTicker func() (<-chan time.Time, func()), iptablesIdle time.Duration
 		if err = auditClient.SetEnabled(true, libaudit.WaitForReply); err != nil {
 			return nil, err
 		}
-	}
+		auditStatus, err := auditClient.GetStatus()
+		if err != nil {
+			return nil, err
+		}
+		if auditStatus.Enabled == 0 {
+			if err = auditClient.SetEnabled(true, libaudit.WaitForReply); err != nil {
+				return nil, err
+			}
+		}
 
-	go a.setWorthCheckingIPTablesRoutine(auditClient, iptablesIdle)
+		go a.setWorthCheckingIPTablesRoutine(auditClient, iptablesIdle)
+	} else {
+		a.worthCheckingIPTables = true
+	}
 	go a.kubernetesServiceWatcher.Start()
 	go a.fixSystemTimeSkew()
 	return a, nil
diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go
index d788e2eb..0951551f 100644
--- a/pkg/hostagent/hostagent.go
+++ b/pkg/hostagent/hostagent.go
@@ -42,15 +42,19 @@ type HostAgent struct {
 	tcpDNSLocalPort int
 	instDir         string
 	instName        string
+	instSSHAddress  string
 	sshConfig       *ssh.SSHConfig
 	portForwarder   *portForwarder
 	onClose         []func() error // LIFO
+	guestAgentProto guestagentclient.Proto
 
 	driver   driver.Driver
 	sigintCh chan os.Signal
 
 	eventEnc   *json.Encoder
 	eventEncMu sync.Mutex
+
+	vSockPort int
 }
 
 type options struct {
@@ -91,6 +95,9 @@ func New(instName string, stdout io.Writer, sigintCh chan os.Signal, opts ...Opt
 	if err != nil {
 		return nil, err
 	}
+	if *y.VMType == limayaml.WSL2 {
+		sshLocalPort = inst.SSHLocalPort
+	}
 
 	var udpDNSLocalPort, tcpDNSLocalPort int
 	if *y.HostResolver.Enabled {
@@ -104,7 +111,21 @@ func New(instName string, stdout io.Writer, sigintCh chan os.Signal, opts ...Opt
 		}
 	}
 
-	if err := cidata.GenerateISO9660(inst.Dir, instName, y, udpDNSLocalPort, tcpDNSLocalPort, o.nerdctlArchive); err != nil {
+	guestAgentProto := guestagentclient.UNIX
+	if *y.VMType == limayaml.WSL2 {
+		guestAgentProto = guestagentclient.VSOCK
+	}
+
+	vSockPort := 0
+	if guestAgentProto == guestagentclient.VSOCK {
+		port, err := getFreeVSockPort()
+		if err != nil {
+			logrus.WithError(err).Error("failed to get free VSock port")
+		}
+		vSockPort = port
+	}
+
+	if err := cidata.GenerateISO9660(inst.Dir, instName, y, udpDNSLocalPort, tcpDNSLocalPort, o.nerdctlArchive, vSockPort); err != nil {
 		return nil, err
 	}
 
@@ -112,7 +133,7 @@ func New(instName string, stdout io.Writer, sigintCh chan os.Signal, opts ...Opt
 	if err != nil {
 		return nil, err
 	}
-	if err = writeSSHConfigFile(inst, sshLocalPort, sshOpts); err != nil {
+	if err = writeSSHConfigFile(inst, inst.SSHAddress, sshLocalPort, sshOpts); err != nil {
 		return nil, err
 	}
 	sshConfig := &ssh.SSHConfig{
@@ -145,16 +166,19 @@ func New(instName string, stdout io.Writer, sigintCh chan os.Signal, opts ...Opt
 		tcpDNSLocalPort: tcpDNSLocalPort,
 		instDir:         inst.Dir,
 		instName:        instName,
+		instSSHAddress:  inst.SSHAddress,
 		sshConfig:       sshConfig,
-		portForwarder:   newPortForwarder(sshConfig, sshLocalPort, rules),
+		portForwarder:   newPortForwarder(sshConfig, sshLocalPort, rules, inst.VMType),
 		driver:          limaDriver,
 		sigintCh:        sigintCh,
 		eventEnc:        json.NewEncoder(stdout),
+		vSockPort:       vSockPort,
+		guestAgentProto: guestAgentProto,
 	}
 	return a, nil
 }
 
-func writeSSHConfigFile(inst *store.Instance, sshLocalPort int, sshOpts []string) error {
+func writeSSHConfigFile(inst *store.Instance, instSSHAddress string, sshLocalPort int, sshOpts []string) error {
 	if inst.Dir == "" {
 		return fmt.Errorf("directory is unknown for the instance %q", inst.Name)
 	}
@@ -167,7 +191,7 @@ func writeSSHConfigFile(inst *store.Instance, sshLocalPort int, sshOpts []string
 	}
 	if err := sshutil.Format(&b, inst.Name, sshutil.FormatConfig,
 		append(sshOpts,
-			"Hostname=127.0.0.1",
+			fmt.Sprintf("Hostname=%s", instSSHAddress),
 			fmt.Sprintf("Port=%d", sshLocalPort),
 		)); err != nil {
 		return err
@@ -292,6 +316,15 @@ func (a *HostAgent) Run(ctx context.Context) error {
 		return err
 	}
 
+	// WSL instance SSH address isn't known until after VM start
+	if *a.y.VMType == limayaml.WSL2 {
+		sshAddr, err := store.GetSSHAddress(a.instName)
+		if err != nil {
+			return err
+		}
+		a.instSSHAddress = sshAddr
+	}
+
 	if a.y.Video.Display != nil && *a.y.Video.Display == "vnc" {
 		vncdisplay, vncoptions, _ := strings.Cut(*a.y.Video.VNC.Display, ",")
 		vnchost, vncnum, err := net.SplitHostPort(vncdisplay)
@@ -396,7 +429,7 @@ func (a *HostAgent) Info(_ context.Context) (*hostagentapi.Info, error) {
 func (a *HostAgent) startHostAgentRoutines(ctx context.Context) error {
 	a.onClose = append(a.onClose, func() error {
 		logrus.Debugf("shutting down the SSH master")
-		if exitMasterErr := ssh.ExitMaster("127.0.0.1", a.sshLocalPort, a.sshConfig); exitMasterErr != nil {
+		if exitMasterErr := ssh.ExitMaster(a.instSSHAddress, a.sshLocalPort, a.sshConfig); exitMasterErr != nil {
 			logrus.WithError(exitMasterErr).Warn("failed to exit SSH master")
 		}
 		return nil
@@ -412,7 +445,7 @@ sudo mkdir -p -m 700 /run/host-services
 sudo ln -sf "${SSH_AUTH_SOCK}" /run/host-services/ssh-auth.sock
 sudo chown -R "${USER}" /run/host-services`
 		faDesc := "linking ssh auth socket to static location /run/host-services/ssh-auth.sock"
-		stdout, stderr, err := ssh.ExecuteScript("127.0.0.1", a.sshLocalPort, a.sshConfig, faScript, faDesc)
+		stdout, stderr, err := ssh.ExecuteScript(a.instSSHAddress, a.sshLocalPort, a.sshConfig, faScript, faDesc)
 		logrus.Debugf("stdout=%q, stderr=%q, err=%v", stdout, stderr, err)
 		if err != nil {
 			errs = append(errs, fmt.Errorf("stdout=%q, stderr=%q: %w", stdout, stderr, err))
@@ -494,11 +527,13 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) {
 	// TODO: use vSock (when QEMU for macOS gets support for vSock)
 
 	// Setup all socket forwards and defer their teardown
-	logrus.Debugf("Forwarding unix sockets")
-	for _, rule := range a.y.PortForwards {
-		if rule.GuestSocket != "" {
-			local := hostAddress(rule, guestagentapi.IPPort{})
-			_ = forwardSSH(ctx, a.sshConfig, a.sshLocalPort, local, rule.GuestSocket, verbForward, rule.Reverse)
+	if *a.y.VMType != limayaml.WSL2 {
+		logrus.Debugf("Forwarding unix sockets")
+		for _, rule := range a.y.PortForwards {
+			if rule.GuestSocket != "" {
+				local := hostAddress(rule, guestagentapi.IPPort{})
+				_ = forwardSSH(ctx, a.sshConfig, a.sshLocalPort, local, rule.GuestSocket, verbForward, rule.Reverse)
+			}
 		}
 	}
 
@@ -523,11 +558,18 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) {
 		return errors.Join(errs...)
 	})
 
+	guestSocketAddr := localUnix
+	if a.guestAgentProto == guestagentclient.VSOCK {
+		guestSocketAddr = fmt.Sprintf("0.0.0.0:%d", a.vSockPort)
+	}
+
 	for {
-		if !isGuestAgentSocketAccessible(ctx, localUnix) {
-			_ = forwardSSH(ctx, a.sshConfig, a.sshLocalPort, localUnix, remoteUnix, verbForward, false)
+		if !isGuestAgentSocketAccessible(ctx, guestSocketAddr, a.guestAgentProto, a.instName) {
+			if a.guestAgentProto != guestagentclient.VSOCK {
+				_ = forwardSSH(ctx, a.sshConfig, a.sshLocalPort, localUnix, remoteUnix, verbForward, false)
+			}
 		}
-		if err := a.processGuestAgentEvents(ctx, localUnix); err != nil {
+		if err := a.processGuestAgentEvents(ctx, guestSocketAddr, a.guestAgentProto, a.instName); err != nil {
 			if !errors.Is(err, context.Canceled) {
 				logrus.WithError(err).Warn("connection to the guest agent was closed unexpectedly")
 			}
@@ -540,8 +582,8 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) {
 	}
 }
 
-func isGuestAgentSocketAccessible(ctx context.Context, localUnix string) bool {
-	client, err := guestagentclient.NewGuestAgentClient(localUnix)
+func isGuestAgentSocketAccessible(ctx context.Context, localUnix string, proto guestagentclient.Proto, instanceName string) bool {
+	client, err := guestagentclient.NewGuestAgentClient(localUnix, proto, instanceName)
 	if err != nil {
 		return false
 	}
@@ -549,8 +591,8 @@ func isGuestAgentSocketAccessible(ctx context.Context, localUnix string) bool {
 	return err == nil
 }
 
-func (a *HostAgent) processGuestAgentEvents(ctx context.Context, localUnix string) error {
-	client, err := guestagentclient.NewGuestAgentClient(localUnix)
+func (a *HostAgent) processGuestAgentEvents(ctx context.Context, localUnix string, proto guestagentclient.Proto, instanceName string) error {
+	client, err := guestagentclient.NewGuestAgentClient(localUnix, proto, instanceName)
 	if err != nil {
 		return err
 	}
@@ -567,7 +609,7 @@ func (a *HostAgent) processGuestAgentEvents(ctx context.Context, localUnix strin
 		for _, f := range ev.Errors {
 			logrus.Warnf("received error from the guest: %q", f)
 		}
-		a.portForwarder.OnEvent(ctx, ev)
+		a.portForwarder.OnEvent(ctx, ev, a.instSSHAddress)
 	}
 
 	if err := client.Events(ctx, onEvent); err != nil {
diff --git a/pkg/hostagent/port.go b/pkg/hostagent/port.go
index 31edb0e7..772f951b 100644
--- a/pkg/hostagent/port.go
+++ b/pkg/hostagent/port.go
@@ -14,15 +14,17 @@ type portForwarder struct {
 	sshConfig   *ssh.SSHConfig
 	sshHostPort int
 	rules       []limayaml.PortForward
+	vmType      limayaml.VMType
 }
 
 const sshGuestPort = 22
 
-func newPortForwarder(sshConfig *ssh.SSHConfig, sshHostPort int, rules []limayaml.PortForward) *portForwarder {
+func newPortForwarder(sshConfig *ssh.SSHConfig, sshHostPort int, rules []limayaml.PortForward, vmType limayaml.VMType) *portForwarder {
 	return &portForwarder{
 		sshConfig:   sshConfig,
 		sshHostPort: sshHostPort,
 		rules:       rules,
+		vmType:      vmType,
 	}
 }
 
@@ -40,7 +42,15 @@ func hostAddress(rule limayaml.PortForward, guest api.IPPort) string {
 	return host.String()
 }
 
-func (pf *portForwarder) forwardingAddresses(guest api.IPPort) (string, string) {
+func (pf *portForwarder) forwardingAddresses(guest api.IPPort, localUnixIP net.IP) (string, string) {
+	if pf.vmType == limayaml.WSL2 {
+		guest.IP = localUnixIP
+		host := api.IPPort{
+			IP:   net.ParseIP("127.0.0.1"),
+			Port: guest.Port,
+		}
+		return host.String(), guest.String()
+	}
 	for _, rule := range pf.rules {
 		if rule.GuestSocket != "" {
 			continue
@@ -69,9 +79,11 @@ func (pf *portForwarder) forwardingAddresses(guest api.IPPort) (string, string)
 	return "", guest.String()
 }
 
-func (pf *portForwarder) OnEvent(ctx context.Context, ev api.Event) {
+func (pf *portForwarder) OnEvent(ctx context.Context, ev api.Event, instSSHAddress string) {
+	localUnixIP := net.ParseIP(instSSHAddress)
+
 	for _, f := range ev.LocalPortsRemoved {
-		local, remote := pf.forwardingAddresses(f)
+		local, remote := pf.forwardingAddresses(f, localUnixIP)
 		if local == "" {
 			continue
 		}
@@ -81,7 +93,7 @@ func (pf *portForwarder) OnEvent(ctx context.Context, ev api.Event) {
 		}
 	}
 	for _, f := range ev.LocalPortsAdded {
-		local, remote := pf.forwardingAddresses(f)
+		local, remote := pf.forwardingAddresses(f, localUnixIP)
 		if local == "" {
 			logrus.Infof("Not forwarding TCP %s", remote)
 			continue
diff --git a/pkg/hostagent/port_darwin.go b/pkg/hostagent/port_darwin.go
index f004b9a1..bd889674 100644
--- a/pkg/hostagent/port_darwin.go
+++ b/pkg/hostagent/port_darwin.go
@@ -156,3 +156,7 @@ func (plf *pseudoLoopbackForwarder) Close() error {
 	_ = plf.ln.Close()
 	return plf.onClose()
 }
+
+func getFreeVSockPort() (int, error) {
+	return 0, nil
+}
diff --git a/pkg/hostagent/port_others.go b/pkg/hostagent/port_others.go
index 35d276d6..601fb035 100644
--- a/pkg/hostagent/port_others.go
+++ b/pkg/hostagent/port_others.go
@@ -1,5 +1,5 @@
-//go:build !darwin
-// +build !darwin
+//go:build !darwin && !windows
+// +build !darwin,!windows
 
 package hostagent
 
@@ -12,3 +12,7 @@ import (
 func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, remote string, verb string) error {
 	return forwardSSH(ctx, sshConfig, port, local, remote, verb, false)
 }
+
+func getFreeVSockPort() (int, error) {
+	return 0, nil
+}
diff --git a/pkg/hostagent/port_windows.go b/pkg/hostagent/port_windows.go
new file mode 100644
index 00000000..f0917c2b
--- /dev/null
+++ b/pkg/hostagent/port_windows.go
@@ -0,0 +1,19 @@
+//go:build windows
+// +build windows
+
+package hostagent
+
+import (
+	"context"
+
+	"github.com/lima-vm/lima/pkg/windows"
+	"github.com/lima-vm/sshocker/pkg/ssh"
+)
+
+func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, remote string, verb string) error {
+	return forwardSSH(ctx, sshConfig, port, local, remote, verb, false)
+}
+
+func getFreeVSockPort() (int, error) {
+	return windows.GetRandomFreeVSockPort(0, 2147483647)
+}
diff --git a/pkg/hostagent/requirements.go b/pkg/hostagent/requirements.go
index 53e6b010..46057ed3 100644
--- a/pkg/hostagent/requirements.go
+++ b/pkg/hostagent/requirements.go
@@ -5,6 +5,7 @@ import (
 	"fmt"
 	"time"
 
+	guestagentclient "github.com/lima-vm/lima/pkg/guestagent/api/client"
 	"github.com/lima-vm/lima/pkg/limayaml"
 	"github.com/lima-vm/sshocker/pkg/ssh"
 	"github.com/sirupsen/logrus"
@@ -43,7 +44,7 @@ func (a *HostAgent) waitForRequirements(label string, requirements []requirement
 
 func (a *HostAgent) waitForRequirement(r requirement) error {
 	logrus.Debugf("executing script %q", r.description)
-	stdout, stderr, err := ssh.ExecuteScript("127.0.0.1", a.sshLocalPort, a.sshConfig, r.script, r.description)
+	stdout, stderr, err := ssh.ExecuteScript(a.instSSHAddress, a.sshLocalPort, a.sshConfig, r.script, r.description)
 	logrus.Debugf("stdout=%q, stderr=%q, err=%v", stdout, stderr, err)
 	if err != nil {
 		return fmt.Errorf("stdout=%q, stderr=%q: %w", stdout, stderr, err)
@@ -116,9 +117,26 @@ fi
 		})
 
 	}
-	req = append(req, requirement{
-		description: "the guest agent to be running",
-		script: `#!/bin/bash
+	if a.guestAgentProto == guestagentclient.VSOCK {
+		req = append(req, requirement{
+			description: "the guest agent to be running",
+			script: fmt.Sprintf(`#!/bin/bash
+set -eux -o pipefail
+if ! timeout 30s bash -c "until ss -a -n --vsock --listen | grep -q '*:%d'; do sleep 3; done"; then
+	echo >&2 "lima-guestagent is not installed yet"
+	exit 1
+fi
+`, a.vSockPort),
+			debugHint: `The guest agent (/run/lima-guestagent.sock) does not seem running.
+Make sure that you are using an officially supported image.
+Also see "/var/log/cloud-init-output.log" in the guest.
+A possible workaround is to run "lima-guestagent install-systemd" in the guest.
+`,
+		})
+	} else {
+		req = append(req, requirement{
+			description: "the guest agent to be running",
+			script: `#!/bin/bash
 set -eux -o pipefail
 sock="/run/lima-guestagent.sock"
 if ! timeout 30s bash -c "until [ -S \"${sock}\" ]; do sleep 3; done"; then
@@ -126,12 +144,13 @@ if ! timeout 30s bash -c "until [ -S \"${sock}\" ]; do sleep 3; done"; then
 	exit 1
 fi
 `,
-		debugHint: `The guest agent (/run/lima-guestagent.sock) does not seem running.
+			debugHint: `The guest agent (/run/lima-guestagent.sock) does not seem running.
 Make sure that you are using an officially supported image.
 Also see "/var/log/cloud-init-output.log" in the guest.
 A possible workaround is to run "lima-guestagent install-systemd" in the guest.
 `,
-	})
+		})
+	}
 	return req
 }
 
diff --git a/pkg/httpclientutil/httpclientutil.go b/pkg/httpclientutil/httpclientutil.go
index 4e398dbe..90b6c3fe 100644
--- a/pkg/httpclientutil/httpclientutil.go
+++ b/pkg/httpclientutil/httpclientutil.go
@@ -9,30 +9,11 @@ import (
 	"errors"
 	"fmt"
 	"io"
-	"net"
 	"net/http"
-	"os"
 
 	"github.com/lima-vm/lima/pkg/httputil"
 )
 
-// NewHTTPClientWithSocketPath creates a client.
-// socketPath is a path to the UNIX socket, without unix:// prefix.
-func NewHTTPClientWithSocketPath(socketPath string) (*http.Client, error) {
-	if _, err := os.Stat(socketPath); err != nil {
-		return nil, err
-	}
-	hc := &http.Client{
-		Transport: &http.Transport{
-			DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
-				var d net.Dialer
-				return d.DialContext(ctx, "unix", socketPath)
-			},
-		},
-	}
-	return hc, nil
-}
-
 // Get calls HTTP GET and verifies that the status code is 2XX .
 func Get(ctx context.Context, c *http.Client, url string) (*http.Response, error) {
 	req, err := http.NewRequestWithContext(ctx, "GET", url, nil)
diff --git a/pkg/httpclientutil/httpclientutil_others.go b/pkg/httpclientutil/httpclientutil_others.go
new file mode 100644
index 00000000..a90b8ee7
--- /dev/null
+++ b/pkg/httpclientutil/httpclientutil_others.go
@@ -0,0 +1,43 @@
+//go:build !windows
+// +build !windows
+
+package httpclientutil
+
+import (
+	"context"
+	"net"
+	"net/http"
+	"os"
+
+	"github.com/mdlayher/vsock"
+)
+
+// NewHTTPClientWithSocketPath creates a client.
+// socketPath is a path to the UNIX socket, without unix:// prefix.
+func NewHTTPClientWithSocketPath(socketPath string) (*http.Client, error) {
+	if _, err := os.Stat(socketPath); err != nil {
+		return nil, err
+	}
+	hc := &http.Client{
+		Transport: &http.Transport{
+			DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+				var d net.Dialer
+				return d.DialContext(ctx, "unix", socketPath)
+			},
+		},
+	}
+	return hc, nil
+}
+
+// NewHTTPClientWithVSockPort creates a client.
+// port is the port to use for the vsock.
+func NewHTTPClientWithVSockPort(port int) *http.Client {
+	hc := &http.Client{
+		Transport: &http.Transport{
+			Dial: func(_, _ string) (net.Conn, error) {
+				return vsock.Dial(2, uint32(port), nil)
+			},
+		},
+	}
+	return hc
+}
diff --git a/pkg/httpclientutil/httpclientutil_windows.go b/pkg/httpclientutil/httpclientutil_windows.go
new file mode 100644
index 00000000..5c84c1ed
--- /dev/null
+++ b/pkg/httpclientutil/httpclientutil_windows.go
@@ -0,0 +1,65 @@
+//go:build windows
+// +build windows
+
+package httpclientutil
+
+import (
+	"context"
+	"fmt"
+	"net"
+	"net/http"
+	"os"
+
+	winio "github.com/Microsoft/go-winio"
+	"github.com/Microsoft/go-winio/pkg/guid"
+	"github.com/lima-vm/lima/pkg/windows"
+)
+
+// NewHTTPClientWithSocketPath creates a client.
+// socketPath is a path to the UNIX socket, without unix:// prefix.
+func NewHTTPClientWithSocketPath(socketPath string) (*http.Client, error) {
+	// Use Lstat on windows, see: https://github.com/adrg/xdg/pull/14
+	if _, err := os.Lstat(socketPath); err != nil {
+		return nil, err
+	}
+	hc := &http.Client{
+		Transport: &http.Transport{
+			DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+				var d net.Dialer
+				return d.DialContext(ctx, "unix", socketPath)
+			},
+		},
+	}
+	return hc, nil
+}
+
+// NewHTTPClientWithVSockPort creates a client for a vsock port.
+func NewHTTPClientWithVSockPort(instanceName string, port int) (*http.Client, error) {
+	VMIDStr, err := windows.GetInstanceVMID(fmt.Sprintf("lima-%s", instanceName))
+	if err != nil {
+		return nil, err
+	}
+	VMIDGUID, err := guid.FromString(VMIDStr)
+	if err != nil {
+		return nil, err
+	}
+
+	serviceGUID, err := guid.FromString(fmt.Sprintf("%x%s", port, windows.MagicVSOCKSuffix))
+	if err != nil {
+		return nil, err
+	}
+
+	sockAddr := &winio.HvsockAddr{
+		VMID:      VMIDGUID,
+		ServiceID: serviceGUID,
+	}
+
+	hc := &http.Client{
+		Transport: &http.Transport{
+			DialContext: func(ctx context.Context, _, _ string) (net.Conn, error) {
+				return winio.Dial(ctx, sockAddr)
+			},
+		},
+	}
+	return hc, nil
+}
diff --git a/pkg/ioutilx/ioutilx.go b/pkg/ioutilx/ioutilx.go
index fa8a38ee..ceee1c2f 100644
--- a/pkg/ioutilx/ioutilx.go
+++ b/pkg/ioutilx/ioutilx.go
@@ -4,6 +4,12 @@ import (
 	"errors"
 	"fmt"
 	"io"
+	"os/exec"
+	"strings"
+
+	"github.com/sirupsen/logrus"
+	"golang.org/x/text/encoding/unicode"
+	"golang.org/x/text/transform"
 )
 
 // ReadAtMaximum reands n at maximum.
@@ -20,3 +26,32 @@ func ReadAtMaximum(r io.Reader, n int64) ([]byte, error) {
 	}
 	return b, err
 }
+
+// FromUTF16le returns an io.Reader for UTF16le data.
+// Windows uses little endian by default, use unicode.UseBOM policy to retrieve BOM from the text,
+// and unicode.LittleEndian as a fallback
+func FromUTF16le(r io.Reader) io.Reader {
+	o := transform.NewReader(r, unicode.UTF16(unicode.LittleEndian, unicode.UseBOM).NewDecoder())
+	return o
+}
+
+// FromUTF16leToString reads from Unicode 16 LE encoded data from an io.Reader and returns a string.
+func FromUTF16leToString(r io.Reader) (string, error) {
+	out, err := io.ReadAll(FromUTF16le(r))
+	if err != nil {
+		return "", err
+	}
+
+	return string(out), nil
+}
+
+func CanonicalWindowsPath(orig string) string {
+	newPath := orig
+	out, err := exec.Command("cygpath", "-m", orig).CombinedOutput()
+	if err != nil {
+		logrus.WithError(err).Errorf("failed to convert path to mingw, maybe not using Git ssh?")
+	} else {
+		newPath = strings.TrimSpace(string(out))
+	}
+	return newPath
+}
diff --git a/pkg/limayaml/defaults.go b/pkg/limayaml/defaults.go
index 910fe21b..94473925 100644
--- a/pkg/limayaml/defaults.go
+++ b/pkg/limayaml/defaults.go
@@ -828,6 +828,8 @@ func NewVMType(driver string) VMType {
 		return VZ
 	case "qemu":
 		return QEMU
+	case "wsl2":
+		return WSL2
 	default:
 		logrus.Warnf("Unknown driver: %s", driver)
 		return driver
diff --git a/pkg/limayaml/limayaml.go b/pkg/limayaml/limayaml.go
index 990ef0a4..f79e902c 100644
--- a/pkg/limayaml/limayaml.go
+++ b/pkg/limayaml/limayaml.go
@@ -56,9 +56,11 @@ const (
 	REVSSHFS MountType = "reverse-sshfs"
 	NINEP    MountType = "9p"
 	VIRTIOFS MountType = "virtiofs"
+	WSLMount MountType = "wsl2"
 
 	QEMU VMType = "qemu"
 	VZ   VMType = "vz"
+	WSL2 VMType = "wsl2"
 )
 
 type Rosetta struct {
diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go
index 7495216d..b3b8a02f 100644
--- a/pkg/limayaml/validate.go
+++ b/pkg/limayaml/validate.go
@@ -56,12 +56,14 @@ func Validate(y LimaYAML, warn bool) error {
 	switch *y.VMType {
 	case QEMU:
 		// NOP
+	case WSL2:
+		// NOP
 	case VZ:
 		if !IsNativeArch(*y.Arch) {
 			return fmt.Errorf("field `arch` must be %q for VZ; got %q", NewArch(runtime.GOARCH), *y.Arch)
 		}
 	default:
-		return fmt.Errorf("field `vmType` must be %q or %q; got %q", QEMU, VZ, *y.VMType)
+		return fmt.Errorf("field `vmType` must be %q, %q, %q; got %q", QEMU, VZ, WSL2, *y.VMType)
 	}
 
 	if len(y.Images) == 0 {
@@ -159,9 +161,9 @@ func Validate(y LimaYAML, warn bool) error {
 	}
 
 	switch *y.MountType {
-	case REVSSHFS, NINEP, VIRTIOFS:
+	case REVSSHFS, NINEP, VIRTIOFS, WSLMount:
 	default:
-		return fmt.Errorf("field `mountType` must be %q or %q or %q, got %q", REVSSHFS, NINEP, VIRTIOFS, *y.MountType)
+		return fmt.Errorf("field `mountType` must be %q or %q or %q, or %q, got %q", REVSSHFS, NINEP, VIRTIOFS, WSLMount, *y.MountType)
 	}
 
 	if warn && runtime.GOOS != "linux" {
diff --git a/pkg/osutil/osutil_windows.go b/pkg/osutil/osutil_windows.go
index 816731ea..48a109ad 100644
--- a/pkg/osutil/osutil_windows.go
+++ b/pkg/osutil/osutil_windows.go
@@ -3,6 +3,9 @@ package osutil
 import (
 	"fmt"
 	"io/fs"
+	"syscall"
+
+	"golang.org/x/sys/windows"
 )
 
 // UnixPathMax is the value of UNIX_PATH_MAX.
@@ -14,7 +17,7 @@ type Stat struct {
 	Gid uint32 //nolint:revive
 }
 
-func SysStat(fi fs.FileInfo) (Stat, bool) {
+func SysStat(_ fs.FileInfo) (Stat, bool) {
 	return Stat{Uid: 0, Gid: 0}, false
 }
 
@@ -26,10 +29,10 @@ const SigKill = Signal(9)
 
 type Signal int
 
-func SysKill(pid int, sig Signal) error {
-	return fmt.Errorf("unimplemented")
+func SysKill(pid int, _ Signal) error {
+	return windows.GenerateConsoleCtrlEvent(syscall.CTRL_BREAK_EVENT, uint32(pid))
 }
 
-func Ftruncate(fd int, length int64) (err error) {
+func Ftruncate(_ int, _ int64) (err error) {
 	return fmt.Errorf("unimplemented")
 }
diff --git a/pkg/sshutil/sshutil.go b/pkg/sshutil/sshutil.go
index 8cc25b63..33f79f37 100644
--- a/pkg/sshutil/sshutil.go
+++ b/pkg/sshutil/sshutil.go
@@ -16,6 +16,7 @@ import (
 	"sync"
 
 	"github.com/coreos/go-semver/semver"
+	"github.com/lima-vm/lima/pkg/ioutilx"
 	"github.com/lima-vm/lima/pkg/lockutil"
 	"github.com/lima-vm/lima/pkg/osutil"
 	"github.com/lima-vm/lima/pkg/store/dirnames"
@@ -134,7 +135,13 @@ func CommonOpts(useDotSSH bool) ([]string, error) {
 	if err != nil {
 		return nil, err
 	}
-	opts := []string{"IdentityFile=\"" + privateKeyPath + "\""}
+	var opts []string
+	if runtime.GOOS == "windows" {
+		privateKeyPath = ioutilx.CanonicalWindowsPath(privateKeyPath)
+		opts = []string{"IdentityFile=" + privateKeyPath}
+	} else {
+		opts = []string{"IdentityFile=\"" + privateKeyPath + "\""}
+	}
 
 	// Append all private keys corresponding to ~/.ssh/*.pub to keep old instances working
 	// that had been created before lima started using an internal identity.
@@ -193,10 +200,18 @@ func CommonOpts(useDotSSH bool) ([]string, error) {
 		// We prioritize AES algorithms when AES accelerator is available.
 		if sshInfo.aesAccelerated {
 			logrus.Debugf("AES accelerator seems available, prioritizing aes128-gcm@openssh.com and aes256-gcm@openssh.com")
-			opts = append(opts, "Ciphers=\"^aes128-gcm@openssh.com,aes256-gcm@openssh.com\"")
+			if runtime.GOOS == "windows" {
+				opts = append(opts, "Ciphers=^aes128-gcm@openssh.com,aes256-gcm@openssh.com")
+			} else {
+				opts = append(opts, "Ciphers=\"^aes128-gcm@openssh.com,aes256-gcm@openssh.com\"")
+			}
 		} else {
 			logrus.Debugf("AES accelerator does not seem available, prioritizing chacha20-poly1305@openssh.com")
-			opts = append(opts, "Ciphers=\"^chacha20-poly1305@openssh.com\"")
+			if runtime.GOOS == "windows" {
+				opts = append(opts, "Ciphers=^chacha20-poly1305@openssh.com")
+			} else {
+				opts = append(opts, "Ciphers=\"^chacha20-poly1305@openssh.com\"")
+			}
 		}
 	}
 	return opts, nil
@@ -216,11 +231,16 @@ func SSHOpts(instDir string, useDotSSH, forwardAgent bool, forwardX11 bool, forw
 	if err != nil {
 		return nil, err
 	}
+	controlPath := fmt.Sprintf("ControlPath=\"%s\"", controlSock)
+	if runtime.GOOS == "windows" {
+		controlSock = ioutilx.CanonicalWindowsPath(controlSock)
+		controlPath = fmt.Sprintf("ControlPath=%s", controlSock)
+	}
 	opts = append(opts,
 		fmt.Sprintf("User=%s", u.Username), // guest and host have the same username, but we should specify the username explicitly (#85)
 		"ControlMaster=auto",
-		fmt.Sprintf("ControlPath=\"%s\"", controlSock),
-		"ControlPersist=5m",
+		controlPath,
+		"ControlPersist=yes",
 	)
 	if forwardAgent {
 		opts = append(opts, "ForwardAgent=yes")
diff --git a/pkg/start/ha_cmd_opts_others.go b/pkg/start/ha_cmd_opts_others.go
new file mode 100644
index 00000000..60f7f1f1
--- /dev/null
+++ b/pkg/start/ha_cmd_opts_others.go
@@ -0,0 +1,10 @@
+//go:build !windows
+// +build !windows
+
+package start
+
+import (
+	"syscall"
+)
+
+var SysProcAttr = &syscall.SysProcAttr{}
diff --git a/pkg/start/ha_cmd_opts_windows.go b/pkg/start/ha_cmd_opts_windows.go
new file mode 100644
index 00000000..047b8eb3
--- /dev/null
+++ b/pkg/start/ha_cmd_opts_windows.go
@@ -0,0 +1,9 @@
+package start
+
+import (
+	"syscall"
+)
+
+var SysProcAttr = &syscall.SysProcAttr{
+	CreationFlags: syscall.CREATE_NEW_PROCESS_GROUP,
+}
diff --git a/pkg/start/start.go b/pkg/start/start.go
index 1e223ed9..2df50090 100644
--- a/pkg/start/start.go
+++ b/pkg/start/start.go
@@ -160,6 +160,7 @@ func Start(ctx context.Context, inst *store.Instance) error {
 	}
 	args = append(args, inst.Name)
 	haCmd := exec.CommandContext(ctx, self, args...)
+	haCmd.SysProcAttr = SysProcAttr
 
 	haCmd.Stdout = haStdoutW
 	haCmd.Stderr = haStderrW
diff --git a/pkg/store/filenames/filenames.go b/pkg/store/filenames/filenames.go
index d3dd43dc..297ddfaa 100644
--- a/pkg/store/filenames/filenames.go
+++ b/pkg/store/filenames/filenames.go
@@ -28,6 +28,7 @@ const (
 const (
 	LimaYAML           = "lima.yaml"
 	CIDataISO          = "cidata.iso"
+	CIDataISODir       = "cidata"
 	BaseDisk           = "basedisk"
 	DiffDisk           = "diffdisk"
 	Kernel             = "kernel"
diff --git a/pkg/store/instance.go b/pkg/store/instance.go
index af9d3f18..cc3f3387 100644
--- a/pkg/store/instance.go
+++ b/pkg/store/instance.go
@@ -27,10 +27,12 @@ import (
 type Status = string
 
 const (
-	StatusUnknown Status = ""
-	StatusBroken  Status = "Broken"
-	StatusStopped Status = "Stopped"
-	StatusRunning Status = "Running"
+	StatusUnknown       Status = ""
+	StatusUninitialized Status = "Uninitialized"
+	StatusInstalling    Status = "Installing"
+	StatusBroken        Status = "Broken"
+	StatusStopped       Status = "Stopped"
+	StatusRunning       Status = "Running"
 )
 
 type Instance struct {
@@ -52,6 +54,7 @@ type Instance struct {
 	DriverPID       int                `json:"driverPID,omitempty"`
 	Errors          []error            `json:"errors,omitempty"`
 	Config          *limayaml.LimaYAML `json:"config,omitempty"`
+	SSHAddress      string             `json:"sshAddress,omitempty"`
 }
 
 func (inst *Instance) LoadYAML() (*limayaml.LimaYAML, error) {
@@ -89,21 +92,9 @@ func Inspect(instName string) (*Instance, error) {
 	inst.Arch = *y.Arch
 	inst.VMType = *y.VMType
 	inst.CPUType = y.CPUType[*y.Arch]
-
-	inst.CPUs = *y.CPUs
-	memory, err := units.RAMInBytes(*y.Memory)
-	if err == nil {
-		inst.Memory = memory
-	}
-	disk, err := units.RAMInBytes(*y.Disk)
-	if err == nil {
-		inst.Disk = disk
-	}
-	inst.AdditionalDisks = y.AdditionalDisks
-	inst.Networks = y.Networks
+	inst.SSHAddress = "127.0.0.1"
 	inst.SSHLocalPort = *y.SSH.LocalPort // maybe 0
 	inst.SSHConfigFile = filepath.Join(instDir, filenames.SSHConfig)
-
 	inst.HostAgentPID, err = ReadPIDFile(filepath.Join(instDir, filenames.HostAgentPID))
 	if err != nil {
 		inst.Status = StatusBroken
@@ -129,26 +120,27 @@ func Inspect(instName string) (*Instance, error) {
 		}
 	}
 
-	inst.DriverPID, err = ReadPIDFile(filepath.Join(instDir, filenames.PIDFile(*y.VMType)))
-	if err != nil {
-		inst.Status = StatusBroken
-		inst.Errors = append(inst.Errors, err)
+	inst.CPUs = *y.CPUs
+	memory, err := units.RAMInBytes(*y.Memory)
+	if err == nil {
+		inst.Memory = memory
 	}
+	disk, err := units.RAMInBytes(*y.Disk)
+	if err == nil {
+		inst.Disk = disk
+	}
+	inst.AdditionalDisks = y.AdditionalDisks
+	inst.Networks = y.Networks
 
-	if inst.Status == StatusUnknown {
-		if inst.HostAgentPID > 0 && inst.DriverPID > 0 {
-			inst.Status = StatusRunning
-		} else if inst.HostAgentPID == 0 && inst.DriverPID == 0 {
-			inst.Status = StatusStopped
-		} else if inst.HostAgentPID > 0 && inst.DriverPID == 0 {
-			inst.Errors = append(inst.Errors, errors.New("host agent is running but driver is not"))
-			inst.Status = StatusBroken
-		} else {
-			inst.Errors = append(inst.Errors, fmt.Errorf("%s driver is running but host agent is not", inst.VMType))
-			inst.Status = StatusBroken
-		}
+	// 0 out values since not configurable on WSL2
+	if inst.VMType == limayaml.WSL2 {
+		inst.Memory = 0
+		inst.CPUs = 0
+		inst.Disk = 0
 	}
 
+	inspectStatus(instDir, inst, y)
+
 	tmpl, err := template.New("format").Parse(y.Message)
 	if err != nil {
 		inst.Errors = append(inst.Errors, fmt.Errorf("message %q is not a valid template: %w", y.Message, err))
@@ -172,6 +164,29 @@ func Inspect(instName string) (*Instance, error) {
 	return inst, nil
 }
 
+func inspectStatusWithPIDFiles(instDir string, inst *Instance, y *limayaml.LimaYAML) {
+	var err error
+	inst.DriverPID, err = ReadPIDFile(filepath.Join(instDir, filenames.PIDFile(*y.VMType)))
+	if err != nil {
+		inst.Status = StatusBroken
+		inst.Errors = append(inst.Errors, err)
+	}
+
+	if inst.Status == StatusUnknown {
+		if inst.HostAgentPID > 0 && inst.DriverPID > 0 {
+			inst.Status = StatusRunning
+		} else if inst.HostAgentPID == 0 && inst.DriverPID == 0 {
+			inst.Status = StatusStopped
+		} else if inst.HostAgentPID > 0 && inst.DriverPID == 0 {
+			inst.Errors = append(inst.Errors, errors.New("host agent is running but driver is not"))
+			inst.Status = StatusBroken
+		} else {
+			inst.Errors = append(inst.Errors, fmt.Errorf("%s driver is running but host agent is not", inst.VMType))
+			inst.Status = StatusBroken
+		}
+	}
+}
+
 // ReadPIDFile returns 0 if the PID file does not exist or the process has already terminated
 // (in which case the PID file will be removed).
 func ReadPIDFile(path string) (int, error) {
@@ -190,6 +205,10 @@ func ReadPIDFile(path string) (int, error) {
 	if err != nil {
 		return 0, err
 	}
+	// os.FindProcess will only return running processes on Windows, exit early
+	if runtime.GOOS == "windows" {
+		return pid, nil
+	}
 	err = proc.Signal(syscall.Signal(0))
 	if err != nil {
 		if errors.Is(err, os.ErrProcessDone) {
@@ -328,7 +347,7 @@ func PrintInstances(w io.Writer, instances []*Instance, format string, options *
 			fmt.Fprintf(w, "%s\t%s\t%s",
 				instance.Name,
 				instance.Status,
-				fmt.Sprintf("127.0.0.1:%d", instance.SSHLocalPort),
+				fmt.Sprintf("%s:%d", instance.SSHAddress, instance.SSHLocalPort),
 			)
 			if !hideType {
 				fmt.Fprintf(w, "\t%s",
diff --git a/pkg/store/instance_unix.go b/pkg/store/instance_unix.go
new file mode 100644
index 00000000..15611683
--- /dev/null
+++ b/pkg/store/instance_unix.go
@@ -0,0 +1,14 @@
+//go:build !windows
+// +build !windows
+
+package store
+
+import "github.com/lima-vm/lima/pkg/limayaml"
+
+func inspectStatus(instDir string, inst *Instance, y *limayaml.LimaYAML) {
+	inspectStatusWithPIDFiles(instDir, inst, y)
+}
+
+func GetSSHAddress(_ string) (string, error) {
+	return "127.0.0.1", nil
+}
diff --git a/pkg/store/instance_windows.go b/pkg/store/instance_windows.go
new file mode 100644
index 00000000..dae0b4b3
--- /dev/null
+++ b/pkg/store/instance_windows.go
@@ -0,0 +1,101 @@
+package store
+
+import (
+	"fmt"
+	"os/exec"
+	"regexp"
+	"strings"
+
+	"github.com/lima-vm/lima/pkg/executil"
+	"github.com/lima-vm/lima/pkg/limayaml"
+)
+
+func inspectStatus(instDir string, inst *Instance, y *limayaml.LimaYAML) {
+	if inst.VMType == limayaml.WSL2 {
+		status, err := GetWslStatus(inst.Name)
+		if err != nil {
+			inst.Status = StatusBroken
+			inst.Errors = append(inst.Errors, err)
+		} else {
+			inst.Status = status
+		}
+
+		inst.SSHLocalPort = 22
+
+		if inst.Status == StatusRunning {
+			sshAddr, err := getWslSSHAddress(inst.Name)
+			if err == nil {
+				inst.SSHAddress = sshAddr
+			} else {
+				inst.Errors = append(inst.Errors, err)
+			}
+		}
+	} else {
+		inspectStatusWithPIDFiles(instDir, inst, y)
+	}
+}
+
+// GetWslStatus runs `wsl --list --verbose` and parses its output
+//
+// Expected output (whitespace preserved):
+// PS > wsl --list --verbose
+//
+//	NAME      STATE           VERSION
+//
+// * Ubuntu    Stopped         2
+func GetWslStatus(instName string) (string, error) {
+	distroName := "lima-" + instName
+	out, err := executil.RunUTF16leCommand([]string{
+		"wsl.exe",
+		"--list",
+		"--verbose",
+	})
+	if err != nil {
+		return "", fmt.Errorf("failed to run `wsl --list --verbose`, err: %w", err)
+	}
+
+	if len(out) == 0 {
+		return StatusBroken, fmt.Errorf("failed to read instance state for instance %s, try running `wsl --list --verbose` to debug, err: %w", instName, err)
+	}
+
+	var instState string
+	// wsl --list --verbose may have differernt headers depending on localization, just split by line
+	for _, rows := range strings.Split(strings.ReplaceAll(string(out), "\r\n", "\n"), "\n") {
+		cols := regexp.MustCompile(`\s+`).Split(strings.TrimSpace(rows), -1)
+		nameIdx := 0
+		// '*' indicates default instance
+		if cols[0] == "*" {
+			nameIdx = 1
+		}
+		if cols[nameIdx] == distroName {
+			instState = cols[nameIdx+1]
+			break
+		}
+	}
+
+	if instState == "" {
+		return StatusUninitialized, nil
+	}
+
+	return instState, nil
+}
+
+func GetSSHAddress(instName string) (string, error) {
+	return getWslSSHAddress(instName)
+}
+
+// GetWslSSHAddress runs a hostname command to get the IP from inside of a wsl2 VM.
+//
+// Expected output (whitespace preserved, [] for optional):
+// PS > wsl -d <distroName> bash -c hostname -I | cut -d' ' -f1
+// 168.1.1.1 [10.0.0.1]
+func getWslSSHAddress(instName string) (string, error) {
+	distroName := "lima-" + instName
+	cmd := exec.Command("wsl.exe", "-d", distroName, "bash", "-c", `hostname -I | cut -d ' ' -f1`)
+	out, err := cmd.CombinedOutput()
+	if err != nil {
+		return "", fmt.Errorf("failed to get hostname for instance %s, err: %w", instName, err)
+	}
+
+	return strings.TrimSpace(string(out)), nil
+}
diff --git a/pkg/windows/process_windows.go b/pkg/windows/process_windows.go
new file mode 100644
index 00000000..566c0211
--- /dev/null
+++ b/pkg/windows/process_windows.go
@@ -0,0 +1,40 @@
+//go:build windows
+// +build windows
+
+package windows
+
+import (
+	"encoding/json"
+	"fmt"
+	"os/exec"
+)
+
+type CommandLineJSON []struct {
+	CommandLine string
+}
+
+// GetProcessCommandLine returns a slice of string containing all commandlines for a given process name.
+func GetProcessCommandLine(name string) ([]string, error) {
+	out, err := exec.Command(
+		"powershell.exe",
+		"-nologo",
+		"-noprofile",
+		fmt.Sprintf(
+			`Get-CimInstance Win32_Process -Filter "name = '%s'" | Select CommandLine | ConvertTo-Json`,
+			name,
+		),
+	).CombinedOutput()
+	if err != nil {
+		return nil, err
+	}
+
+	var outJSON CommandLineJSON
+	json.Unmarshal([]byte(out), &outJSON)
+
+	var ret []string
+	for _, s := range outJSON {
+		ret = append(ret, s.CommandLine)
+	}
+
+	return ret, nil
+}
diff --git a/pkg/windows/registry_windows.go b/pkg/windows/registry_windows.go
new file mode 100644
index 00000000..a5298bc9
--- /dev/null
+++ b/pkg/windows/registry_windows.go
@@ -0,0 +1,225 @@
+//go:build windows
+// +build windows
+
+package windows
+
+import (
+	"fmt"
+	"math/rand"
+	"sort"
+	"strconv"
+	"strings"
+
+	"golang.org/x/exp/slices"
+	"golang.org/x/sys/windows/registry"
+)
+
+const (
+	guestCommunicationsPrefix = `SOFTWARE\Microsoft\Windows NT\CurrentVersion\Virtualization\GuestCommunicationServices`
+	MagicVSOCKSuffix          = "-facb-11e6-bd58-64006a7986d3"
+	wslDistroInfoPrefix       = `SOFTWARE\Microsoft\Windows\CurrentVersion\Lxss`
+)
+
+// AddVSockRegistryKey makes a vsock server running on the host acceessible in guests.
+func AddVSockRegistryKey(port int) error {
+	rootKey, err := getGuestCommunicationServicesKey()
+	if err != nil {
+		return err
+	}
+	defer rootKey.Close()
+
+	used, err := getUsedPorts(rootKey)
+	if err != nil {
+		return err
+	}
+
+	if slices.Contains(used, port) {
+		return fmt.Errorf("port %q in use", port)
+	}
+
+	vsockKeyPath := fmt.Sprintf(`%x%s`, port, MagicVSOCKSuffix)
+	vSockKey, _, err := registry.CreateKey(
+		rootKey,
+		vsockKeyPath,
+		registry.ALL_ACCESS,
+	)
+	if err != nil {
+		return fmt.Errorf(
+			"failed to create new key (%s%s): %w",
+			guestCommunicationsPrefix,
+			vsockKeyPath,
+			err,
+		)
+	}
+	defer vSockKey.Close()
+
+	return nil
+}
+
+// RemoveVSockRegistryKey removes entries created by AddVSockRegistryKey.
+func RemoveVSockRegistryKey(port int) error {
+	rootKey, err := getGuestCommunicationServicesKey()
+	if err != nil {
+		return err
+	}
+	defer rootKey.Close()
+
+	vsockKeyPath := fmt.Sprintf(`%x%s`, port, MagicVSOCKSuffix)
+	if err := registry.DeleteKey(rootKey, vsockKeyPath); err != nil {
+		return fmt.Errorf(
+			"failed to create new key (%s%s): %w",
+			guestCommunicationsPrefix,
+			vsockKeyPath,
+			err,
+		)
+	}
+
+	return nil
+}
+
+// IsVSockPortFree determines if a VSock port has been registiered already.
+func IsVSockPortFree(port int) (bool, error) {
+	rootKey, err := getGuestCommunicationServicesKey()
+	if err != nil {
+		return false, err
+	}
+	defer rootKey.Close()
+
+	used, err := getUsedPorts(rootKey)
+	if err != nil {
+		return false, err
+	}
+
+	if slices.Contains(used, port) {
+		return false, nil
+	}
+
+	return true, nil
+}
+
+// GetDistroID returns a DistroId GUID corresponding to a Lima instance name.
+func GetDistroID(name string) (string, error) {
+	rootKey, err := registry.OpenKey(
+		registry.CURRENT_USER,
+		wslDistroInfoPrefix,
+		registry.READ,
+	)
+	if err != nil {
+		return "", fmt.Errorf(
+			"failed to open Lxss key (%s): %w",
+			wslDistroInfoPrefix,
+			err,
+		)
+	}
+	defer rootKey.Close()
+
+	keys, err := rootKey.ReadSubKeyNames(-1)
+
+	if err != nil {
+		return "", fmt.Errorf("failed to read subkey names for %s: %w", wslDistroInfoPrefix, err)
+	}
+
+	var out string
+	for _, k := range keys {
+		subKey, err := registry.OpenKey(
+			registry.CURRENT_USER,
+			fmt.Sprintf(`%s\%s`, wslDistroInfoPrefix, k),
+			registry.READ,
+		)
+		if err != nil {
+			return "", fmt.Errorf("failed to read subkey %q for key %q: %w", k, wslDistroInfoPrefix, err)
+		}
+		dn, _, err := subKey.GetStringValue("DistributionName")
+		if err != nil {
+			return "", fmt.Errorf("failed to read 'DistributionName' value for subkey %q of %q: %w", k, wslDistroInfoPrefix, err)
+		}
+		if dn == name {
+			out = k
+			break
+		}
+	}
+
+	if out == "" {
+		return "", fmt.Errorf("failed to find matching DistroID for %q", name)
+	}
+
+	return out, nil
+}
+
+// GetRandomFreeVSockPort gets a list of all registered VSock ports and returns a non-registered port.
+func GetRandomFreeVSockPort(min, max int) (int, error) {
+	rootKey, err := getGuestCommunicationServicesKey()
+	if err != nil {
+		return 0, err
+	}
+	defer rootKey.Close()
+
+	used, err := getUsedPorts(rootKey)
+	if err != nil {
+		return 0, err
+	}
+
+	type pair struct{ v, offset int }
+	tree := make([]pair, 1, len(used)+1)
+	tree[0] = pair{0, min}
+
+	sort.Ints(used)
+	for i, v := range used {
+		if tree[len(tree)-1].v+tree[len(tree)-1].offset == v {
+			tree[len(tree)-1].offset++
+		} else {
+			tree = append(tree, pair{v - min - i, min + i + 1})
+		}
+	}
+
+	v := rand.Intn(max - min + 1 - len(used))
+
+	for len(tree) > 1 {
+		m := len(tree) / 2
+		if v < tree[m].v {
+			tree = tree[:m]
+		} else {
+			tree = tree[m:]
+		}
+	}
+
+	return tree[0].offset + v, nil
+}
+
+func getGuestCommunicationServicesKey() (registry.Key, error) {
+	rootKey, err := registry.OpenKey(
+		registry.LOCAL_MACHINE,
+		guestCommunicationsPrefix,
+		registry.WRITE|registry.READ,
+	)
+	if err != nil {
+		return 0, fmt.Errorf(
+			"failed to open GuestCommunicationServices key (%s): %w",
+			guestCommunicationsPrefix,
+			err,
+		)
+	}
+
+	return rootKey, nil
+}
+
+func getUsedPorts(key registry.Key) ([]int, error) {
+	keys, err := key.ReadSubKeyNames(-1)
+	if err != nil {
+		return nil, fmt.Errorf("failed to read subkey names for %s: %w", guestCommunicationsPrefix, err)
+	}
+
+	out := []int{}
+	for _, k := range keys {
+		split := strings.Split(k, MagicVSOCKSuffix)
+		if len(split) == 2 {
+			i, err := strconv.Atoi(split[0])
+			if err != nil {
+				return nil, fmt.Errorf("failed convert %q to int: %w", split[0], err)
+			}
+			out = append(out, i)
+		}
+	}
+
+	return out, nil
+}
diff --git a/pkg/windows/wsl_util_windows.go b/pkg/windows/wsl_util_windows.go
new file mode 100644
index 00000000..c15b40be
--- /dev/null
+++ b/pkg/windows/wsl_util_windows.go
@@ -0,0 +1,44 @@
+//go:build windows
+// +build windows
+
+package windows
+
+import (
+	"fmt"
+	"regexp"
+	"strings"
+)
+
+// GetInstanceVMID returns the VM ID of a running WSL instance.
+func GetInstanceVMID(instanceName string) (string, error) {
+	distroID, err := GetDistroID(instanceName)
+	if err != nil {
+		return "", err
+	}
+
+	cmdLines, err := GetProcessCommandLine("wslhost.exe")
+	if err != nil {
+		return "", err
+	}
+
+	re := regexp.MustCompile(`--vm-id\s\{(?P<vmID>.{36})\}`)
+	if err != nil {
+		return "", err
+	}
+
+	vmID := ""
+	for _, cmdLine := range cmdLines {
+		if strings.Contains(cmdLine, distroID) {
+			if matches := re.FindStringSubmatch(cmdLine); matches != nil {
+				vmID = matches[re.SubexpIndex("vmID")]
+				break
+			}
+		}
+	}
+
+	if vmID == "" {
+		return "", fmt.Errorf("failed to find VM ID for instance %q", instanceName)
+	}
+
+	return vmID, nil
+}
diff --git a/pkg/wsl2/fs.go b/pkg/wsl2/fs.go
new file mode 100644
index 00000000..7500985e
--- /dev/null
+++ b/pkg/wsl2/fs.go
@@ -0,0 +1,35 @@
+package wsl2
+
+import (
+	"errors"
+	"os"
+	"path/filepath"
+
+	"github.com/lima-vm/lima/pkg/driver"
+	"github.com/lima-vm/lima/pkg/fileutils"
+	"github.com/lima-vm/lima/pkg/store/filenames"
+	"github.com/sirupsen/logrus"
+)
+
+// EnsureFs downloads the root fs.
+func EnsureFs(driver *driver.BaseDriver) error {
+	baseDisk := filepath.Join(driver.Instance.Dir, filenames.BaseDisk)
+	if _, err := os.Stat(baseDisk); errors.Is(err, os.ErrNotExist) {
+		var ensuredBaseDisk bool
+		errs := make([]error, len(driver.Yaml.Images))
+		for i, f := range driver.Yaml.Images {
+			if _, err := fileutils.DownloadFile(baseDisk, f.File, true, "the image", *driver.Yaml.Arch); err != nil {
+				errs[i] = err
+				continue
+			}
+			ensuredBaseDisk = true
+			break
+		}
+		if !ensuredBaseDisk {
+			return fileutils.Errors(errs)
+		}
+	}
+	logrus.Info("Download succeeded")
+
+	return nil
+}
diff --git a/pkg/wsl2/lima-init.TEMPLATE b/pkg/wsl2/lima-init.TEMPLATE
new file mode 100644
index 00000000..7c93f139
--- /dev/null
+++ b/pkg/wsl2/lima-init.TEMPLATE
@@ -0,0 +1,6 @@
+set -eu; \
+export LOG_FILE=/var/log/lima-init.log; \
+exec > >(tee \$LOG_FILE) 2>&1; \
+LIMA_CIDATA_MNT=$(/usr/bin/wslpath '{{.CIDataPath}}'); \
+export LIMA_CIDATA_MNT; \
+exec \$LIMA_CIDATA_MNT/boot.sh;
diff --git a/pkg/wsl2/vm_windows.go b/pkg/wsl2/vm_windows.go
new file mode 100755
index 00000000..192047d6
--- /dev/null
+++ b/pkg/wsl2/vm_windows.go
@@ -0,0 +1,142 @@
+//go:build windows
+// +build windows
+
+package wsl2
+
+import (
+	"context"
+	_ "embed"
+	"fmt"
+	"os/exec"
+	"path/filepath"
+	"strings"
+
+	"github.com/lima-vm/lima/pkg/executil"
+	"github.com/lima-vm/lima/pkg/store"
+	"github.com/lima-vm/lima/pkg/store/filenames"
+	"github.com/lima-vm/lima/pkg/textutil"
+	"github.com/sirupsen/logrus"
+)
+
+// startVM calls WSL to start a VM.
+func startVM(ctx context.Context, distroName string) error {
+	_, err := executil.RunUTF16leCommand([]string{
+		"wsl.exe",
+		"--distribution",
+		distroName,
+	}, executil.WithContext(&ctx))
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// initVM calls WSL to import a new VM specifically for Lima.
+func initVM(ctx context.Context, instanceDir, distroName string) error {
+	baseDisk := filepath.Join(instanceDir, filenames.BaseDisk)
+	logrus.Infof("Importing distro from %q to %q", baseDisk, instanceDir)
+	_, err := executil.RunUTF16leCommand([]string{
+		"wsl.exe",
+		"--import",
+		distroName,
+		instanceDir,
+		baseDisk,
+	}, executil.WithContext(&ctx))
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+// stopVM calls WSL to stop a running VM.
+func stopVM(ctx context.Context, distroName string) error {
+	_, err := executil.RunUTF16leCommand([]string{
+		"wsl.exe",
+		"--terminate",
+		distroName,
+	}, executil.WithContext(&ctx))
+	if err != nil {
+		return err
+	}
+	return nil
+}
+
+//go:embed lima-init.TEMPLATE
+var limaBoot string
+
+// provisionVM starts Lima's boot process inside an already imported VM.
+func provisionVM(ctx context.Context, instanceDir, instanceName, distroName string, errCh *chan error) error {
+	ciDataPath := filepath.Join(instanceDir, filenames.CIDataISODir)
+	m := map[string]string{
+		"CIDataPath": ciDataPath,
+	}
+	out, err := textutil.ExecuteTemplate(limaBoot, m)
+	if err != nil {
+		return fmt.Errorf("failed to construct wsl boot.sh script: %w", err)
+	}
+	outString := strings.Replace(string(out), `\r\n`, `\n`, -1)
+
+	go func() {
+		cmd := exec.CommandContext(
+			ctx,
+			"wsl.exe",
+			"-d",
+			distroName,
+			"bash",
+			"-c",
+			outString,
+		)
+		if _, err := cmd.CombinedOutput(); err != nil {
+			*errCh <- fmt.Errorf(
+				"error running wslCommand that executes boot.sh: %w, "+
+					"check /var/log/lima-init.log for more details", err)
+		}
+
+		for {
+			select {
+			case <-ctx.Done():
+				logrus.Info("Context closed, stopping vm")
+				if status, err := store.GetWslStatus(instanceName); err == nil &&
+					status == store.StatusRunning {
+					stopVM(ctx, distroName)
+				}
+			}
+		}
+	}()
+
+	return err
+}
+
+// keepAlive runs a background process which in order to keep the WSL2 VM running in the background after launch.
+func keepAlive(ctx context.Context, distroName string, errCh *chan error) {
+	keepAliveCmd := exec.CommandContext(
+		ctx,
+		"wsl.exe",
+		"-d",
+		distroName,
+		"bash",
+		"-c",
+		"nohup sleep 2147483647d >/dev/null 2>&1",
+	)
+
+	go func() {
+		if err := keepAliveCmd.Run(); err != nil {
+			*errCh <- fmt.Errorf(
+				"error running wsl keepAlive command: %w", err)
+		}
+	}()
+}
+
+// unregisterVM calls WSL to unregister a VM.
+func unregisterVM(ctx context.Context, distroName string) error {
+	logrus.Info("Unregistering WSL2 VM")
+	_, err := executil.RunUTF16leCommand([]string{
+		"wsl.exe",
+		"--unregister",
+		distroName,
+	}, executil.WithContext(&ctx))
+	if err != nil {
+		return err
+	}
+	return nil
+}
diff --git a/pkg/wsl2/wsl_driver_others.go b/pkg/wsl2/wsl_driver_others.go
new file mode 100644
index 00000000..3d264714
--- /dev/null
+++ b/pkg/wsl2/wsl_driver_others.go
@@ -0,0 +1,41 @@
+//go:build !windows || no_wsl
+// +build !windows no_wsl
+
+package wsl2
+
+import (
+	"context"
+	"errors"
+
+	"github.com/lima-vm/lima/pkg/driver"
+)
+
+var ErrUnsupported = errors.New("vm driver 'wsl2' requires Windows 10 build 19041 or later (Hint: try recompiling Lima if you are seeing this error on Windows 10+)")
+
+const Enabled = false
+
+type LimaWslDriver struct {
+	*driver.BaseDriver
+}
+
+func New(driver *driver.BaseDriver) *LimaWslDriver {
+	return &LimaWslDriver{
+		BaseDriver: driver,
+	}
+}
+
+func (l *LimaWslDriver) Validate() error {
+	return ErrUnsupported
+}
+
+func (l *LimaWslDriver) CreateDisk() error {
+	return ErrUnsupported
+}
+
+func (l *LimaWslDriver) Start(_ context.Context) (chan error, error) {
+	return nil, ErrUnsupported
+}
+
+func (l *LimaWslDriver) Stop(_ context.Context) error {
+	return ErrUnsupported
+}
diff --git a/pkg/wsl2/wsl_driver_windows.go b/pkg/wsl2/wsl_driver_windows.go
new file mode 100644
index 00000000..8983158b
--- /dev/null
+++ b/pkg/wsl2/wsl_driver_windows.go
@@ -0,0 +1,170 @@
+//go:build windows
+// +build windows
+
+package wsl2
+
+import (
+	"context"
+	"fmt"
+	"regexp"
+
+	"github.com/lima-vm/lima/pkg/driver"
+	"github.com/lima-vm/lima/pkg/limayaml"
+	"github.com/lima-vm/lima/pkg/reflectutil"
+	"github.com/lima-vm/lima/pkg/store"
+	"github.com/sirupsen/logrus"
+)
+
+const Enabled = true
+
+type LimaWslDriver struct {
+	*driver.BaseDriver
+}
+
+func New(driver *driver.BaseDriver) *LimaWslDriver {
+	return &LimaWslDriver{
+		BaseDriver: driver,
+	}
+}
+
+func (l *LimaWslDriver) Validate() error {
+	if *l.Yaml.MountType != limayaml.WSLMount {
+		return fmt.Errorf("field `mountType` must be %q for WSL2 driver, got %q", limayaml.WSLMount, *l.Yaml.MountType)
+	}
+	// TODO: revise this list for WSL2
+	if unknown := reflectutil.UnknownNonEmptyFields(l.Yaml, "VMType",
+		"Arch",
+		"Images",
+		"CPUType",
+		"Disk",
+		"Mounts",
+		"MountType",
+		"SSH",
+		"Provision",
+		"Containerd",
+		"Probes",
+		"PortForwards",
+		"Message",
+		"Env",
+		"DNS",
+		"HostResolver",
+		"PropagateProxyEnv",
+	); len(unknown) > 0 {
+		logrus.Warnf("Ignoring: vmType %s: %+v", *l.Yaml.VMType, unknown)
+	}
+
+	if !limayaml.IsNativeArch(*l.Yaml.Arch) {
+		return fmt.Errorf("unsupported arch: %q", *l.Yaml.Arch)
+	}
+
+	for k, v := range l.Yaml.CPUType {
+		if v != "" {
+			logrus.Warnf("Ignoring: vmType %s: cpuType[%q]: %q", *l.Yaml.VMType, k, v)
+		}
+	}
+
+	re, err := regexp.Compile(`.*tar\.*`)
+	if err != nil {
+		return fmt.Errorf("failed to compile file check regex: %w", err)
+	}
+	for i, image := range l.Yaml.Images {
+		if unknown := reflectutil.UnknownNonEmptyFields(image, "File"); len(unknown) > 0 {
+			logrus.Warnf("Ignoring: vmType %s: images[%d]: %+v", *l.Yaml.VMType, i, unknown)
+		}
+		// TODO: real filetype checks
+		match := re.MatchString(image.Location)
+		if image.Arch == *l.Yaml.Arch && !match {
+			return fmt.Errorf("unsupported image type for vmType: %s, tarball root file system required: %q", *l.Yaml.VMType, image.Location)
+		}
+	}
+
+	for i, mount := range l.Yaml.Mounts {
+		if unknown := reflectutil.UnknownNonEmptyFields(mount); len(unknown) > 0 {
+			logrus.Warnf("Ignoring: vmType %s: mounts[%d]: %+v", *l.Yaml.VMType, i, unknown)
+		}
+	}
+
+	for i, network := range l.Yaml.Networks {
+		if unknown := reflectutil.UnknownNonEmptyFields(network); len(unknown) > 0 {
+			logrus.Warnf("Ignoring: vmType %s: networks[%d]: %+v", *l.Yaml.VMType, i, unknown)
+		}
+	}
+
+	audioDevice := *l.Yaml.Audio.Device
+	if audioDevice != "" {
+		logrus.Warnf("Ignoring: vmType %s: `audio.device`: %+v", *l.Yaml.VMType, audioDevice)
+	}
+
+	return nil
+}
+
+func (l *LimaWslDriver) Start(ctx context.Context) (chan error, error) {
+	logrus.Infof("Starting WSL VM")
+	status, err := store.GetWslStatus(l.Instance.Name)
+	if err != nil {
+		return nil, err
+	}
+
+	distroName := "lima-" + l.Instance.Name
+
+	if status == store.StatusUninitialized {
+		if err := EnsureFs(l.BaseDriver); err != nil {
+			return nil, err
+		}
+		if err := initVM(ctx, l.BaseDriver.Instance.Dir, distroName); err != nil {
+			return nil, err
+		}
+	}
+
+	errCh := make(chan error)
+
+	if err := startVM(ctx, distroName); err != nil {
+		return nil, err
+	}
+
+	if err := provisionVM(
+		ctx,
+		l.BaseDriver.Instance.Dir,
+		l.BaseDriver.Instance.Name,
+		distroName,
+		&errCh,
+	); err != nil {
+		return nil, err
+	}
+
+	keepAlive(ctx, distroName, &errCh)
+
+	return errCh, err
+}
+
+// Requires WSLg, which requires specific version of WSL2 to be installed.
+// TODO: Add check and add support for WSLg (instead of VNC) to hostagent.
+func (l *LimaWslDriver) CanRunGUI() bool {
+	// return *l.Yaml.Video.Display == "wsl"
+	return false
+}
+
+func (l *LimaWslDriver) RunGUI() error {
+	return fmt.Errorf("RunGUI is not support for the given driver '%s' and diplay '%s'", "wsl", *l.Yaml.Video.Display)
+}
+
+func (l *LimaWslDriver) Stop(ctx context.Context) error {
+	logrus.Info("Shutting down WSL2 VM")
+	distroName := "lima-" + l.Instance.Name
+	return stopVM(ctx, distroName)
+}
+
+func (l *LimaWslDriver) Unregister(ctx context.Context) error {
+	distroName := "lima-" + l.Instance.Name
+	status, err := store.GetWslStatus(l.Instance.Name)
+	if err != nil {
+		return err
+	}
+	switch status {
+	case store.StatusRunning, store.StatusStopped, store.StatusBroken, store.StatusInstalling:
+		return unregisterVM(ctx, distroName)
+	}
+
+	logrus.Info("VM not registered, skipping unregistration")
+	return nil
+}


Test patch:
diff --git a/pkg/store/instance_test.go b/pkg/store/instance_test.go
index 837ce138..2b2088b7 100644
--- a/pkg/store/instance_test.go
+++ b/pkg/store/instance_test.go
@@ -19,11 +19,12 @@ var goarch = limayaml.NewArch(runtime.GOARCH)
 var space = strings.Repeat(" ", len(goarch)-4)
 
 var instance = Instance{
-	Name:   "foo",
-	Status: StatusStopped,
-	VMType: vmtype,
-	Arch:   goarch,
-	Dir:    "dir",
+	Name:       "foo",
+	Status:     StatusStopped,
+	VMType:     vmtype,
+	Arch:       goarch,
+	Dir:        "dir",
+	SSHAddress: "127.0.0.1",
 }
 
 var table = "NAME    STATUS     SSH            CPUS    MEMORY    DISK    DIR\n" +