diff --git a/cmd/limactl/copy.go b/cmd/limactl/copy.go index ae9a295d2bf..d1b55d1b9d3 100644 --- a/cmd/limactl/copy.go +++ b/cmd/limactl/copy.go @@ -61,6 +61,7 @@ func copyAction(cmd *cobra.Command, args []string) error { scpFlags = append(scpFlags, "-r") } legacySSH := sshutil.DetectOpenSSHVersion().LessThan(*semver.New("8.0.0")) + localhostOnly := true for _, arg := range args { path := strings.Split(arg, ":") switch len(path) { @@ -80,9 +81,12 @@ func copyAction(cmd *cobra.Command, args []string) error { } if legacySSH { scpFlags = append(scpFlags, "-P", fmt.Sprintf("%d", inst.SSHLocalPort)) - scpArgs = append(scpArgs, fmt.Sprintf("%s@127.0.0.1:%s", *inst.Config.User.Name, path[1])) + scpArgs = append(scpArgs, fmt.Sprintf("%s@%s:%s", *inst.Config.User.Name, inst.SSHAddress, path[1])) } else { - scpArgs = append(scpArgs, fmt.Sprintf("scp://%s@127.0.0.1:%d/%s", *inst.Config.User.Name, inst.SSHLocalPort, path[1])) + scpArgs = append(scpArgs, fmt.Sprintf("scp://%s@%s:%d/%s", *inst.Config.User.Name, inst.SSHAddress, inst.SSHLocalPort, path[1])) + } + if !sshutil.IsLocalhost(inst.SSHAddress) { + localhostOnly = false } instances[instName] = inst default: @@ -101,14 +105,14 @@ func copyAction(cmd *cobra.Command, args []string) error { // arguments such as ControlPath. This is preferred as we can multiplex // sessions without re-authenticating (MaxSessions permitting). for _, inst := range instances { - sshOpts, err = sshutil.SSHOpts(inst.Dir, *inst.Config.User.Name, false, false, false, false) + sshOpts, err = sshutil.SSHOpts(inst.Dir, *inst.Config.User.Name, false, inst.SSHAddress, false, false, false) if err != nil { return err } } } else { // Copying among multiple hosts; we can't pass in host-specific options. - sshOpts, err = sshutil.CommonOpts(false) + sshOpts, err = sshutil.CommonOpts(false, localhostOnly) if err != nil { return err } diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go index 65d8fad7b29..19da8fa8992 100644 --- a/cmd/limactl/shell.go +++ b/cmd/limactl/shell.go @@ -169,6 +169,7 @@ func shellAction(cmd *cobra.Command, args []string) error { inst.Dir, *inst.Config.User.Name, *inst.Config.SSH.LoadDotSSHPubKeys, + *inst.Config.SSH.Address, *inst.Config.SSH.ForwardAgent, *inst.Config.SSH.ForwardX11, *inst.Config.SSH.ForwardX11Trusted) diff --git a/cmd/limactl/show-ssh.go b/cmd/limactl/show-ssh.go index 04cbf59088a..f68ce54f21e 100644 --- a/cmd/limactl/show-ssh.go +++ b/cmd/limactl/show-ssh.go @@ -92,13 +92,14 @@ func showSSHAction(cmd *cobra.Command, args []string) error { inst.Dir, *inst.Config.User.Name, *inst.Config.SSH.LoadDotSSHPubKeys, + *inst.Config.SSH.Address, *inst.Config.SSH.ForwardAgent, *inst.Config.SSH.ForwardX11, *inst.Config.SSH.ForwardX11Trusted) if err != nil { return err } - opts = append(opts, "Hostname=127.0.0.1") + opts = append(opts, fmt.Sprintf("Hostname=%s", inst.SSHAddress)) opts = append(opts, fmt.Sprintf("Port=%d", inst.SSHLocalPort)) return sshutil.Format(w, instName, format, opts) } diff --git a/cmd/limactl/tunnel.go b/cmd/limactl/tunnel.go index 446089c65e5..290d6908bcb 100644 --- a/cmd/limactl/tunnel.go +++ b/cmd/limactl/tunnel.go @@ -109,6 +109,7 @@ func tunnelAction(cmd *cobra.Command, args []string) error { inst.Dir, *inst.Config.User.Name, *inst.Config.SSH.LoadDotSSHPubKeys, + *inst.Config.SSH.Address, *inst.Config.SSH.ForwardAgent, *inst.Config.SSH.ForwardX11, *inst.Config.SSH.ForwardX11Trusted) diff --git a/examples/experimental/ext.yaml b/examples/experimental/ext.yaml new file mode 100644 index 00000000000..bea4f51d651 --- /dev/null +++ b/examples/experimental/ext.yaml @@ -0,0 +1,14 @@ +vmType: ext + +arch: "aarch64" +cpus: 4 +memory: 512MiB +disk: 32GiB + +mounts: +- location: "~" +- location: "/tmp/lima" + writable: true + +ssh: + address: raspberrypi.local diff --git a/pkg/driverutil/driverutil.go b/pkg/driverutil/driverutil.go index a0d12787332..eeb814edf6e 100644 --- a/pkg/driverutil/driverutil.go +++ b/pkg/driverutil/driverutil.go @@ -1,6 +1,7 @@ package driverutil import ( + "github.com/lima-vm/lima/pkg/ext" "github.com/lima-vm/lima/pkg/limayaml" "github.com/lima-vm/lima/pkg/vz" "github.com/lima-vm/lima/pkg/wsl2" @@ -15,5 +16,8 @@ func Drivers() []string { if wsl2.Enabled { drivers = append(drivers, limayaml.WSL2) } + if ext.Enabled { + drivers = append(drivers, limayaml.EXT) + } return drivers } diff --git a/pkg/driverutil/instance.go b/pkg/driverutil/instance.go index f93b3854115..5cf083ae4f4 100644 --- a/pkg/driverutil/instance.go +++ b/pkg/driverutil/instance.go @@ -2,6 +2,7 @@ package driverutil import ( "github.com/lima-vm/lima/pkg/driver" + "github.com/lima-vm/lima/pkg/ext" "github.com/lima-vm/lima/pkg/limayaml" "github.com/lima-vm/lima/pkg/qemu" "github.com/lima-vm/lima/pkg/vz" @@ -16,5 +17,8 @@ func CreateTargetDriverInstance(base *driver.BaseDriver) driver.Driver { if *limaDriver == limayaml.WSL2 { return wsl2.New(base) } + if *limaDriver == limayaml.EXT { + return ext.New(base) + } return qemu.New(base) } diff --git a/pkg/ext/ext_driver.go b/pkg/ext/ext_driver.go new file mode 100644 index 00000000000..a94148ee7ae --- /dev/null +++ b/pkg/ext/ext_driver.go @@ -0,0 +1,17 @@ +package ext + +import ( + "github.com/lima-vm/lima/pkg/driver" +) + +const Enabled = true + +type LimaExtDriver struct { + *driver.BaseDriver +} + +func New(driver *driver.BaseDriver) *LimaExtDriver { + return &LimaExtDriver{ + BaseDriver: driver, + } +} diff --git a/pkg/hostagent/events/events.go b/pkg/hostagent/events/events.go index f7d6b4a1458..da835da05d9 100644 --- a/pkg/hostagent/events/events.go +++ b/pkg/hostagent/events/events.go @@ -13,7 +13,8 @@ type Status struct { Errors []string `json:"errors,omitempty"` - SSHLocalPort int `json:"sshLocalPort,omitempty"` + SSHIPAddress string `json:"sshIPAddress,omitempty"` + SSHLocalPort int `json:"sshLocalPort,omitempty"` } type Event struct { diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index 8d7a8d6830e..a1388f443d5 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -100,7 +100,7 @@ func New(instName string, stdout io.Writer, signalCh chan os.Signal, opts ...Opt } // inst.Config is loaded with FillDefault() already, so no need to care about nil pointers. - sshLocalPort, err := determineSSHLocalPort(*inst.Config.SSH.LocalPort, instName) + sshLocalPort, err := determineSSHLocalPort(*inst.Config.SSH.Address, *inst.Config.SSH.LocalPort, instName) if err != nil { return nil, err } @@ -138,14 +138,17 @@ func New(instName string, stdout io.Writer, signalCh chan os.Signal, opts ...Opt if err := cidata.GenerateCloudConfig(inst.Dir, instName, inst.Config); err != nil { return nil, err } - if err := cidata.GenerateISO9660(inst.Dir, instName, inst.Config, udpDNSLocalPort, tcpDNSLocalPort, o.nerdctlArchive, vSockPort, virtioPort); err != nil { - return nil, err + if *inst.Config.VMType != limayaml.EXT { + if err := cidata.GenerateISO9660(inst.Dir, instName, inst.Config, udpDNSLocalPort, tcpDNSLocalPort, o.nerdctlArchive, vSockPort, virtioPort); err != nil { + return nil, err + } } sshOpts, err := sshutil.SSHOpts( inst.Dir, *inst.Config.User.Name, *inst.Config.SSH.LoadDotSSHPubKeys, + *inst.Config.SSH.Address, *inst.Config.SSH.ForwardAgent, *inst.Config.SSH.ForwardX11, *inst.Config.SSH.ForwardX11Trusted) @@ -208,7 +211,7 @@ func New(instName string, stdout io.Writer, signalCh chan os.Signal, opts ...Opt instName: instName, instSSHAddress: inst.SSHAddress, sshConfig: sshConfig, - portForwarder: newPortForwarder(sshConfig, sshLocalPort, rules, ignoreTCP, inst.VMType), + portForwarder: newPortForwarder(sshConfig, inst.SSHAddress, sshLocalPort, rules, ignoreTCP, inst.VMType), grpcPortForwarder: portfwd.NewPortForwarder(rules, ignoreTCP, ignoreUDP), driver: limaDriver, signalCh: signalCh, @@ -242,13 +245,16 @@ func writeSSHConfigFile(instName, instDir, instSSHAddress string, sshLocalPort i return os.WriteFile(fileName, b.Bytes(), 0o600) } -func determineSSHLocalPort(confLocalPort int, instName string) (int, error) { +func determineSSHLocalPort(confSSHAddress string, confLocalPort int, instName string) (int, error) { if confLocalPort > 0 { return confLocalPort, nil } if confLocalPort < 0 { return 0, fmt.Errorf("invalid ssh local port %d", confLocalPort) } + if confLocalPort == 0 && confSSHAddress != "127.0.0.1" { + return 22, nil + } if instName == "default" { // use hard-coded value for "default" instance, for backward compatibility return 60022, nil @@ -378,8 +384,21 @@ func (a *HostAgent) Run(ctx context.Context) error { return a.startRoutinesAndWait(ctx, errCh) } +func getIP(address string) string { + ip := net.ParseIP(address) + if ip != nil { + return address + } + ips, err := net.LookupIP(address) + if err == nil && len(ips) > 0 { + return ips[0].String() + } + return address +} + func (a *HostAgent) startRoutinesAndWait(ctx context.Context, errCh <-chan error) error { stBase := events.Status{ + SSHIPAddress: getIP(a.instSSHAddress), SSHLocalPort: a.sshLocalPort, } stBooting := stBase @@ -483,6 +502,11 @@ sudo chown -R "${USER}" /run/host-services` return errors.Join(unlockErrs...) }) } + if *a.instConfig.VMType == limayaml.EXT { + if err := a.runProvisionScripts(); err != nil { + return err + } + } if !*a.instConfig.Plain { go a.watchGuestAgentEvents(ctx) } @@ -543,7 +567,7 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) { for _, rule := range a.instConfig.PortForwards { if rule.GuestSocket != "" { local := hostAddress(rule, &guestagentapi.IPPort{}) - _ = forwardSSH(ctx, a.sshConfig, a.sshLocalPort, local, rule.GuestSocket, verbForward, rule.Reverse) + _ = forwardSSH(ctx, a.sshConfig, a.instSSHAddress, a.sshLocalPort, local, rule.GuestSocket, verbForward, rule.Reverse) } } } @@ -558,13 +582,13 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) { if rule.GuestSocket != "" { local := hostAddress(rule, &guestagentapi.IPPort{}) // using ctx.Background() because ctx has already been cancelled - if err := forwardSSH(context.Background(), a.sshConfig, a.sshLocalPort, local, rule.GuestSocket, verbCancel, rule.Reverse); err != nil { + if err := forwardSSH(context.Background(), a.sshConfig, a.instSSHAddress, a.sshLocalPort, local, rule.GuestSocket, verbCancel, rule.Reverse); err != nil { errs = append(errs, err) } } } if a.driver.ForwardGuestAgent() { - if err := forwardSSH(context.Background(), a.sshConfig, a.sshLocalPort, localUnix, remoteUnix, verbCancel, false); err != nil { + if err := forwardSSH(context.Background(), a.sshConfig, a.instSSHAddress, a.sshLocalPort, localUnix, remoteUnix, verbCancel, false); err != nil { errs = append(errs, err) } } @@ -575,7 +599,7 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) { if a.instConfig.MountInotify != nil && *a.instConfig.MountInotify { if a.client == nil || !isGuestAgentSocketAccessible(ctx, a.client) { if a.driver.ForwardGuestAgent() { - _ = forwardSSH(ctx, a.sshConfig, a.sshLocalPort, localUnix, remoteUnix, verbForward, false) + _ = forwardSSH(ctx, a.sshConfig, a.instSSHAddress, a.sshLocalPort, localUnix, remoteUnix, verbForward, false) } } err := a.startInotify(ctx) @@ -588,7 +612,7 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) { for { if a.client == nil || !isGuestAgentSocketAccessible(ctx, a.client) { if a.driver.ForwardGuestAgent() { - _ = forwardSSH(ctx, a.sshConfig, a.sshLocalPort, localUnix, remoteUnix, verbForward, false) + _ = forwardSSH(ctx, a.sshConfig, a.instSSHAddress, a.sshLocalPort, localUnix, remoteUnix, verbForward, false) } } client, err := a.getOrCreateClient(ctx) @@ -676,11 +700,11 @@ const ( verbCancel = "cancel" ) -func executeSSH(ctx context.Context, sshConfig *ssh.SSHConfig, port int, command ...string) error { +func executeSSH(ctx context.Context, sshConfig *ssh.SSHConfig, addr string, port int, command ...string) error { args := sshConfig.Args() args = append(args, "-p", strconv.Itoa(port), - "127.0.0.1", + addr, "--", ) args = append(args, command...) @@ -691,7 +715,7 @@ func executeSSH(ctx context.Context, sshConfig *ssh.SSHConfig, port int, command return nil } -func forwardSSH(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, remote, verb string, reverse bool) error { +func forwardSSH(ctx context.Context, sshConfig *ssh.SSHConfig, addr string, port int, local, remote, verb string, reverse bool) error { args := sshConfig.Args() args = append(args, "-T", @@ -710,7 +734,7 @@ func forwardSSH(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, "-N", "-f", "-p", strconv.Itoa(port), - "127.0.0.1", + addr, "--", ) if strings.HasPrefix(local, "/") { @@ -718,7 +742,7 @@ func forwardSSH(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, case verbForward: if reverse { logrus.Infof("Forwarding %q (host) to %q (guest)", local, remote) - if err := executeSSH(ctx, sshConfig, port, "rm", "-f", remote); err != nil { + if err := executeSSH(ctx, sshConfig, addr, port, "rm", "-f", remote); err != nil { logrus.WithError(err).Warnf("Failed to clean up %q (guest) before setting up forwarding", remote) } } else { @@ -733,7 +757,7 @@ func forwardSSH(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, case verbCancel: if reverse { logrus.Infof("Stopping forwarding %q (host) to %q (guest)", local, remote) - if err := executeSSH(ctx, sshConfig, port, "rm", "-f", remote); err != nil { + if err := executeSSH(ctx, sshConfig, addr, port, "rm", "-f", remote); err != nil { logrus.WithError(err).Warnf("Failed to clean up %q (guest) after stopping forwarding", remote) } } else { @@ -753,7 +777,7 @@ func forwardSSH(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, if verb == verbForward && strings.HasPrefix(local, "/") { if reverse { logrus.WithError(err).Warnf("Failed to set up forward from %q (host) to %q (guest)", local, remote) - if err := executeSSH(ctx, sshConfig, port, "rm", "-f", remote); err != nil { + if err := executeSSH(ctx, sshConfig, addr, port, "rm", "-f", remote); err != nil { logrus.WithError(err).Warnf("Failed to clean up %q (guest) after forwarding failed", remote) } } else { diff --git a/pkg/hostagent/mount.go b/pkg/hostagent/mount.go index 646dc5d4eb3..abc3f911acf 100644 --- a/pkg/hostagent/mount.go +++ b/pkg/hostagent/mount.go @@ -58,7 +58,7 @@ func (a *HostAgent) setupMount(m limayaml.Mount) (*mount, error) { Driver: *m.SSHFS.SFTPDriver, SSHConfig: a.sshConfig, LocalPath: location, - Host: "127.0.0.1", + Host: a.instSSHAddress, Port: a.sshLocalPort, RemotePath: mountPoint, Readonly: !(*m.Writable), diff --git a/pkg/hostagent/port.go b/pkg/hostagent/port.go index bfb86d1ce3c..0e82fb19199 100644 --- a/pkg/hostagent/port.go +++ b/pkg/hostagent/port.go @@ -12,6 +12,7 @@ import ( type portForwarder struct { sshConfig *ssh.SSHConfig + sshHostAddr string sshHostPort int rules []limayaml.PortForward ignore bool @@ -22,9 +23,10 @@ const sshGuestPort = 22 var IPv4loopback1 = limayaml.IPv4loopback1 -func newPortForwarder(sshConfig *ssh.SSHConfig, sshHostPort int, rules []limayaml.PortForward, ignore bool, vmType limayaml.VMType) *portForwarder { +func newPortForwarder(sshConfig *ssh.SSHConfig, sshHostAddr string, sshHostPort int, rules []limayaml.PortForward, ignore bool, vmType limayaml.VMType) *portForwarder { return &portForwarder{ sshConfig: sshConfig, + sshHostAddr: sshHostAddr, sshHostPort: sshHostPort, rules: rules, ignore: ignore, @@ -91,7 +93,7 @@ func (pf *portForwarder) OnEvent(ctx context.Context, ev *api.Event) { continue } logrus.Infof("Stopping forwarding TCP from %s to %s", remote, local) - if err := forwardTCP(ctx, pf.sshConfig, pf.sshHostPort, local, remote, verbCancel); err != nil { + if err := forwardTCP(ctx, pf.sshConfig, pf.sshHostAddr, pf.sshHostPort, local, remote, verbCancel); err != nil { logrus.WithError(err).Warnf("failed to stop forwarding tcp port %d", f.Port) } } @@ -107,7 +109,7 @@ func (pf *portForwarder) OnEvent(ctx context.Context, ev *api.Event) { continue } logrus.Infof("Forwarding TCP from %s to %s", remote, local) - if err := forwardTCP(ctx, pf.sshConfig, pf.sshHostPort, local, remote, verbForward); err != nil { + if err := forwardTCP(ctx, pf.sshConfig, pf.sshHostAddr, pf.sshHostPort, local, remote, verbForward); err != nil { logrus.WithError(err).Warnf("failed to set up forwarding tcp port %d (negligible if already forwarded)", f.Port) } } diff --git a/pkg/hostagent/port_darwin.go b/pkg/hostagent/port_darwin.go index 615a2bd03f9..c6ca72a41e5 100644 --- a/pkg/hostagent/port_darwin.go +++ b/pkg/hostagent/port_darwin.go @@ -15,9 +15,9 @@ import ( ) // forwardTCP is not thread-safe. -func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, remote, verb string) error { +func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, addr string, port int, local, remote, verb string) error { if strings.HasPrefix(local, "/") { - return forwardSSH(ctx, sshConfig, port, local, remote, verb, false) + return forwardSSH(ctx, sshConfig, addr, port, local, remote, verb, false) } localIPStr, localPortStr, err := net.SplitHostPort(local) if err != nil { @@ -30,7 +30,7 @@ func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, } if !localIP.Equal(IPv4loopback1) || localPort >= 1024 { - return forwardSSH(ctx, sshConfig, port, local, remote, verb, false) + return forwardSSH(ctx, sshConfig, addr, port, local, remote, verb, false) } // on macOS, listening on 127.0.0.1:80 requires root while 0.0.0.0:80 does not require root. @@ -45,7 +45,7 @@ func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, localUnix := plf.unixAddr.Name _ = plf.Close() delete(pseudoLoopbackForwarders, local) - if err := forwardSSH(ctx, sshConfig, port, localUnix, remote, verb, false); err != nil { + if err := forwardSSH(ctx, sshConfig, addr, port, localUnix, remote, verb, false); err != nil { return err } } else { @@ -60,12 +60,12 @@ func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, } localUnix := filepath.Join(localUnixDir, "sock") logrus.Debugf("forwarding %q to %q", localUnix, remote) - if err := forwardSSH(ctx, sshConfig, port, localUnix, remote, verb, false); err != nil { + if err := forwardSSH(ctx, sshConfig, addr, port, localUnix, remote, verb, false); err != nil { return err } plf, err := newPseudoLoopbackForwarder(localPort, localUnix) if err != nil { - if cancelErr := forwardSSH(ctx, sshConfig, port, localUnix, remote, verbCancel, false); cancelErr != nil { + if cancelErr := forwardSSH(ctx, sshConfig, addr, port, localUnix, remote, verbCancel, false); cancelErr != nil { logrus.WithError(cancelErr).Warnf("failed to cancel forwarding %q to %q", localUnix, remote) } return err diff --git a/pkg/hostagent/port_others.go b/pkg/hostagent/port_others.go index c038e6c377d..2512a85759a 100644 --- a/pkg/hostagent/port_others.go +++ b/pkg/hostagent/port_others.go @@ -8,6 +8,6 @@ import ( "github.com/lima-vm/sshocker/pkg/ssh" ) -func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, remote, verb string) error { - return forwardSSH(ctx, sshConfig, port, local, remote, verb, false) +func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, addr string, port int, local, remote, verb string) error { + return forwardSSH(ctx, sshConfig, addr, port, local, remote, verb, false) } diff --git a/pkg/hostagent/port_windows.go b/pkg/hostagent/port_windows.go index 6a3d74e14b7..37f86809162 100644 --- a/pkg/hostagent/port_windows.go +++ b/pkg/hostagent/port_windows.go @@ -6,6 +6,6 @@ import ( "github.com/lima-vm/sshocker/pkg/ssh" ) -func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, port int, local, remote, verb string) error { - return forwardSSH(ctx, sshConfig, port, local, remote, verb, false) +func forwardTCP(ctx context.Context, sshConfig *ssh.SSHConfig, addr string, port int, local, remote, verb string) error { + return forwardSSH(ctx, sshConfig, addr, port, local, remote, verb, false) } diff --git a/pkg/hostagent/provision.go b/pkg/hostagent/provision.go new file mode 100644 index 00000000000..b9010fcef41 --- /dev/null +++ b/pkg/hostagent/provision.go @@ -0,0 +1,69 @@ +package hostagent + +import ( + "errors" + "fmt" + + "github.com/lima-vm/lima/pkg/limayaml" + "github.com/lima-vm/sshocker/pkg/ssh" + "github.com/sirupsen/logrus" +) + +func (a *HostAgent) runProvisionScripts() error { + var errs []error + + for i, f := range a.instConfig.Provision { + switch f.Mode { + case limayaml.ProvisionModeSystem, limayaml.ProvisionModeUser: + logrus.Infof("Running %s provision %d of %d", f.Mode, i+1, len(a.instConfig.Provision)) + err := a.waitForProvision( + provision{ + description: fmt.Sprintf("provision.%s/%08d", f.Mode, i), + sudo: f.Mode == limayaml.ProvisionModeSystem, + script: f.Script, + }) + if err != nil { + errs = append(errs, err) + } + case limayaml.ProvisionModeDependency, limayaml.ProvisionModeBoot: + logrus.Infof("Skipping %s provision %d of %d", f.Mode, i+1, len(a.instConfig.Provision)) + continue + default: + return fmt.Errorf("unknown provision mode %q", f.Mode) + } + } + return errors.Join(errs...) +} + +func (a *HostAgent) waitForProvision(p provision) error { + if p.sudo { + return a.waitForSystemProvision(p) + } + return a.waitForUserProvision(p) +} + +func (a *HostAgent) waitForSystemProvision(p provision) error { + logrus.Debugf("executing script %q", p.description) + stdout, stderr, err := sudoExecuteScript(a.instSSHAddress, a.sshLocalPort, a.sshConfig, p.script, p.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) + } + return nil +} + +func (a *HostAgent) waitForUserProvision(p provision) error { + logrus.Debugf("executing script %q", p.description) + stdout, stderr, err := ssh.ExecuteScript(a.instSSHAddress, a.sshLocalPort, a.sshConfig, p.script, p.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) + } + return nil +} + +type provision struct { + description string + script string + sudo bool +} diff --git a/pkg/hostagent/requirements.go b/pkg/hostagent/requirements.go index 2f290768499..bb18a3c177f 100644 --- a/pkg/hostagent/requirements.go +++ b/pkg/hostagent/requirements.go @@ -117,22 +117,24 @@ If any private key under ~/.ssh is protected with a passphrase, you need to have if *a.instConfig.Plain { return req } - req = append(req, - requirement{ - description: "user session is ready for ssh", - script: `#!/bin/bash + if *a.instConfig.VMType != limayaml.EXT { + req = append(req, + requirement{ + description: "user session is ready for ssh", + script: `#!/bin/bash set -eux -o pipefail if ! timeout 30s bash -c "until sudo diff -q /run/lima-ssh-ready /mnt/lima-cidata/meta-data 2>/dev/null; do sleep 3; done"; then echo >&2 "not ready to start persistent ssh session" exit 1 fi `, - debugHint: `The boot sequence will terminate any existing user session after updating + debugHint: `The boot sequence will terminate any existing user session after updating /etc/environment to make sure the session includes the new values. Terminating the session will break the persistent SSH tunnel, so it must not be created until the session reset is done. `, - }) + }) + } if *a.instConfig.MountType == limayaml.REVSSHFS && len(a.instConfig.Mounts) > 0 { req = append(req, requirement{ @@ -214,20 +216,22 @@ Also see "/var/log/cloud-init-output.log" in the guest. func (a *HostAgent) finalRequirements() []requirement { req := make([]requirement, 0) - req = append(req, - requirement{ - description: "boot scripts must have finished", - script: `#!/bin/bash + if *a.instConfig.VMType != limayaml.EXT { + req = append(req, + requirement{ + description: "boot scripts must have finished", + script: `#!/bin/bash set -eux -o pipefail if ! timeout 30s bash -c "until sudo diff -q /run/lima-boot-done /mnt/lima-cidata/meta-data 2>/dev/null; do sleep 3; done"; then echo >&2 "boot scripts have not finished" exit 1 fi `, - debugHint: `All boot scripts, provisioning scripts, and readiness probes must + debugHint: `All boot scripts, provisioning scripts, and readiness probes must finish before the instance is considered "ready". Check "/var/log/cloud-init-output.log" in the guest to see where the process is blocked! `, - }) + }) + } return req } diff --git a/pkg/hostagent/sudo.go b/pkg/hostagent/sudo.go new file mode 100644 index 00000000000..cae95c0c7e6 --- /dev/null +++ b/pkg/hostagent/sudo.go @@ -0,0 +1,43 @@ +package hostagent + +import ( + "bytes" + "errors" + "fmt" + "os/exec" + "strconv" + "strings" + + "github.com/lima-vm/sshocker/pkg/ssh" + "github.com/sirupsen/logrus" +) + +// sudoExecuteScript executes the given script (as root) on the remote host via stdin. +// Returns stdout and stderr. +// +// scriptName is used only for readability of error strings. +func sudoExecuteScript(host string, port int, c *ssh.SSHConfig, script, scriptName string) (stdout, stderr string, err error) { + if c == nil { + return "", "", errors.New("got nil SSHConfig") + } + interpreter, err := ssh.ParseScriptInterpreter(script) + if err != nil { + return "", "", err + } + sshBinary := c.Binary() + sshArgs := c.Args() + if port != 0 { + sshArgs = append(sshArgs, "-p", strconv.Itoa(port)) + } + sshArgs = append(sshArgs, host, "--", "sudo", interpreter) + sshCmd := exec.Command(sshBinary, sshArgs...) + sshCmd.Stdin = strings.NewReader(script) + var buf bytes.Buffer + sshCmd.Stderr = &buf + logrus.Debugf("executing ssh for script %q: %s %v", scriptName, sshCmd.Path, sshCmd.Args) + out, err := sshCmd.Output() + if err != nil { + return string(out), buf.String(), fmt.Errorf("failed to execute script %q: stdout=%q, stderr=%q: %w", scriptName, string(out), buf.String(), err) + } + return string(out), buf.String(), nil +} diff --git a/pkg/instance/start.go b/pkg/instance/start.go index 899f07c7147..4af18a49f2c 100644 --- a/pkg/instance/start.go +++ b/pkg/instance/start.go @@ -98,6 +98,10 @@ func Prepare(ctx context.Context, inst *store.Instance) (*Prepared, error) { if err := limaDriver.CreateDisk(ctx); err != nil { return nil, err } + if *inst.Config.VMType == limayaml.EXT { + // Created externally + created = true + } nerdctlArchiveCache, err := ensureNerdctlArchiveCache(ctx, inst.Config, created) if err != nil { return nil, err @@ -285,7 +289,13 @@ func watchHostAgentEvents(ctx context.Context, inst *store.Instance, haStdoutPat ) onEvent := func(ev hostagentevents.Event) bool { if !printedSSHLocalPort && ev.Status.SSHLocalPort != 0 { - logrus.Infof("SSH Local Port: %d", ev.Status.SSHLocalPort) + if ev.Status.SSHIPAddress == "127.0.0.1" { + logrus.Infof("SSH Local Port: %d", ev.Status.SSHLocalPort) + } else if ev.Status.SSHLocalPort == 22 { + logrus.Infof("SSH IP Address: %s", ev.Status.SSHIPAddress) + } else { + logrus.Infof("SSH IP Address: %s Port: %d", ev.Status.SSHIPAddress, ev.Status.SSHLocalPort) + } printedSSHLocalPort = true } diff --git a/pkg/limayaml/defaults.go b/pkg/limayaml/defaults.go index a3b196d9eec..77c9cd481da 100644 --- a/pkg/limayaml/defaults.go +++ b/pkg/limayaml/defaults.go @@ -398,6 +398,16 @@ func FillDefault(y, d, o *LimaYAML, filePath string, warn bool) { y.TimeZone = ptr.Of(hostTimeZone()) } + if y.SSH.Address == nil { + y.SSH.Address = d.SSH.Address + } + if o.SSH.Address != nil { + y.SSH.Address = o.SSH.Address + } + if y.SSH.Address == nil { + y.SSH.Address = ptr.Of("127.0.0.1") + } + if y.SSH.LocalPort == nil { y.SSH.LocalPort = d.SSH.LocalPort } @@ -1041,6 +1051,8 @@ func NewVMType(driver string) VMType { return QEMU case "wsl2": return WSL2 + case "ext": + return EXT default: logrus.Warnf("Unknown driver: %s", driver) return driver diff --git a/pkg/limayaml/defaults_test.go b/pkg/limayaml/defaults_test.go index 6ce45311ac1..ebe8132fa53 100644 --- a/pkg/limayaml/defaults_test.go +++ b/pkg/limayaml/defaults_test.go @@ -84,6 +84,7 @@ func TestFillDefault(t *testing.T) { Archives: defaultContainerdArchives(), }, SSH: SSH{ + Address: ptr.Of("127.0.0.1"), LocalPort: ptr.Of(0), LoadDotSSHPubKeys: ptr.Of(false), ForwardAgent: ptr.Of(false), @@ -342,6 +343,7 @@ func TestFillDefault(t *testing.T) { }, }, SSH: SSH{ + Address: ptr.Of("0.0.0.0"), LocalPort: ptr.Of(888), LoadDotSSHPubKeys: ptr.Of(false), ForwardAgent: ptr.Of(true), @@ -556,6 +558,7 @@ func TestFillDefault(t *testing.T) { }, }, SSH: SSH{ + Address: ptr.Of("127.0.1.1"), LocalPort: ptr.Of(4433), LoadDotSSHPubKeys: ptr.Of(true), ForwardAgent: ptr.Of(true), diff --git a/pkg/limayaml/limayaml.go b/pkg/limayaml/limayaml.go index a899ed35658..de1d6f4d8fb 100644 --- a/pkg/limayaml/limayaml.go +++ b/pkg/limayaml/limayaml.go @@ -75,6 +75,7 @@ const ( QEMU VMType = "qemu" VZ VMType = "vz" WSL2 VMType = "wsl2" + EXT VMType = "ext" ) var ( @@ -167,7 +168,8 @@ type Virtiofs struct { } type SSH struct { - LocalPort *int `yaml:"localPort,omitempty" json:"localPort,omitempty" jsonschema:"nullable"` + Address *string `yaml:"address,omitempty" json:"address,omitempty" jsonschema:"nullable"` + LocalPort *int `yaml:"localPort,omitempty" json:"localPort,omitempty" jsonschema:"nullable"` // LoadDotSSHPubKeys loads ~/.ssh/*.pub in addition to $LIMA_HOME/_config/user.pub . LoadDotSSHPubKeys *bool `yaml:"loadDotSSHPubKeys,omitempty" json:"loadDotSSHPubKeys,omitempty" jsonschema:"nullable"` // default: false diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go index 56d03c0d22c..f23507fada1 100644 --- a/pkg/limayaml/validate.go +++ b/pkg/limayaml/validate.go @@ -83,11 +83,13 @@ func Validate(y *LimaYAML, warn bool) error { if !IsNativeArch(*y.Arch) { return fmt.Errorf("field `arch` must be %q for VZ; got %q", NewArch(runtime.GOARCH), *y.Arch) } + case EXT: + // NOP default: - return fmt.Errorf("field `vmType` must be %q, %q, %q; got %q", QEMU, VZ, WSL2, *y.VMType) + return fmt.Errorf("field `vmType` must be %q, %q, %q, %q; got %q", QEMU, VZ, WSL2, EXT, *y.VMType) } - if len(y.Images) == 0 { + if len(y.Images) == 0 && *y.VMType != EXT { return errors.New("field `images` must be set") } for i, f := range y.Images { @@ -167,6 +169,14 @@ func Validate(y *LimaYAML, warn bool) error { } } + if *y.SSH.Address == "127.0.0.1" && *y.VMType == EXT { + return errors.New("field `ssh.address` must be set, for ext") + } + if y.SSH.Address != nil { + if err := validateHost("ssh.address", *y.SSH.Address); err != nil { + return err + } + } if *y.SSH.LocalPort != 0 { if err := validatePort("ssh.localPort", *y.SSH.LocalPort); err != nil { return err @@ -493,6 +503,25 @@ func ValidateParamIsUsed(y *LimaYAML) error { return nil } +func lookupIP(host string) error { + if strings.HasSuffix(host, ".local") { + // allow offline or slow mDNS + return nil + } + _, err := net.LookupIP(host) + return err +} + +func validateHost(field, host string) error { + if net.ParseIP(host) != nil { + return nil + } + if err := lookupIP(host); err != nil { + return fmt.Errorf("field `%s` must be IP: %w", field, err) + } + return nil +} + func validatePort(field string, port int) error { switch { case port < 0: @@ -511,6 +540,9 @@ func warnExperimental(y *LimaYAML) { if *y.MountType == VIRTIOFS && runtime.GOOS == "linux" { logrus.Warn("`mountType: virtiofs` on Linux is experimental") } + if *y.VMType == EXT { + logrus.Warn("`vmType: ext` is experimental") + } if *y.Arch == RISCV64 { logrus.Warn("`arch: riscv64` is experimental") } diff --git a/pkg/sshutil/sshutil.go b/pkg/sshutil/sshutil.go index af141e660a6..cb3e07c4d1a 100644 --- a/pkg/sshutil/sshutil.go +++ b/pkg/sshutil/sshutil.go @@ -7,6 +7,7 @@ import ( "errors" "fmt" "io/fs" + "net" "os" "os/exec" "path/filepath" @@ -121,12 +122,20 @@ var sshInfo struct { openSSHVersion semver.Version } +func IsLocalhost(address string) bool { + ip := net.ParseIP(address) + if ip == nil { + return false + } + return ip.IsLoopback() +} + // CommonOpts returns ssh option key-value pairs like {"IdentityFile=/path/to/id_foo"}. // The result may contain different values with the same key. // // The result always contains the IdentityFile option. // The result never contains the Port option. -func CommonOpts(useDotSSH bool) ([]string, error) { +func CommonOpts(useDotSSH, localhost bool) ([]string, error) { configDir, err := dirnames.LimaConfigDir() if err != nil { return nil, err @@ -181,14 +190,20 @@ func CommonOpts(useDotSSH bool) ([]string, error) { } } + if localhost { + opts = append(opts, + "StrictHostKeyChecking=no", + "UserKnownHostsFile=/dev/null", + "BatchMode=yes", + ) + } + opts = append(opts, - "StrictHostKeyChecking=no", - "UserKnownHostsFile=/dev/null", "NoHostAuthenticationForLocalhost=yes", "GSSAPIAuthentication=no", "PreferredAuthentications=publickey", "Compression=no", - "BatchMode=yes", + "PasswordAuthentication=no", "IdentitiesOnly=yes", ) @@ -223,12 +238,12 @@ func CommonOpts(useDotSSH bool) ([]string, error) { } // SSHOpts adds the following options to CommonOptions: User, ControlMaster, ControlPath, ControlPersist. -func SSHOpts(instDir, username string, useDotSSH, forwardAgent, forwardX11, forwardX11Trusted bool) ([]string, error) { +func SSHOpts(instDir, username string, useDotSSH bool, hostAddress string, forwardAgent, forwardX11, forwardX11Trusted bool) ([]string, error) { controlSock := filepath.Join(instDir, filenames.SSHSock) if len(controlSock) >= osutil.UnixPathMax { return nil, fmt.Errorf("socket path %q is too long: >= UNIX_PATH_MAX=%d", controlSock, osutil.UnixPathMax) } - opts, err := CommonOpts(useDotSSH) + opts, err := CommonOpts(useDotSSH, IsLocalhost(hostAddress)) if err != nil { return nil, err } diff --git a/pkg/sshutil/sshutil_test.go b/pkg/sshutil/sshutil_test.go index ef2b754da23..e452a160f1d 100644 --- a/pkg/sshutil/sshutil_test.go +++ b/pkg/sshutil/sshutil_test.go @@ -15,6 +15,10 @@ func TestDefaultPubKeys(t *testing.T) { } } +func TestIsLocalhost(t *testing.T) { + assert.Equal(t, IsLocalhost("127.0.0.1"), true) +} + func TestParseOpenSSHVersion(t *testing.T) { assert.Check(t, ParseOpenSSHVersion([]byte("OpenSSH_8.4p1 Ubuntu")).Equal( semver.Version{Major: 8, Minor: 4, Patch: 1, PreRelease: "", Metadata: ""})) diff --git a/pkg/store/instance.go b/pkg/store/instance.go index 7daf4537e1e..95c02f10972 100644 --- a/pkg/store/instance.go +++ b/pkg/store/instance.go @@ -94,7 +94,7 @@ func Inspect(instName string) (*Instance, error) { inst.Arch = *y.Arch inst.VMType = *y.VMType inst.CPUType = y.CPUType[*y.Arch] - inst.SSHAddress = "127.0.0.1" + inst.SSHAddress = *y.SSH.Address inst.SSHLocalPort = *y.SSH.LocalPort // maybe 0 inst.SSHConfigFile = filepath.Join(instDir, filenames.SSHConfig) inst.HostAgentPID, err = ReadPIDFile(filepath.Join(instDir, filenames.HostAgentPID)) @@ -189,6 +189,14 @@ func inspectStatusWithPIDFiles(instDir string, inst *Instance, y *limayaml.LimaY inst.Status = StatusBroken inst.Errors = append(inst.Errors, err) } + if *y.VMType == limayaml.EXT { + if inst.HostAgentPID > 0 { + inst.Status = StatusRunning + } else if inst.HostAgentPID == 0 { + inst.Status = StatusStopped + } + return + } if inst.Status == StatusUnknown { switch { diff --git a/templates/README.md b/templates/README.md index 8cdd2b0c708..6700b995d1b 100644 --- a/templates/README.md +++ b/templates/README.md @@ -30,6 +30,9 @@ Distro: - [`experimental/gentoo`](./experimental/gentoo.yaml): [experimental] Gentoo - [`experimental/opensuse-tumbleweed`](./experimental/opensuse-tumbleweed.yaml): [experimental] openSUSE Tumbleweed +External: +- [`experimental/ext`](./experimental/ext.yaml): [experimental] External Raspberry Pi Zero + Container engines: - [`apptainer`](./apptainer.yaml): Apptainer - [`apptainer-rootful`](./apptainer-rootful.yaml): Apptainer (rootful) diff --git a/templates/default.yaml b/templates/default.yaml index 3720be52ecc..461cda94a32 100644 --- a/templates/default.yaml +++ b/templates/default.yaml @@ -142,6 +142,9 @@ additionalDisks: # fsType: "ext4" ssh: + # Address for the host. + # 🟢 Builtin default: "127.0.0.1" (localhost) + address: null # A localhost port of the host. Forwarded to port 22 of the guest. # 🟢 Builtin default: 0 (automatically assigned to a free port) # NOTE: when the instance name is "default", the builtin default value is set to diff --git a/website/content/en/docs/config/vmtype/_index.md b/website/content/en/docs/config/vmtype/_index.md index 3c407a5db3e..9a9980f6910 100644 --- a/website/content/en/docs/config/vmtype/_index.md +++ b/website/content/en/docs/config/vmtype/_index.md @@ -7,6 +7,9 @@ Lima supports two ways of running guest machines: - [qemu](#qemu) - [vz](#vz) +Lima also supports connecting to external machines: +- [ext](#ext) + The vmType can be specified only on creating the instance. The vmType of existing instances cannot be changed. @@ -111,3 +114,11 @@ containerd: - When running lima using "wsl2", `${LIMA_HOME}//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. + +## EXT +> **Warning** +> "ext" mode is experimental + +"ext" option makes use of an external machine, either a virtual machine or a physical machine. + +It is accessed using an address (for SSH), the keys are supposed to be set up for it already.