Skip to content

Commit 4558b5a

Browse files
committed
Implement vde_vmnet management
Lima uses ~/.lima/_config/networks.yaml to define managed networks for instances. By default "shared", "bridged", and "host" are defined. They can be referenced from `lima.yaml` via e.g. `lima://shared`. The networks will be automatically started by lime as long as any instance referencing them are running, and once the last instance using a network is stopped, the network is brought down as well. A new command `limactl sudoers` can be used to write a sudoers file to allow lima to start/stop the daemons via `sudo`. Lima does not support prompting for a sudo password; either the sudoers file must be maintained, or the user must have password-less sudo configured. Every time the `networks.yaml` configuration is changed, the sudoers file must be regenerated to match. Signed-off-by: Jan Dubois <[email protected]>
1 parent 06e35a6 commit 4558b5a

File tree

16 files changed

+575
-21
lines changed

16 files changed

+575
-21
lines changed

cmd/limactl/delete.go

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"fmt"
66
"os"
77

8+
"github.com/lima-vm/lima/pkg/networks"
89
"github.com/lima-vm/lima/pkg/store"
910
"github.com/sirupsen/logrus"
1011
"github.com/spf13/cobra"
@@ -42,7 +43,7 @@ func deleteAction(cmd *cobra.Command, args []string) error {
4243
}
4344
logrus.Infof("Deleted %q (%q)", instName, inst.Dir)
4445
}
45-
return nil
46+
return networks.Reconcile(cmd.Context(), "")
4647
}
4748

