diff --git a/cmd/limactl/copy.go b/cmd/limactl/copy.go index 8b4a138f9f5..d2af96bbed4 100644 --- a/cmd/limactl/copy.go +++ b/cmd/limactl/copy.go @@ -82,6 +82,7 @@ func copyAction(cmd *cobra.Command, args []string) error { } // this assumes that ssh and scp come from the same place, but scp has no -V legacySSH := sshutil.DetectOpenSSHVersion("ssh").LessThan(*semver.New("8.0.0")) + localhostOnly := true for _, arg := range args { if runtime.GOOS == "windows" { if filepath.IsAbs(arg) { @@ -111,9 +112,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: @@ -132,14 +136,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("ssh", inst.Dir, *inst.Config.User.Name, false, false, false, false) + sshOpts, err = sshutil.SSHOpts("ssh", 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("ssh", false) + sshOpts, err = sshutil.CommonOpts("ssh", false, localhostOnly) if err != nil { return err } diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go index 3aba88208e3..82220dd8c70 100644 --- a/cmd/limactl/shell.go +++ b/cmd/limactl/shell.go @@ -205,6 +205,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 b5103756c74..0ee1a054e0a 100644 --- a/cmd/limactl/show-ssh.go +++ b/cmd/limactl/show-ssh.go @@ -96,13 +96,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, "ssh", instName, format, opts) } diff --git a/cmd/limactl/tunnel.go b/cmd/limactl/tunnel.go index 9e69028186b..06a04665361 100644 --- a/cmd/limactl/tunnel.go +++ b/cmd/limactl/tunnel.go @@ -91,6 +91,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/pkg/driverutil/driverutil.go b/pkg/driverutil/driverutil.go index eb27833e7ad..7cb8c34f686 100644 --- a/pkg/driverutil/driverutil.go +++ b/pkg/driverutil/driverutil.go @@ -18,5 +18,6 @@ func Drivers() []string { if wsl2.Enabled { drivers = append(drivers, limayaml.WSL2) } + drivers = append(drivers, limayaml.EXT) return drivers } diff --git a/pkg/driverutil/instance.go b/pkg/driverutil/instance.go index d7c443ff5d2..b35c6fe1bff 100644 --- a/pkg/driverutil/instance.go +++ b/pkg/driverutil/instance.go @@ -5,6 +5,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" @@ -19,5 +20,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..49dc779f3b9 --- /dev/null +++ b/pkg/ext/ext_driver.go @@ -0,0 +1,18 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package ext + +import ( + "github.com/lima-vm/lima/pkg/driver" +) + +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 4752d980852..114e06fe2de 100644 --- a/pkg/hostagent/events/events.go +++ b/pkg/hostagent/events/events.go @@ -16,7 +16,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 f48f8dd86b8..605dcda18f5 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -103,7 +103,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 } @@ -134,8 +134,10 @@ 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( @@ -143,6 +145,7 @@ func New(instName string, stdout io.Writer, signalCh chan os.Signal, opts ...Opt 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) @@ -198,7 +201,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, @@ -232,13 +235,16 @@ func writeSSHConfigFile(sshPath, instName, instDir, instSSHAddress string, sshLo 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 @@ -368,8 +374,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 @@ -473,6 +492,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) } @@ -533,7 +557,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) } } } @@ -548,13 +572,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) } } @@ -565,7 +589,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) @@ -578,7 +602,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) @@ -680,11 +704,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...) @@ -695,7 +719,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", @@ -714,7 +738,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, "/") { @@ -722,7 +746,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 { @@ -737,7 +761,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 { @@ -757,7 +781,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 877d759fd0f..491dd5fe9fc 100644 --- a/pkg/hostagent/mount.go +++ b/pkg/hostagent/mount.go @@ -62,7 +62,7 @@ func (a *HostAgent) setupMount(m limayaml.Mount) (*mount, error) { Driver: *m.SSHFS.SFTPDriver, SSHConfig: a.sshConfig, LocalPath: resolvedLocation, - Host: "127.0.0.1", + Host: a.instSSHAddress, Port: a.sshLocalPort, RemotePath: *m.MountPoint, Readonly: !(*m.Writable), diff --git a/pkg/hostagent/port.go b/pkg/hostagent/port.go index 040d7594092..7da5abe7df7 100644 --- a/pkg/hostagent/port.go +++ b/pkg/hostagent/port.go @@ -15,6 +15,7 @@ import ( type portForwarder struct { sshConfig *ssh.SSHConfig + sshHostAddr string sshHostPort int rules []limayaml.PortForward ignore bool @@ -25,9 +26,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, @@ -94,7 +96,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) } } @@ -110,7 +112,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 5ac63b41388..98dae80fae5 100644 --- a/pkg/hostagent/port_darwin.go +++ b/pkg/hostagent/port_darwin.go @@ -19,9 +19,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 { @@ -34,7 +34,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. @@ -49,7 +49,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 { @@ -64,12 +64,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 8d218c25b35..a8fd5c4fdb7 100644 --- a/pkg/hostagent/port_others.go +++ b/pkg/hostagent/port_others.go @@ -11,6 +11,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 d8d19f0cbd1..fcd7dd60413 100644 --- a/pkg/hostagent/port_windows.go +++ b/pkg/hostagent/port_windows.go @@ -9,6 +9,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..bad8cbe1b95 --- /dev/null +++ b/pkg/hostagent/provision.go @@ -0,0 +1,72 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +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 7d8f24f3549..d0b389c6a30 100644 --- a/pkg/hostagent/requirements.go +++ b/pkg/hostagent/requirements.go @@ -131,22 +131,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{ @@ -228,20 +230,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..73b5d4784fd --- /dev/null +++ b/pkg/hostagent/sudo.go @@ -0,0 +1,46 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +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 b53de08183a..fd2cfa2fbbc 100644 --- a/pkg/instance/start.go +++ b/pkg/instance/start.go @@ -101,6 +101,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 @@ -288,7 +292,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 f6782d39c03..c6753068004 100644 --- a/pkg/limayaml/defaults.go +++ b/pkg/limayaml/defaults.go @@ -419,6 +419,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 } @@ -1099,6 +1109,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 b8f203838dd..ddc015f1806 100644 --- a/pkg/limayaml/defaults_test.go +++ b/pkg/limayaml/defaults_test.go @@ -87,6 +87,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), @@ -359,6 +360,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), @@ -582,6 +584,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 b954063c06c..ff18285ca2c 100644 --- a/pkg/limayaml/limayaml.go +++ b/pkg/limayaml/limayaml.go @@ -87,13 +87,14 @@ const ( QEMU VMType = "qemu" VZ VMType = "vz" WSL2 VMType = "wsl2" + EXT VMType = "ext" ) var ( OSTypes = []OS{LINUX} ArchTypes = []Arch{X8664, AARCH64, ARMV7L, RISCV64, S390X} MountTypes = []MountType{REVSSHFS, NINEP, VIRTIOFS, WSLMount} - VMTypes = []VMType{QEMU, VZ, WSL2} + VMTypes = []VMType{QEMU, VZ, WSL2, EXT} ) type User struct { @@ -180,7 +181,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 b5ee24c87d9..f8fce37b552 100644 --- a/pkg/limayaml/validate.go +++ b/pkg/limayaml/validate.go @@ -92,11 +92,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 { @@ -187,6 +189,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 @@ -559,6 +569,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: @@ -577,6 +606,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") + } switch *y.Arch { case RISCV64, ARMV7L, S390X: logrus.Warnf("`arch: %s ` is experimental", *y.Arch) diff --git a/pkg/sshutil/sshutil.go b/pkg/sshutil/sshutil.go index 1d8a5f15ce6..7090f275521 100644 --- a/pkg/sshutil/sshutil.go +++ b/pkg/sshutil/sshutil.go @@ -10,6 +10,7 @@ import ( "errors" "fmt" "io/fs" + "net" "os" "os/exec" "path/filepath" @@ -162,12 +163,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(sshPath string, useDotSSH bool) ([]string, error) { +func CommonOpts(sshPath string, useDotSSH, localhost bool) ([]string, error) { configDir, err := dirnames.LimaConfigDir() if err != nil { return nil, err @@ -221,14 +230,20 @@ func CommonOpts(sshPath string, 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", ) @@ -274,12 +289,12 @@ func identityFileEntry(privateKeyPath string) (string, error) { } // SSHOpts adds the following options to CommonOptions: User, ControlMaster, ControlPath, ControlPersist. -func SSHOpts(sshPath, instDir, username string, useDotSSH, forwardAgent, forwardX11, forwardX11Trusted bool) ([]string, error) { +func SSHOpts(sshPath, 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(sshPath, useDotSSH) + opts, err := CommonOpts(sshPath, useDotSSH, IsLocalhost(hostAddress)) if err != nil { return nil, err } diff --git a/pkg/sshutil/sshutil_test.go b/pkg/sshutil/sshutil_test.go index 972c3037b1a..b0be5cdb814 100644 --- a/pkg/sshutil/sshutil_test.go +++ b/pkg/sshutil/sshutil_test.go @@ -18,6 +18,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 2af8f814f93..abb1f0e40a7 100644 --- a/pkg/store/instance.go +++ b/pkg/store/instance.go @@ -96,7 +96,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)) @@ -191,6 +191,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 3fd1ca59a48..cb470e1d10b 100644 --- a/templates/README.md +++ b/templates/README.md @@ -33,6 +33,9 @@ Distro: - [`experimental/opensuse-tumbleweed`](./experimental/opensuse-tumbleweed.yaml): [experimental] openSUSE Tumbleweed - [`experimental/debian-sid`](./experimental/debian-sid.yaml): [experimental] Debian Sid +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 eac787d7d77..dc03934dc30 100644 --- a/templates/default.yaml +++ b/templates/default.yaml @@ -147,6 +147,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/templates/experimental/ext.yaml b/templates/experimental/ext.yaml new file mode 100644 index 00000000000..bea4f51d651 --- /dev/null +++ b/templates/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/website/content/en/docs/config/vmtype.md b/website/content/en/docs/config/vmtype.md index 3c407a5db3e..9a9980f6910 100644 --- a/website/content/en/docs/config/vmtype.md +++ b/website/content/en/docs/config/vmtype.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.