From 6fe22237c60c40f799900e8be83c0a4ae4db5f62 Mon Sep 17 00:00:00 2001 From: Jan Dubois Date: Fri, 1 Nov 2024 11:13:37 -0700 Subject: [PATCH] Make guest username, uid, home directory configurable Also disallows the "admin" username by default (because it is a builtin user in Ubuntu), but can be overridden by setting it explicitly in lima.yaml. Signed-off-by: Jan Dubois --- .golangci.yml | 3 + cmd/limactl/copy.go | 21 ++-- cmd/limactl/edit.go | 2 +- cmd/limactl/shell.go | 1 + cmd/limactl/show-ssh.go | 1 + cmd/limactl/tunnel.go | 1 + hack/test-templates.sh | 7 ++ hack/test-templates/test-misc.yaml | 6 ++ pkg/cidata/cidata.go | 12 +-- pkg/cidata/template.go | 6 +- pkg/hostagent/hostagent.go | 5 +- pkg/instance/create.go | 2 +- pkg/limayaml/defaults.go | 108 ++++++++++++++----- pkg/limayaml/defaults_test.go | 40 ++++++-- pkg/limayaml/limayaml.go | 8 ++ pkg/limayaml/load.go | 13 ++- pkg/limayaml/validate.go | 12 +-- pkg/must/must.go | 8 ++ pkg/osutil/user.go | 160 +++++++++++++++-------------- pkg/osutil/user_test.go | 49 ++++++--- pkg/sshutil/sshutil.go | 8 +- templates/default.yaml | 19 +++- 22 files changed, 314 insertions(+), 178 deletions(-) create mode 100644 pkg/must/must.go diff --git a/.golangci.yml b/.golangci.yml index 82a57f202fe8..4f67d2c1f4dc 100644 --- a/.golangci.yml +++ b/.golangci.yml @@ -121,6 +121,9 @@ linters-settings: - name: context-keys-type - name: deep-exit - name: dot-imports + arguments: + - allowedPackages: + - github.com/lima-vm/lima/pkg/must - name: empty-block - name: error-naming - name: error-return diff --git a/cmd/limactl/copy.go b/cmd/limactl/copy.go index cbee6d21127c..32f1d3e99f1d 100644 --- a/cmd/limactl/copy.go +++ b/cmd/limactl/copy.go @@ -8,7 +8,6 @@ import ( "strings" "github.com/coreos/go-semver/semver" - "github.com/lima-vm/lima/pkg/osutil" "github.com/lima-vm/lima/pkg/sshutil" "github.com/lima-vm/lima/pkg/store" "github.com/sirupsen/logrus" @@ -48,11 +47,7 @@ func copyAction(cmd *cobra.Command, args []string) error { if err != nil { return err } - u, err := osutil.LimaUser(false) - if err != nil { - return err - } - instDirs := make(map[string]string) + instances := make(map[string]*store.Instance) scpFlags := []string{} scpArgs := []string{} debug, err := cmd.Flags().GetBool("debug") @@ -85,28 +80,28 @@ 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", u.Username, path[1])) + scpArgs = append(scpArgs, fmt.Sprintf("%s@127.0.0.1:%s", *inst.Config.User.Username, path[1])) } else { - scpArgs = append(scpArgs, fmt.Sprintf("scp://%s@127.0.0.1:%d/%s", u.Username, inst.SSHLocalPort, path[1])) + scpArgs = append(scpArgs, fmt.Sprintf("scp://%s@127.0.0.1:%d/%s", *inst.Config.User.Username, inst.SSHLocalPort, path[1])) } - instDirs[instName] = inst.Dir + instances[instName] = inst default: return fmt.Errorf("path %q contains multiple colons", arg) } } - if legacySSH && len(instDirs) > 1 { + if legacySSH && len(instances) > 1 { return fmt.Errorf("more than one (instance) host is involved in this command, this is only supported for openSSH v8.0 or higher") } scpFlags = append(scpFlags, "-3", "--") scpArgs = append(scpFlags, scpArgs...) var sshOpts []string - if len(instDirs) == 1 { + if len(instances) == 1 { // Only one (instance) host is involved; we can use the instance-specific // arguments such as ControlPath. This is preferred as we can multiplex // sessions without re-authenticating (MaxSessions permitting). - for _, instDir := range instDirs { - sshOpts, err = sshutil.SSHOpts(instDir, false, false, false, false) + for _, inst := range instances { + sshOpts, err = sshutil.SSHOpts(inst.Dir, *inst.Config.User.Username, false, false, false, false) if err != nil { return err } diff --git a/cmd/limactl/edit.go b/cmd/limactl/edit.go index 70a54a858f6d..3d0dde19935c 100644 --- a/cmd/limactl/edit.go +++ b/cmd/limactl/edit.go @@ -115,7 +115,7 @@ func editAction(cmd *cobra.Command, args []string) error { logrus.Info("Aborting, no changes made to the instance") return nil } - y, err := limayaml.Load(yBytes, filePath) + y, err := limayaml.LoadWithWarnings(yBytes, filePath) if err != nil { return err } diff --git a/cmd/limactl/shell.go b/cmd/limactl/shell.go index 694413923d89..a8ad8dace308 100644 --- a/cmd/limactl/shell.go +++ b/cmd/limactl/shell.go @@ -167,6 +167,7 @@ func shellAction(cmd *cobra.Command, args []string) error { sshOpts, err := sshutil.SSHOpts( inst.Dir, + *inst.Config.User.Username, *inst.Config.SSH.LoadDotSSHPubKeys, *inst.Config.SSH.ForwardAgent, *inst.Config.SSH.ForwardX11, diff --git a/cmd/limactl/show-ssh.go b/cmd/limactl/show-ssh.go index 63e203c5c143..41b34879a5cf 100644 --- a/cmd/limactl/show-ssh.go +++ b/cmd/limactl/show-ssh.go @@ -90,6 +90,7 @@ func showSSHAction(cmd *cobra.Command, args []string) error { filepath.Join(inst.Dir, filenames.SSHConfig), inst.Hostname) opts, err := sshutil.SSHOpts( inst.Dir, + *inst.Config.User.Username, *inst.Config.SSH.LoadDotSSHPubKeys, *inst.Config.SSH.ForwardAgent, *inst.Config.SSH.ForwardX11, diff --git a/cmd/limactl/tunnel.go b/cmd/limactl/tunnel.go index c84c9b25ed57..6a00f9ed031b 100644 --- a/cmd/limactl/tunnel.go +++ b/cmd/limactl/tunnel.go @@ -107,6 +107,7 @@ func tunnelAction(cmd *cobra.Command, args []string) error { sshOpts, err := sshutil.SSHOpts( inst.Dir, + *inst.Config.User.Username, *inst.Config.SSH.LoadDotSSHPubKeys, *inst.Config.SSH.ForwardAgent, *inst.Config.SSH.ForwardX11, diff --git a/hack/test-templates.sh b/hack/test-templates.sh index 37455a2a1f9e..c84cdd75ae9b 100755 --- a/hack/test-templates.sh +++ b/hack/test-templates.sh @@ -37,6 +37,7 @@ declare -A CHECKS=( ["mount-path-with-spaces"]="" ["provision-ansible"]="" ["param-env-variables"]="" + ["set-user"]="" ) case "$NAME" in @@ -63,6 +64,7 @@ case "$NAME" in CHECKS["mount-path-with-spaces"]="1" CHECKS["provision-ansible"]="1" CHECKS["param-env-variables"]="1" + CHECKS["set-user"]="1" ;; "docker") CONTAINER_ENGINE="docker" @@ -172,6 +174,11 @@ if [[ -n ${CHECKS["param-env-variables"]} ]]; then limactl shell "$NAME" test -e /tmp/param-user fi +if [[ -n ${CHECKS["set-user"]} ]]; then + INFO 'Testing that user settings can be provided by lima.yaml' + limactl shell "$NAME" grep "^john:x:4711:4711:John Doe:/tmp/john" /etc/passwd +fi + INFO "Testing proxy settings are imported" got=$(limactl shell "$NAME" env | grep FTP_PROXY) # Expected: FTP_PROXY is set in addition to ftp_proxy, localhost is replaced diff --git a/hack/test-templates/test-misc.yaml b/hack/test-templates/test-misc.yaml index 6284c4acc679..e1d425423394 100644 --- a/hack/test-templates/test-misc.yaml +++ b/hack/test-templates/test-misc.yaml @@ -58,3 +58,9 @@ probes: # $ limactl disk create data --size 10G additionalDisks: - "data" + +user: + name: john + gecos: John Doe + home: "/tmp/{{.User}}" + uid: 4711 diff --git a/pkg/cidata/cidata.go b/pkg/cidata/cidata.go index b0e60db8fde7..9b39f16549a1 100644 --- a/pkg/cidata/cidata.go +++ b/pkg/cidata/cidata.go @@ -117,11 +117,7 @@ func templateArgs(bootScripts bool, instDir, name string, instConfig *limayaml.L if err := limayaml.Validate(instConfig, false); err != nil { return nil, err } - u, err := osutil.LimaUser(true) - if err != nil { - return nil, err - } - uid, err := strconv.Atoi(u.Uid) + uid, err := strconv.Atoi(*instConfig.User.UID) if err != nil { return nil, err } @@ -130,10 +126,10 @@ func templateArgs(bootScripts bool, instDir, name string, instConfig *limayaml.L BootScripts: bootScripts, Name: name, Hostname: identifierutil.HostnameFromInstName(name), // TODO: support customization - User: u.Username, UID: uid, - GECOS: u.Name, - Home: fmt.Sprintf("/home/%s.linux", u.Username), + GECOS: *instConfig.User.Name, + User: *instConfig.User.Username, + Home: *instConfig.User.HomeDir, GuestInstallPrefix: *instConfig.GuestInstallPrefix, UpgradePackages: *instConfig.UpgradePackages, Containerd: Containerd{System: *instConfig.Containerd.System, User: *instConfig.Containerd.User}, diff --git a/pkg/cidata/template.go b/pkg/cidata/template.go index a927f839509d..a5e2e34dd786 100644 --- a/pkg/cidata/template.go +++ b/pkg/cidata/template.go @@ -11,7 +11,6 @@ import ( "github.com/lima-vm/lima/pkg/iso9660util" "github.com/containerd/containerd/identifiers" - "github.com/lima-vm/lima/pkg/osutil" "github.com/lima-vm/lima/pkg/textutil" ) @@ -97,9 +96,8 @@ func ValidateTemplateArgs(args *TemplateArgs) error { if err := identifiers.Validate(args.Name); err != nil { return err } - if !osutil.ValidateUsername(args.User) { - return errors.New("field User must be valid linux username") - } + // args.User is intentionally not validated here; the user can override with any name they want + // limayaml.FillDefault will validate the default (local) username, but not an explicit setting if args.User == "root" { return errors.New("field User must not be \"root\"") } diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index 0706b6cc7306..9a73e6caf137 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -144,6 +144,7 @@ func New(instName string, stdout io.Writer, signalCh chan os.Signal, opts ...Opt sshOpts, err := sshutil.SSHOpts( inst.Dir, + *inst.Config.User.Username, *inst.Config.SSH.LoadDotSSHPubKeys, *inst.Config.SSH.ForwardAgent, *inst.Config.SSH.ForwardX11, @@ -182,13 +183,13 @@ func New(instName string, stdout io.Writer, signalCh chan os.Signal, opts ...Opt // Block ports 22 and sshLocalPort on all IPs for _, port := range []int{sshGuestPort, sshLocalPort} { rule := limayaml.PortForward{GuestIP: net.IPv4zero, GuestPort: port, Ignore: true} - limayaml.FillPortForwardDefaults(&rule, inst.Dir, inst.Param) + limayaml.FillPortForwardDefaults(&rule, inst.Dir, inst.Config.User, inst.Param) rules = append(rules, rule) } rules = append(rules, inst.Config.PortForwards...) // Default forwards for all non-privileged ports from "127.0.0.1" and "::1" rule := limayaml.PortForward{} - limayaml.FillPortForwardDefaults(&rule, inst.Dir, inst.Param) + limayaml.FillPortForwardDefaults(&rule, inst.Dir, inst.Config.User, inst.Param) rules = append(rules, rule) limaDriver := driverutil.CreateTargetDriverInstance(&driver.BaseDriver{ diff --git a/pkg/instance/create.go b/pkg/instance/create.go index 6a3f91d1c011..4941efbcf418 100644 --- a/pkg/instance/create.go +++ b/pkg/instance/create.go @@ -41,7 +41,7 @@ func Create(ctx context.Context, instName string, instConfig []byte, saveBrokenY } // limayaml.Load() needs to pass the store file path to limayaml.FillDefault() to calculate default MAC addresses filePath := filepath.Join(instDir, filenames.LimaYAML) - loadedInstConfig, err := limayaml.Load(instConfig, filePath) + loadedInstConfig, err := limayaml.LoadWithWarnings(instConfig, filePath) if err != nil { return nil, err } diff --git a/pkg/limayaml/defaults.go b/pkg/limayaml/defaults.go index 3d2a89a3296b..2c3b6cd0fcdf 100644 --- a/pkg/limayaml/defaults.go +++ b/pkg/limayaml/defaults.go @@ -8,6 +8,7 @@ import ( "fmt" "net" "os" + "os/user" "path/filepath" "runtime" "slices" @@ -18,11 +19,13 @@ import ( "github.com/coreos/go-semver/semver" "github.com/docker/go-units" "github.com/goccy/go-yaml" + "github.com/lima-vm/lima/pkg/version" "github.com/pbnjay/memory" "github.com/sirupsen/logrus" "golang.org/x/sys/cpu" "github.com/lima-vm/lima/pkg/identifierutil" + . "github.com/lima-vm/lima/pkg/must" "github.com/lima-vm/lima/pkg/networks" "github.com/lima-vm/lima/pkg/osutil" "github.com/lima-vm/lima/pkg/ptr" @@ -43,7 +46,12 @@ const ( DefaultVirtiofsQueueSize int = 1024 ) -var IPv4loopback1 = net.IPv4(127, 0, 0, 1) +var ( + IPv4loopback1 = net.IPv4(127, 0, 0, 1) + + userHomeDir = Must(os.UserHomeDir()) + currentUser = Must(user.Current()) +) func defaultCPUType() CPUType { cpuType := map[Arch]string{ @@ -171,17 +179,66 @@ func defaultGuestInstallPrefix() string { // - Networks are appended in d, y, o order // - DNS are picked from the highest priority where DNS is not empty. // - CACertificates Files and Certs are uniquely appended in d, y, o order -func FillDefault(y, d, o *LimaYAML, filePath string) { +func FillDefault(y, d, o *LimaYAML, filePath string, warn bool) { instDir := filepath.Dir(filePath) // existingLimaVersion can be empty if the instance was created with Lima prior to v0.20, - // or, when editing a template file without an instance (`limactl edit foo.yaml`) var existingLimaVersion string - limaVersionFile := filepath.Join(instDir, filenames.LimaVersion) - if b, err := os.ReadFile(limaVersionFile); err == nil { - existingLimaVersion = strings.TrimSpace(string(b)) - } else if !errors.Is(err, os.ErrNotExist) { - logrus.WithError(err).Warnf("Failed to read %q", limaVersionFile) + if !isExistingInstanceDir(instDir) { + existingLimaVersion = version.Version + } else { + limaVersionFile := filepath.Join(instDir, filenames.LimaVersion) + if b, err := os.ReadFile(limaVersionFile); err == nil { + existingLimaVersion = strings.TrimSpace(string(b)) + } else if !errors.Is(err, os.ErrNotExist) { + logrus.WithError(err).Warnf("Failed to read %q", limaVersionFile) + } + } + + if y.User.Username == nil { + y.User.Username = d.User.Username + } + if y.User.Name == nil { + y.User.Name = d.User.Name + } + if y.User.HomeDir == nil { + y.User.HomeDir = d.User.HomeDir + } + if y.User.UID == nil { + y.User.UID = d.User.UID + } + if o.User.Username != nil { + y.User.Username = o.User.Username + } + if o.User.Name != nil { + y.User.Name = o.User.Name + } + if o.User.HomeDir != nil { + y.User.HomeDir = o.User.HomeDir + } + if o.User.UID != nil { + y.User.UID = o.User.UID + } + if y.User.Username == nil { + y.User.Username = ptr.Of(osutil.LimaUser(existingLimaVersion, warn).Username) + warn = false + } + if y.User.Name == nil { + y.User.Name = ptr.Of(osutil.LimaUser(existingLimaVersion, warn).Name) + warn = false + } + if y.User.HomeDir == nil { + y.User.HomeDir = ptr.Of(osutil.LimaUser(existingLimaVersion, warn).HomeDir) + warn = false + } + if y.User.UID == nil { + y.User.UID = ptr.Of(osutil.LimaUser(existingLimaVersion, warn).Uid) + // warn = false + } + if out, err := executeGuestTemplate(*y.User.HomeDir, instDir, y.User, y.Param); err == nil { + y.User.HomeDir = ptr.Of(out.String()) + } else { + logrus.WithError(err).Warnf("Couldn't process `user.homeDir` value %q as a template", *y.User.HomeDir) } if y.VMType == nil { @@ -406,7 +463,7 @@ func FillDefault(y, d, o *LimaYAML, filePath string) { if provision.Mode == ProvisionModeDependency && provision.SkipDefaultDependencyResolution == nil { provision.SkipDefaultDependencyResolution = ptr.Of(false) } - if out, err := executeGuestTemplate(provision.Script, instDir, y.Param); err == nil { + if out, err := executeGuestTemplate(provision.Script, instDir, y.User, y.Param); err == nil { provision.Script = out.String() } else { logrus.WithError(err).Warnf("Couldn't process provisioning script %q as a template", provision.Script) @@ -477,7 +534,7 @@ func FillDefault(y, d, o *LimaYAML, filePath string) { if probe.Description == "" { probe.Description = fmt.Sprintf("user probe %d/%d", i+1, len(y.Probes)) } - if out, err := executeGuestTemplate(probe.Script, instDir, y.Param); err == nil { + if out, err := executeGuestTemplate(probe.Script, instDir, y.User, y.Param); err == nil { probe.Script = out.String() } else { logrus.WithError(err).Warnf("Couldn't process probing script %q as a template", probe.Script) @@ -486,13 +543,13 @@ func FillDefault(y, d, o *LimaYAML, filePath string) { y.PortForwards = append(append(o.PortForwards, y.PortForwards...), d.PortForwards...) for i := range y.PortForwards { - FillPortForwardDefaults(&y.PortForwards[i], instDir, y.Param) + FillPortForwardDefaults(&y.PortForwards[i], instDir, y.User, y.Param) // After defaults processing the singular HostPort and GuestPort values should not be used again. } y.CopyToHost = append(append(o.CopyToHost, y.CopyToHost...), d.CopyToHost...) for i := range y.CopyToHost { - FillCopyToHostDefaults(&y.CopyToHost[i], instDir, y.Param) + FillCopyToHostDefaults(&y.CopyToHost[i], instDir, y.User, y.Param) } if y.HostResolver.Enabled == nil { @@ -621,7 +678,7 @@ func FillDefault(y, d, o *LimaYAML, filePath string) { logrus.WithError(err).Warnf("Couldn't process mount location %q as a template", mount.Location) } if mount.MountPoint != nil { - if out, err := executeGuestTemplate(*mount.MountPoint, instDir, y.Param); err == nil { + if out, err := executeGuestTemplate(*mount.MountPoint, instDir, y.User, y.Param); err == nil { mount.MountPoint = ptr.Of(out.String()) } else { logrus.WithError(err).Warnf("Couldn't process mount point %q as a template", *mount.MountPoint) @@ -811,17 +868,16 @@ func fixUpForPlainMode(y *LimaYAML) { y.TimeZone = ptr.Of("") } -func executeGuestTemplate(format, instDir string, param map[string]string) (bytes.Buffer, error) { +func executeGuestTemplate(format, instDir string, user User, param map[string]string) (bytes.Buffer, error) { tmpl, err := template.New("").Parse(format) if err == nil { - user, _ := osutil.LimaUser(false) name := filepath.Base(instDir) data := map[string]interface{}{ - "Home": fmt.Sprintf("/home/%s.linux", user.Username), "Name": name, "Hostname": identifierutil.HostnameFromInstName(name), // TODO: support customization - "UID": user.Uid, - "User": user.Username, + "UID": *user.UID, + "User": *user.Username, + "Home": *user.HomeDir, "Param": param, } var out bytes.Buffer @@ -835,16 +891,14 @@ func executeGuestTemplate(format, instDir string, param map[string]string) (byte func executeHostTemplate(format, instDir string, param map[string]string) (bytes.Buffer, error) { tmpl, err := template.New("").Parse(format) if err == nil { - user, _ := osutil.LimaUser(false) - home, _ := os.UserHomeDir() limaHome, _ := dirnames.LimaDir() data := map[string]interface{}{ "Dir": instDir, - "Home": home, "Name": filepath.Base(instDir), // TODO: add hostname fields for the host and the guest - "UID": user.Uid, - "User": user.Username, + "UID": currentUser.Uid, + "User": currentUser.Username, + "Home": userHomeDir, "Param": param, "Instance": filepath.Base(instDir), // DEPRECATED, use `{{.Name}}` @@ -858,7 +912,7 @@ func executeHostTemplate(format, instDir string, param map[string]string) (bytes return bytes.Buffer{}, err } -func FillPortForwardDefaults(rule *PortForward, instDir string, param map[string]string) { +func FillPortForwardDefaults(rule *PortForward, instDir string, user User, param map[string]string) { if rule.Proto == "" { rule.Proto = ProtoTCP } @@ -890,7 +944,7 @@ func FillPortForwardDefaults(rule *PortForward, instDir string, param map[string } } if rule.GuestSocket != "" { - if out, err := executeGuestTemplate(rule.GuestSocket, instDir, param); err == nil { + if out, err := executeGuestTemplate(rule.GuestSocket, instDir, user, param); err == nil { rule.GuestSocket = out.String() } else { logrus.WithError(err).Warnf("Couldn't process guestSocket %q as a template", rule.GuestSocket) @@ -908,9 +962,9 @@ func FillPortForwardDefaults(rule *PortForward, instDir string, param map[string } } -func FillCopyToHostDefaults(rule *CopyToHost, instDir string, param map[string]string) { +func FillCopyToHostDefaults(rule *CopyToHost, instDir string, user User, param map[string]string) { if rule.GuestFile != "" { - if out, err := executeGuestTemplate(rule.GuestFile, instDir, param); err == nil { + if out, err := executeGuestTemplate(rule.GuestFile, instDir, user, param); err == nil { rule.GuestFile = out.String() } else { logrus.WithError(err).Warnf("Couldn't process guest %q as a template", rule.GuestFile) diff --git a/pkg/limayaml/defaults_test.go b/pkg/limayaml/defaults_test.go index 848dc8036efe..90846283d46c 100644 --- a/pkg/limayaml/defaults_test.go +++ b/pkg/limayaml/defaults_test.go @@ -54,10 +54,12 @@ func TestFillDefault(t *testing.T) { assert.NilError(t, err) limaHome, err := dirnames.LimaDir() assert.NilError(t, err) - user, err := osutil.LimaUser(false) - assert.NilError(t, err) + user := osutil.LimaUser("0.0.0", false) + if runtime.GOOS != "windows" { + // manual template expansion for "/home/{{.User}}.linux" (done by FillDefault) + user.HomeDir = fmt.Sprintf("/home/%s.linux", user.Username) + } - guestHome := fmt.Sprintf("/home/%s.linux", user.Username) instName := "instance" instDir := filepath.Join(limaHome, instName) filePath := filepath.Join(instDir, filenames.LimaYAML) @@ -108,6 +110,11 @@ func TestFillDefault(t *testing.T) { }, NestedVirtualization: ptr.Of(false), Plain: ptr.Of(false), + User: User{ + Username: ptr.Of(user.Username), + HomeDir: ptr.Of(user.HomeDir), + UID: ptr.Of(user.Uid), + }, } defaultPortForward := PortForward{ @@ -267,11 +274,11 @@ func TestFillDefault(t *testing.T) { expect.PortForwards[2].HostPort = 8888 expect.PortForwards[2].HostPortRange = [2]int{8888, 8888} - expect.PortForwards[3].GuestSocket = fmt.Sprintf("%s | %s | %s | %s", guestHome, user.Uid, user.Username, y.Param["ONE"]) - expect.PortForwards[3].HostSocket = fmt.Sprintf("%s | %s | %s | %s | %s | %s", hostHome, instDir, instName, user.Uid, user.Username, y.Param["ONE"]) + expect.PortForwards[3].GuestSocket = fmt.Sprintf("%s | %s | %s | %s", user.HomeDir, user.Uid, user.Username, y.Param["ONE"]) + expect.PortForwards[3].HostSocket = fmt.Sprintf("%s | %s | %s | %s | %s | %s", hostHome, instDir, instName, currentUser.Uid, currentUser.Username, y.Param["ONE"]) - expect.CopyToHost[0].GuestFile = fmt.Sprintf("%s | %s | %s | %s", guestHome, user.Uid, user.Username, y.Param["ONE"]) - expect.CopyToHost[0].HostFile = fmt.Sprintf("%s | %s | %s | %s | %s | %s", hostHome, instDir, instName, user.Uid, user.Username, y.Param["ONE"]) + expect.CopyToHost[0].GuestFile = fmt.Sprintf("%s | %s | %s | %s", user.HomeDir, user.Uid, user.Username, y.Param["ONE"]) + expect.CopyToHost[0].HostFile = fmt.Sprintf("%s | %s | %s | %s | %s | %s", hostHome, instDir, instName, currentUser.Uid, currentUser.Username, y.Param["ONE"]) expect.Env = y.Env @@ -296,7 +303,7 @@ func TestFillDefault(t *testing.T) { expect.NestedVirtualization = ptr.Of(false) - FillDefault(&y, &LimaYAML{}, &LimaYAML{}, filePath) + FillDefault(&y, &LimaYAML{}, &LimaYAML{}, filePath, false) assert.DeepEqual(t, &y, &expect, opts...) filledDefaults := y @@ -424,6 +431,11 @@ func TestFillDefault(t *testing.T) { BinFmt: ptr.Of(true), }, NestedVirtualization: ptr.Of(true), + User: User{ + Username: ptr.Of("xxx"), + HomeDir: ptr.Of("/tmp"), + UID: ptr.Of("8080"), + }, } expect = d @@ -464,7 +476,7 @@ func TestFillDefault(t *testing.T) { expect.Plain = ptr.Of(false) y = LimaYAML{} - FillDefault(&y, &d, &LimaYAML{}, filePath) + FillDefault(&y, &d, &LimaYAML{}, filePath, false) assert.DeepEqual(t, &y, &expect, opts...) dExpect := expect @@ -475,6 +487,7 @@ func TestFillDefault(t *testing.T) { y = filledDefaults y.DNS = []net.IP{net.ParseIP("8.8.8.8")} y.AdditionalDisks = []Disk{{Name: "overridden"}} + y.User.HomeDir = ptr.Of("/root") expect = y @@ -502,7 +515,7 @@ func TestFillDefault(t *testing.T) { t.Logf("d.vmType=%q, y.vmType=%q, expect.vmType=%q", *d.VMType, *y.VMType, *expect.VMType) - FillDefault(&y, &d, &LimaYAML{}, filePath) + FillDefault(&y, &d, &LimaYAML{}, filePath, false) assert.DeepEqual(t, &y, &expect, opts...) // ------------------------------------------------------------------------------------ @@ -639,6 +652,11 @@ func TestFillDefault(t *testing.T) { BinFmt: ptr.Of(false), }, NestedVirtualization: ptr.Of(false), + User: User{ + Username: ptr.Of("foo"), + HomeDir: ptr.Of("/override"), + UID: ptr.Of("1122"), + }, } y = filledDefaults @@ -697,7 +715,7 @@ func TestFillDefault(t *testing.T) { expect.NestedVirtualization = ptr.Of(false) - FillDefault(&y, &d, &o, filePath) + FillDefault(&y, &d, &o, filePath, false) assert.DeepEqual(t, &y, &expect, opts...) } diff --git a/pkg/limayaml/limayaml.go b/pkg/limayaml/limayaml.go index dc27723e5126..d680354fd412 100644 --- a/pkg/limayaml/limayaml.go +++ b/pkg/limayaml/limayaml.go @@ -47,6 +47,7 @@ type LimaYAML struct { Plain *bool `yaml:"plain,omitempty" json:"plain,omitempty" jsonschema:"nullable"` TimeZone *string `yaml:"timezone,omitempty" json:"timezone,omitempty" jsonschema:"nullable"` NestedVirtualization *bool `yaml:"nestedVirtualization,omitempty" json:"nestedVirtualization,omitempty" jsonschema:"nullable"` + User User `yaml:"user,omitempty" json:"user,omitempty"` } type ( @@ -83,6 +84,13 @@ var ( VMTypes = []VMType{QEMU, VZ, WSL2} ) +type User struct { + Username *string `yaml:"name,omitempty" json:"name,omitempty" jsonschema:"nullable"` + Name *string `yaml:"gecos,omitempty" json:"gecos,omitempty" jsonschema:"nullable"` + HomeDir *string `yaml:"home,omitempty" json:"home,omitempty" jsonschema:"nullable"` + UID *string `yaml:"uid,omitempty" json:"uid,omitempty" jsonschema:"nullable"` +} + type VMOpts struct { QEMU QEMUOpts `yaml:"qemu,omitempty" json:"qemu,omitempty"` } diff --git a/pkg/limayaml/load.go b/pkg/limayaml/load.go index 511ae2b63073..58a47a0a564b 100644 --- a/pkg/limayaml/load.go +++ b/pkg/limayaml/load.go @@ -15,6 +15,17 @@ import ( // // Load does not validate. Use Validate for validation. func Load(b []byte, filePath string) (*LimaYAML, error) { + return load(b, filePath, false) +} + +// LoadWithWarnings will call FillDefaults with warnings enabled (e.g. when +// the username is not valid on Linux and must be replaced by "Lima"). +// It is called when creating or editing an instance. +func LoadWithWarnings(b []byte, filePath string) (*LimaYAML, error) { + return load(b, filePath, true) +} + +func load(b []byte, filePath string, warn bool) (*LimaYAML, error) { var y, d, o LimaYAML if err := Unmarshal(b, &y, fmt.Sprintf("main file %q", filePath)); err != nil { @@ -52,6 +63,6 @@ func Load(b []byte, filePath string) (*LimaYAML, error) { return nil, err } - FillDefault(&y, &d, &o, filePath) + FillDefault(&y, &d, &o, filePath, warn) return &y, nil } diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go index 6c1a5d6d6580..60b5c276302f 100644 --- a/pkg/limayaml/validate.go +++ b/pkg/limayaml/validate.go @@ -136,13 +136,6 @@ func Validate(y *LimaYAML, warn bool) error { return fmt.Errorf("field `memory` has an invalid value: %w", err) } - u, err := osutil.LimaUser(false) - if err != nil { - return fmt.Errorf("internal error (not an error of YAML): %w", err) - } - // reservedHome is the home directory defined in "cidata.iso:/user-data" - reservedHome := fmt.Sprintf("/home/%s.linux", u.Username) - for i, f := range y.Mounts { if !filepath.IsAbs(f.Location) && !strings.HasPrefix(f.Location, "~") { return fmt.Errorf("field `mounts[%d].location` must be an absolute path, got %q", @@ -155,8 +148,9 @@ func Validate(y *LimaYAML, warn bool) error { switch loc { case "/", "/bin", "/dev", "/etc", "/home", "/opt", "/sbin", "/tmp", "/usr", "/var": return fmt.Errorf("field `mounts[%d].location` must not be a system path such as /etc or /usr", i) - case reservedHome: - return fmt.Errorf("field `mounts[%d].location` is internally reserved", i) + // home directory defined in "cidata.iso:/user-data" + case *y.User.HomeDir: + return fmt.Errorf("field `mounts[%d].location` is the reserved internal home directory", i) } st, err := os.Stat(loc) diff --git a/pkg/must/must.go b/pkg/must/must.go new file mode 100644 index 000000000000..8dbada99f88b --- /dev/null +++ b/pkg/must/must.go @@ -0,0 +1,8 @@ +package must + +func Must[T any](obj T, err error) T { + if err != nil { + panic(err) + } + return obj +} diff --git a/pkg/osutil/user.go b/pkg/osutil/user.go index 45b2b7e1ab47..d1883c59d6ed 100644 --- a/pkg/osutil/user.go +++ b/pkg/osutil/user.go @@ -11,6 +11,8 @@ import ( "strings" "sync" + . "github.com/lima-vm/lima/pkg/must" + "github.com/lima-vm/lima/pkg/version/versionutil" "github.com/sirupsen/logrus" ) @@ -41,10 +43,6 @@ var regexUsername = regexp.MustCompile("^[a-z_][a-z0-9_-]*$") // regexPath detects valid Linux path. var regexPath = regexp.MustCompile("^[/a-zA-Z0-9_-]+$") -func ValidateUsername(name string) bool { - return regexUsername.MatchString(name) -} - func LookupUser(name string) (User, error) { if users == nil { users = make(map[string]User) @@ -95,11 +93,89 @@ const ( fallbackGid = 1000 ) -var cache struct { - sync.Once - u *user.User - err error +var currentUser = Must(user.Current()) + +var ( + once = new(sync.Once) + limaUser *user.User warnings []string +) + +func LimaUser(limaVersion string, warn bool) *user.User { + once.Do(func() { + limaUser = currentUser + if !regexUsername.MatchString(limaUser.Username) { + warning := fmt.Sprintf("local username %q is not a valid Linux username (must match %q); using %q instead", + limaUser.Username, regexUsername.String(), fallbackUser) + warnings = append(warnings, warning) + limaUser.Username = fallbackUser + } + if runtime.GOOS != "windows" { + limaUser.HomeDir = "/home/{{.User}}.linux" + } else { + idu, err := call([]string{"id", "-u"}) + if err != nil { + logrus.Debug(err) + } + uid, err := parseUidGid(idu) + if err != nil { + uid = fallbackUid + } + if _, err := parseUidGid(limaUser.Uid); err != nil { + warning := fmt.Sprintf("local uid %q is not a valid Linux uid (must be integer); using %d uid instead", + limaUser.Uid, uid) + warnings = append(warnings, warning) + limaUser.Uid = formatUidGid(uid) + } + idg, err := call([]string{"id", "-g"}) + if err != nil { + logrus.Debug(err) + } + gid, err := parseUidGid(idg) + if err != nil { + gid = fallbackGid + } + if _, err := parseUidGid(limaUser.Gid); err != nil { + warning := fmt.Sprintf("local gid %q is not a valid Linux gid (must be integer); using %d gid instead", + limaUser.Gid, gid) + warnings = append(warnings, warning) + limaUser.Gid = formatUidGid(gid) + } + home, err := call([]string{"cygpath", limaUser.HomeDir}) + if err != nil { + logrus.Debug(err) + } + if home == "" { + drive := filepath.VolumeName(limaUser.HomeDir) + home = filepath.ToSlash(limaUser.HomeDir) + // replace C: with /c + prefix := strings.ToLower(fmt.Sprintf("/%c", drive[0])) + home = strings.Replace(home, drive, prefix, 1) + } + if !regexPath.MatchString(limaUser.HomeDir) { + warning := fmt.Sprintf("local home %q is not a valid Linux path (must match %q); using %q home instead", + limaUser.HomeDir, regexPath.String(), home) + warnings = append(warnings, warning) + limaUser.HomeDir = home + } + } + }) + if warn { + for _, warning := range warnings { + logrus.Warn(warning) + } + } + // Make sure we return a pointer to a COPY of limaUser + u := *limaUser + if versionutil.GreaterEqual(limaVersion, "1.0.0") { + if u.Username == "admin" { + if warn { + logrus.Warnf("local username %q is reserved; using %q instead", u.Username, fallbackUser) + } + u.Username = fallbackUser + } + } + return &u } func call(args []string) (string, error) { @@ -111,74 +187,6 @@ func call(args []string) (string, error) { return strings.TrimSpace(string(out)), nil } -func LimaUser(warn bool) (*user.User, error) { - cache.warnings = []string{} - cache.Do(func() { - cache.u, cache.err = user.Current() - if cache.err == nil { - if !ValidateUsername(cache.u.Username) { - warning := fmt.Sprintf("local user %q is not a valid Linux username (must match %q); using %q username instead", - cache.u.Username, regexUsername.String(), fallbackUser) - cache.warnings = append(cache.warnings, warning) - cache.u.Username = fallbackUser - } - if runtime.GOOS == "windows" { - idu, err := call([]string{"id", "-u"}) - if err != nil { - logrus.Debug(err) - } - uid, err := parseUidGid(idu) - if err != nil { - uid = fallbackUid - } - if _, err := parseUidGid(cache.u.Uid); err != nil { - warning := fmt.Sprintf("local uid %q is not a valid Linux uid (must be integer); using %d uid instead", - cache.u.Uid, uid) - cache.warnings = append(cache.warnings, warning) - cache.u.Uid = formatUidGid(uid) - } - idg, err := call([]string{"id", "-g"}) - if err != nil { - logrus.Debug(err) - } - gid, err := parseUidGid(idg) - if err != nil { - gid = fallbackGid - } - if _, err := parseUidGid(cache.u.Gid); err != nil { - warning := fmt.Sprintf("local gid %q is not a valid Linux gid (must be integer); using %d gid instead", - cache.u.Gid, gid) - cache.warnings = append(cache.warnings, warning) - cache.u.Gid = formatUidGid(gid) - } - home, err := call([]string{"cygpath", cache.u.HomeDir}) - if err != nil { - logrus.Debug(err) - } - if home == "" { - drive := filepath.VolumeName(cache.u.HomeDir) - home = filepath.ToSlash(cache.u.HomeDir) - // replace C: with /c - prefix := strings.ToLower(fmt.Sprintf("/%c", drive[0])) - home = strings.Replace(home, drive, prefix, 1) - } - if !regexPath.MatchString(cache.u.HomeDir) { - warning := fmt.Sprintf("local home %q is not a valid Linux path (must match %q); using %q home instead", - cache.u.HomeDir, regexPath.String(), home) - cache.warnings = append(cache.warnings, warning) - cache.u.HomeDir = home - } - } - } - }) - if warn && len(cache.warnings) > 0 { - for _, warning := range cache.warnings { - logrus.Warn(warning) - } - } - return cache.u, cache.err -} - // parseUidGid converts string value to Linux uid or gid. func parseUidGid(uidOrGid string) (uint32, error) { res, err := strconv.ParseUint(uidOrGid, 10, 32) diff --git a/pkg/osutil/user_test.go b/pkg/osutil/user_test.go index e68375acddba..fe1aa05428aa 100644 --- a/pkg/osutil/user_test.go +++ b/pkg/osutil/user_test.go @@ -3,40 +3,57 @@ package osutil import ( "path" "strconv" + "sync" "testing" "gotest.tools/v3/assert" ) -func TestLimaUserWarn(t *testing.T) { - _, err := LimaUser(true) - assert.NilError(t, err) +const limaVersion = "1.0.0" + +// "admin" is a reserved username in 1.0.0 +func TestLimaUserAdminNew(t *testing.T) { + currentUser.Username = "admin" + once = new(sync.Once) + user := LimaUser(limaVersion, false) + assert.Equal(t, user.Username, fallbackUser) } -func TestLimaUsername(t *testing.T) { - user, err := LimaUser(false) - assert.NilError(t, err) - // check for reasonable unix user name - assert.Assert(t, ValidateUsername(user.Username), user.Username) +// "admin" is allowed in older instances +func TestLimaUserAdminOld(t *testing.T) { + currentUser.Username = "admin" + once = new(sync.Once) + user := LimaUser("0.23.0", false) + assert.Equal(t, user.Username, "admin") +} + +func TestLimaUserInvalid(t *testing.T) { + currentUser.Username = "use@example.com" + once = new(sync.Once) + user := LimaUser(limaVersion, false) + assert.Equal(t, user.Username, fallbackUser) } func TestLimaUserUid(t *testing.T) { - user, err := LimaUser(false) - assert.NilError(t, err) - _, err = strconv.Atoi(user.Uid) + currentUser.Username = fallbackUser + once = new(sync.Once) + user := LimaUser(limaVersion, false) + _, err := strconv.Atoi(user.Uid) assert.NilError(t, err) } func TestLimaUserGid(t *testing.T) { - user, err := LimaUser(false) - assert.NilError(t, err) - _, err = strconv.Atoi(user.Gid) + currentUser.Username = fallbackUser + once = new(sync.Once) + user := LimaUser(limaVersion, false) + _, err := strconv.Atoi(user.Gid) assert.NilError(t, err) } func TestLimaHomeDir(t *testing.T) { - user, err := LimaUser(false) - assert.NilError(t, err) + currentUser.Username = fallbackUser + once = new(sync.Once) + user := LimaUser(limaVersion, false) // check for absolute unix path (/home) assert.Assert(t, path.IsAbs(user.HomeDir), user.HomeDir) } diff --git a/pkg/sshutil/sshutil.go b/pkg/sshutil/sshutil.go index 302ebfdb794e..af141e660a69 100644 --- a/pkg/sshutil/sshutil.go +++ b/pkg/sshutil/sshutil.go @@ -223,15 +223,11 @@ func CommonOpts(useDotSSH bool) ([]string, error) { } // SSHOpts adds the following options to CommonOptions: User, ControlMaster, ControlPath, ControlPersist. -func SSHOpts(instDir string, useDotSSH, forwardAgent, forwardX11, forwardX11Trusted bool) ([]string, error) { +func SSHOpts(instDir, username string, useDotSSH, 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) } - u, err := osutil.LimaUser(false) - if err != nil { - return nil, err - } opts, err := CommonOpts(useDotSSH) if err != nil { return nil, err @@ -242,7 +238,7 @@ func SSHOpts(instDir string, useDotSSH, forwardAgent, forwardX11, forwardX11Trus 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) + fmt.Sprintf("User=%s", username), // guest and host have the same username, but we should specify the username explicitly (#85) "ControlMaster=auto", controlPath, "ControlPersist=yes", diff --git a/templates/default.yaml b/templates/default.yaml index a11f51d8a6ad..cbdc051d1c2e 100644 --- a/templates/default.yaml +++ b/templates/default.yaml @@ -56,7 +56,7 @@ disk: null # Expose host directories to the guest, the mount point might be accessible from all UIDs in the guest # "location" can use these template variables: {{.Home}}, {{.Dir}}, {{.Name}}, {{.UID}}, {{.User}}, and {{.Param.Key}}. -# "mountPoint" can use these template variables: {{.Home}}, {{.Name}}, {{.Hostname}}, {{.UID}}, {{.User}}, and {{.Param.Key}} +# "mountPoint" can use these template variables: {{.Home}}, {{.Name}}, {{.Hostname}}, {{.UID}}, {{.User}}, and {{.Param.Key}}. # 🟢 Builtin default: [] (Mount nothing) # 🔵 This file: Mount the home as read-only, /tmp/lima as writable mounts: @@ -210,7 +210,7 @@ containerd: # Provisioning scripts need to be idempotent because they might be called # multiple times, e.g. when the host VM is being restarted. -# The scripts can use the following template variables: {{.Home}}, {{.Name}}, {{.Hostname}}, {{.UID}}, {{.User}}, and {{.Param.Key}} +# The scripts can use the following template variables: {{.Home}}, {{.Name}}, {{.Hostname}}, {{.UID}}, {{.User}}, and {{.Param.Key}}. # 🟢 Builtin default: [] # provision: # # `system` is executed with root privileges @@ -253,7 +253,7 @@ containerd: # Probe scripts to check readiness. # The scripts run in user mode. They must start with a '#!' line. -# The scripts can use the following template variables: {{.Home}}, {{.Name}}, {{.Hostname}}, {{.UID}}, {{.User}}, and {{.Param.Key}} +# The scripts can use the following template variables: {{.Home}}, {{.Name}}, {{.Hostname}}, {{.UID}}, {{.User}}, and {{.Param.Key}}. # 🟢 Builtin default: [] # probes: # # Only `readiness` probes are supported right now. @@ -279,6 +279,19 @@ containerd: # 🟢 Builtin default: not set minimumLimaVersion: null +# User to be used inside the VM +user: + # User name. An explicitly specified username is not validated by Lima. + # 🟢 Builtin default: same as the host username, if it is a valid Linux username, otherwise "lima" + name: null + # Numeric user id. It is not currently possible to specify a group id. + # 🟢 Builtin default: same as the host user id of the current user (NOT a lookup of the specified "username"). + uid: null + # Home directory inside the VM, NOT the mounted home directory of the host. + # It can use the following template variables: {{.Name}}, {{.Hostname}}, {{.UID}}, {{.User}}, and {{.Param.Key}}. + # 🟢 Builtin default: "/home/{{.User}}.linux" + home: null + vmOpts: qemu: # Minimum version of QEMU required to create an instance of this template.