4849
func deleteInstance(inst *store.Instance, force bool) error {

cmd/limactl/main.go

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import (
55
"os"
66
"strings"
77

8+
"github.com/lima-vm/lima/pkg/store"
89
"github.com/lima-vm/lima/pkg/version"
910
"github.com/sirupsen/logrus"
1011
"github.com/spf13/cobra"
@@ -38,6 +39,11 @@ func newApp() *cobra.Command {
3839
if os.Geteuid() == 0 {
3940
return errors.New("must not run as the root")
4041
}
42+
// Make sure either $HOME or $LIMA_HOME is defined, so we don't need
43+
// to check for errors later
44+
if _, err := store.LimaDir(); err != nil {
45+
return err
46+
}
4147
return nil
4248
}
4349
rootCmd.AddCommand(
@@ -48,6 +54,7 @@ func newApp() *cobra.Command {
4854
newListCommand(),
4955
newDeleteCommand(),
5056
newValidateCommand(),
57+
newSudoersCommand(),
5158
newPruneCommand(),
5259
newHostagentCommand(),
5360
)

cmd/limactl/start.go

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ import (
1212
"github.com/AlecAivazis/survey/v2"
1313
"github.com/containerd/containerd/identifiers"
1414
"github.com/lima-vm/lima/pkg/limayaml"
15+
"github.com/lima-vm/lima/pkg/networks"
1516
"github.com/lima-vm/lima/pkg/osutil"
1617
"github.com/lima-vm/lima/pkg/start"
1718
"github.com/lima-vm/lima/pkg/store"
@@ -228,6 +229,10 @@ func startAction(cmd *cobra.Command, args []string) error {
228229
logrus.Warnf("expected status %q, got %q", store.StatusStopped, inst.Status)
229230
}
230231
ctx := cmd.Context()
232+
err = networks.Reconcile(ctx, inst.Name)
233+
if err != nil {
234+
return err
235+
}
231236
return start.Start(ctx, inst)
232237
}
233238

cmd/limactl/stop.go

Lines changed: 8 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -11,6 +11,7 @@ import (
1111
"time"
1212

1313
hostagentapi "github.com/lima-vm/lima/pkg/hostagent/api"
14+
"github.com/lima-vm/lima/pkg/networks"
1415
"github.com/lima-vm/lima/pkg/store"
1516
"github.com/lima-vm/lima/pkg/store/filenames"
1617
"github.com/sirupsen/logrus"
@@ -47,10 +48,14 @@ func stopAction(cmd *cobra.Command, args []string) error {
4748
}
4849
if force {
4950
stopInstanceForcibly(inst)
50-
return nil
51+
} else {
52+
err = stopInstanceGracefully(inst)
5153
}
52-
53-
return stopInstanceGracefully(inst)
54+
// TODO: should we also reconcile networks if graceful stop returned an error?
55+
if err == nil {
56+
err = networks.Reconcile(cmd.Context(), "")
57+
}
58+
return err
5459
}
5560

5661
func stopInstanceGracefully(inst *store.Instance) error {

cmd/limactl/sudoers.go

Lines changed: 56 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,56 @@
1+
package main
2+
3+
import (
4+
"errors"
5+
"fmt"
6+
"github.com/lima-vm/lima/pkg/networks"
7+
"github.com/spf13/cobra"
8+
)
9+
10+
func newSudoersCommand() *cobra.Command {
11+
sudoersCommand := &cobra.Command{
12+
Use: "sudoers [SUDOERSFILE]",
13+
Short: "Generate /etc/sudoers.d/lima file.",
14+
Args: cobra.MaximumNArgs(1),
15+
RunE: sudoersAction,
16+
}
17+
sudoersCommand.Flags().Bool("check", false,
18+
"check that the sudoers file is up-to-date with $LIMA_HOME/_config/networks.yaml")
19+
return sudoersCommand
20+
}
21+
22+
func sudoersAction(cmd *cobra.Command, args []string) error {
23+
check, err := cmd.Flags().GetBool("check")
24+
if err != nil {
25+
return err
26+
}
27+
if check {
28+
var file string
29+
switch len(args) {
30+
case 0:
31+
config, err := networks.Config()
32+
if err != nil {
33+
return err
34+
}
35+
file = config.Paths.Sudoers
36+
if file == "" {
37+
return errors.New("no sudoers file defined in ~/.lima/_config/networks.yaml")
38+
}
39+
case 1:
40+
file = args[0]
41+
default:
42+
return errors.New("can check only a single sudoers file")
43+
}
44+
if err := networks.CheckSudoers(file); err != nil {
45+
return err
46+
}
47+
fmt.Printf("%q is up-to-date\n", file)
48+
return nil
49+
}
50+
sudoers, err := networks.Sudoers()
51+
if err != nil {
52+
return err
53+
}
54+
fmt.Print(sudoers)
55+
return nil
56+
}

pkg/limayaml/validate.go

Lines changed: 8 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -177,7 +177,14 @@ func validateNetwork(yNetwork Network) error {
177177
// The field is called VDE.VNL in anticipation of QEMU upgrading VDE2 to VDEplug4,
178178
// but right now the only valid value on macOS is a path to the vde_switch socket directory,
179179
// optionally with vde:// prefix.
180-
if !strings.Contains(vde.VNL, "://") || strings.HasPrefix(vde.VNL, "vde://") {
180+
// TODO: use networks.LimaSchema after solving circular dependency
181+
if strings.HasPrefix(vde.VNL, "lima://") {
182+
// TODO: validate network names? Problem is we can't use "networks" or "store" packages here...
183+
//name := strings.TrimPrefix(vde.VNL, networks.LimaScheme)
184+
//if _, ok := networkConfig.Networks[name]; !ok {
185+
// return fmt.Errorf("field `%s.vnl` references undefined network %q", field, name)
186+
//}
187+
} else if !strings.Contains(vde.VNL, "://") || strings.HasPrefix(vde.VNL, "vde://") {
181188
vdeSwitch := strings.TrimPrefix(vde.VNL, "vde://")
182189
if fi, err := os.Stat(vdeSwitch); err != nil {
183190
// negligible when the instance is stopped

pkg/networks/commands.go

Lines changed: 84 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,84 @@
1+
package networks
2+
3+
import (
4+
"fmt"
5+
"github.com/lima-vm/lima/pkg/store"
6+
)
7+
8+
const (
9+
Switch = "switch"
10+
VMNet = "vmnet"
11+
)
12+
13+
// Commands in `sudoers` cannot use quotes, so all arguments are printed via "%s"
14+
// and not "%q". config.Paths.* entries must not include any whitespace!
15+
16+
func (config *NetworksConfig) Check(name string) error {
17+
if _, ok := config.Networks[name]; ok {
18+
return nil
19+
}
20+
return fmt.Errorf("network %q is not defined", name)
21+
}
22+
23+
func (config *NetworksConfig) VDESock(name string) string {
24+
return fmt.Sprintf("%s/%s.ctl", config.Paths.VarRun, name)
25+
}
26+
27+
func (config *NetworksConfig) PIDFile(name, daemon string) string {
28+
return fmt.Sprintf("%s/%s_%s.pid", config.Paths.VarRun, name, daemon)
29+
}
30+
31+
func (config *NetworksConfig) LogFile(name, daemon, stream string) string {
32+
networksDir, _ := store.LimaNetworksDir()
33+
return fmt.Sprintf("%s/%s_%s.%s.log", networksDir, name, daemon, stream)
34+
}
35+
36+
func (config *NetworksConfig) DaemonUser(daemon string) string {
37+
switch daemon {
38+
case Switch:
39+
return "daemon"
40+
case VMNet:
41+
return "root"
42+
}
43+
panic("daemonuser")
44+
}
45+
46+
func (config *NetworksConfig) DaemonGroup(daemon string) string {
47+
switch daemon {
48+
case Switch:
49+
return config.Group
50+
case VMNet:
51+
return "wheel"
52+
}
53+
panic("daemongroup")
54+
}
55+
56+
func (config *NetworksConfig) MkdirCmd() string {
57+
return fmt.Sprintf("/bin/mkdir -m 775 -p %s", config.Paths.VarRun)
58+
}
59+
60+
func (config *NetworksConfig) StartCmd(name, daemon string) string {
61+
var cmd string
62+
switch daemon {
63+
case Switch:
64+
cmd = fmt.Sprintf("%s --pidfile=%s --sock=%s --group=%s --dirmode=0770 --nostdin",
65+
config.Paths.VDESwitch, config.PIDFile(name, Switch), config.VDESock(name), config.Group)
66+
case VMNet:
67+
nw := config.Networks[name]
68+
cmd = fmt.Sprintf("%s --pidfile=%s --vde-group=%s --vmnet-mode=%s",
69+
config.Paths.VDEVMNet, config.PIDFile(name, VMNet), config.Group, nw.Mode)
70+
switch nw.Mode {
71+
case ModeBridged:
72+
cmd += fmt.Sprintf(" --vmnet-interface=%s", nw.Interface)
73+
case ModeHost, ModeShared:
74+
cmd += fmt.Sprintf(" --vmnet-gateway=%s --vmnet-dhcp-end=%s --vmnet-mask=%s",
75+
nw.Gateway, nw.DHCPEnd, nw.NetMask)
76+
}
77+
cmd += " " + config.VDESock(name)
78+
}
79+
return cmd
80+
}
81+
82+
func (config *NetworksConfig) StopCmd(name, daemon string) string {
83+
return fmt.Sprintf("/usr/bin/pkill -F %s", config.PIDFile(name, daemon))
84+
}

pkg/networks/config.go

Lines changed: 81 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,81 @@
1+
package networks
2+
3+
import (
4+
_ "embed"
5+
"errors"
6+
"fmt"
7+
"gopkg.in/yaml.v2"
8+
"os"
9+
"path/filepath"
10+
"strings"
11+
"sync"
12+
13+
"github.com/lima-vm/lima/pkg/store"
14+
"github.com/lima-vm/lima/pkg/store/filenames"
15+
)
16+
17+
//go:embed networks.yaml
18+
var defaultConfig []byte
19+
20+
var cache struct {
21+
sync.Once
22+
config NetworksConfig
23+
err error
24+
}
25+
26+
// load() loads the _config/networks.yaml file.
27+
func load() {
28+
cache.Do(func() {
29+
var configDir string
30+
configDir, cache.err = store.LimaConfigDir()
31+
if cache.err != nil {
32+
return
33+
}
34+
configFile := filepath.Join(configDir, filenames.NetworksConfig)
35+
_, cache.err = os.Stat(configFile)
36+
if cache.err != nil {
37+
if !errors.Is(cache.err, os.ErrNotExist) {
38+
return
39+
}
40+
cache.err = os.MkdirAll(configDir, 0700)
41+
if cache.err != nil {
42+
cache.err = fmt.Errorf("could not create %q directory: %w", configDir, cache.err)
43+
return
44+
}
45+
cache.err = os.WriteFile(configFile, defaultConfig, 0644)
46+
if cache.err != nil {
47+
return
48+
}
49+
}
50+
var b []byte
51+
b, cache.err = os.ReadFile(configFile)
52+
if cache.err != nil {
53+
return
54+
}
55+
cache.err = yaml.Unmarshal(b, &cache.config)
56+
if cache.err != nil {
57+
cache.err = fmt.Errorf("cannot parse %q: %w", configFile, cache.err)
58+
}
59+
})
60+
}
61+
62+
// Config returns the network config from the _config/networks.yaml file.
63+
func Config() (NetworksConfig, error) {
64+
load()
65+
return cache.config, cache.err
66+
}
67+
68+
func VDESock(name string) (string, error) {
69+
if strings.HasPrefix(name, LimaScheme) {
70+
load()
71+
if cache.err != nil {
72+
return "", cache.err
73+
}
74+
name = strings.TrimPrefix(name, LimaScheme)
75+
if err := cache.config.Check(name); err != nil {
76+
return "", err
77+
}
78+
return cache.config.VDESock(name), nil
79+
}
80+
return name, nil
81+
}

pkg/networks/networks.go

Lines changed: 34 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,34 @@
1+
package networks
2+
3+
import "net"
4+
5+
const (
6+
LimaScheme = "lima://"
7+
)
8+
9+
type NetworksConfig struct {
10+
Paths Paths `yaml:"paths"`
11+
Group string `yaml:"group,omitempty"` // default: "staff"
12+
Networks map[string]Network `yaml:"networks"`
13+
}
14+
15+
type Paths struct {
16+
VDESwitch string `yaml:"vdeSwitch"`
17+
VDEVMNet string `yaml:"vdeVMNet"`
18+
VarRun string `yaml:"varRun"`
19+
Sudoers string `yaml:"sudoers"`
20+
}
21+
22+
const (
23+
ModeHost = "host"
24+
ModeShared = "shared"
25+
ModeBridged = "bridged"
26+
)
27+
28+
type Network struct {
29+
Mode string `yaml:"mode"` // "host", "shared", or "bridged"
30+
Interface string `yaml:"interface,omitempty"` // only used by "bridged" networks
31+
Gateway net.IP `yaml:"gateway,omitempty"` // only used by "host" and "shared" networks
32+
DHCPEnd net.IP `yaml:"dhcpEnd,omitempty"` // default: same as Gateway, last byte is 254
33+
NetMask net.IP `yaml:"netmask,omitempty"` // default: 255.255.255.0
34+
}

pkg/networks/networks.yaml

Lines changed: 30 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,30 @@
1+
# Paths to vde executables. Because vde_vmnet is invoked via sudo it should be
2+
# installed where only root can modify/replace it. This means also none of the
3+
# parent directories should be writable by the user.
4+
#
5+
# The var_run directory also must not be writable by the user because it will
6+
# include the vde_vmnet pid files. Those will be terminated via sudo, so replacing
7+
# the pid files would allow killing of arbitrary privileged processes.
8+
paths:
9+
vdeSwitch: /opt/vde/bin/vde_switch
10+
vdeVMNet: /opt/vde/bin/vde_vmnet
11+
varRun: /var/run/lima
12+
sudoers: /etc/sudoers.d/lima
13+
14+
group: staff
15+
16+
networks:
17+
shared:
18+
mode: shared
19+
gateway: 192.168.105.1
20+
dhcpEnd: 192.168.105.254
21+
netmask: 255.255.255.0
22+
bridged:
23+
mode: bridged
24+
interface: en0
25+
# bridged mode doesn't have a gateway; dhcp is managed by outside network
26+
host:
27+
mode: host
28+
gateway: 192.168.106.1
29+
dhcpEnd: 192.168.106.254
30+
netmask: 255.255.255.0

0 commit comments

Comments
 (0)