diff --git a/cmd/limactl/completion.go b/cmd/limactl/completion.go index f69005b9d31..f68f463b5cc 100644 --- a/cmd/limactl/completion.go +++ b/cmd/limactl/completion.go @@ -4,10 +4,14 @@ package main import ( + "maps" + "net" + "slices" "strings" "github.com/spf13/cobra" + "github.com/lima-vm/lima/pkg/networks" "github.com/lima-vm/lima/pkg/store" "github.com/lima-vm/lima/pkg/templatestore" ) @@ -51,3 +55,24 @@ func bashCompleteDiskNames(_ *cobra.Command) ([]string, cobra.ShellCompDirective } return disks, cobra.ShellCompDirectiveNoFileComp } + +func bashCompleteNetworkNames(_ *cobra.Command) ([]string, cobra.ShellCompDirective) { + config, err := networks.LoadConfig() + if err != nil { + return nil, cobra.ShellCompDirectiveDefault + } + networks := slices.Sorted(maps.Keys(config.Networks)) + return networks, cobra.ShellCompDirectiveNoFileComp +} + +func bashFlagCompleteNetworkInterfaceNames(_ *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + intf, err := net.Interfaces() + if err != nil { + return nil, cobra.ShellCompDirectiveDefault + } + var intfNames []string + for _, f := range intf { + intfNames = append(intfNames, f.Name) + } + return intfNames, cobra.ShellCompDirectiveNoFileComp +} diff --git a/cmd/limactl/disk.go b/cmd/limactl/disk.go index 7f19b069c5d..f6b035b11d7 100644 --- a/cmd/limactl/disk.go +++ b/cmd/limactl/disk.go @@ -141,11 +141,11 @@ $ limactl disk list return diskListCommand } -func diskMatches(diskName string, disks []string) []string { +func nameMatches(nameName string, names []string) []string { matches := []string{} - for _, disk := range disks { - if disk == diskName { - matches = append(matches, disk) + for _, name := range names { + if name == nameName { + matches = append(matches, name) } } return matches @@ -165,7 +165,7 @@ func diskListAction(cmd *cobra.Command, args []string) error { disks := []string{} if len(args) > 0 { for _, arg := range args { - matches := diskMatches(arg, allDisks) + matches := nameMatches(arg, allDisks) if len(matches) > 0 { disks = append(disks, matches...) } else { diff --git a/cmd/limactl/main.go b/cmd/limactl/main.go index a006f0bb33f..acfa41074e2 100644 --- a/cmd/limactl/main.go +++ b/cmd/limactl/main.go @@ -189,6 +189,7 @@ func newApp() *cobra.Command { newRestartCommand(), newSudoersCommand(), newStartAtLoginCommand(), + newNetworkCommand(), ) return rootCmd diff --git a/cmd/limactl/network.go b/cmd/limactl/network.go new file mode 100644 index 00000000000..7fd32ff293a --- /dev/null +++ b/cmd/limactl/network.go @@ -0,0 +1,271 @@ +// SPDX-FileCopyrightText: Copyright The Lima Authors +// SPDX-License-Identifier: Apache-2.0 + +package main + +import ( + "encoding/json" + "errors" + "fmt" + "maps" + "net" + "os" + "slices" + "strings" + "text/tabwriter" + + "github.com/sirupsen/logrus" + "github.com/spf13/cobra" + + "github.com/lima-vm/lima/pkg/networks" + "github.com/lima-vm/lima/pkg/yqutil" +) + +const networkCreateExample = ` Create a network: + $ limactl network create foo --gateway 192.168.42.1/24 + + Connect VM instances to the newly created network: + $ limactl create --network lima:foo --name vm1 + $ limactl create --network lima:foo --name vm2 +` + +func newNetworkCommand() *cobra.Command { + networkCommand := &cobra.Command{ + Use: "network", + Short: "Lima network management", + Example: networkCreateExample, + GroupID: advancedCommand, + } + networkCommand.AddCommand( + newNetworkListCommand(), + newNetworkCreateCommand(), + newNetworkDeleteCommand(), + ) + return networkCommand +} + +func newNetworkListCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "list", + Short: "List networks", + Aliases: []string{"ls"}, + Args: WrapArgsError(cobra.ArbitraryArgs), + RunE: networkListAction, + ValidArgsFunction: networkBashComplete, + } + flags := cmd.Flags() + flags.Bool("json", false, "JSONify output") + return cmd +} + +func networkListAction(cmd *cobra.Command, args []string) error { + flags := cmd.Flags() + jsonFormat, err := flags.GetBool("json") + if err != nil { + return err + } + + config, err := networks.LoadConfig() + if err != nil { + return err + } + + allNetworks := slices.Sorted(maps.Keys(config.Networks)) + + networks := []string{} + if len(args) > 0 { + for _, arg := range args { + matches := nameMatches(arg, allNetworks) + if len(matches) > 0 { + networks = append(networks, matches...) + } else { + logrus.Warnf("No network matching %v found.", arg) + } + } + } else { + networks = allNetworks + } + + if jsonFormat { + w := cmd.OutOrStdout() + for _, name := range networks { + nw, ok := config.Networks[name] + if !ok { + logrus.Errorf("network %q does not exist", nw) + continue + } + j, err := json.Marshal(nw) + if err != nil { + return err + } + fmt.Fprintln(w, string(j)) + } + return nil + } + + w := tabwriter.NewWriter(cmd.OutOrStdout(), 4, 8, 4, ' ', 0) + fmt.Fprintln(w, "NAME\tMODE\tGATEWAY\tINTERFACE") + for _, name := range networks { + nw, ok := config.Networks[name] + if !ok { + logrus.Errorf("network %q does not exist", nw) + continue + } + gwStr := "-" + if nw.Gateway != nil { + gw := net.IPNet{ + IP: nw.Gateway, + Mask: net.IPMask(nw.NetMask), + } + gwStr = gw.String() + } + intfStr := "-" + if nw.Interface != "" { + intfStr = nw.Interface + } + fmt.Fprintf(w, "%s\t%s\t%s\t%s\n", name, nw.Mode, gwStr, intfStr) + } + return w.Flush() +} + +func newNetworkCreateCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "create NETWORK", + Short: "Create a Lima network", + Example: networkCreateExample, + Args: WrapArgsError(cobra.ExactArgs(1)), + RunE: networkCreateAction, + } + flags := cmd.Flags() + flags.String("mode", networks.ModeUserV2, "mode") + _ = cmd.RegisterFlagCompletionFunc("mode", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return networks.Modes, cobra.ShellCompDirectiveNoFileComp + }) + flags.String("gateway", "", "gateway, e.g., \"192.168.42.1/24\"") + flags.String("interface", "", "interface for bridged mode") + _ = cmd.RegisterFlagCompletionFunc("interface", bashFlagCompleteNetworkInterfaceNames) + return cmd +} + +func networkCreateAction(cmd *cobra.Command, args []string) error { + name := args[0] + // LoadConfig ensures existence of networks.yaml + config, err := networks.LoadConfig() + if err != nil { + return err + } + if _, ok := config.Networks[name]; ok { + return fmt.Errorf("network %q already exists", name) + } + + flags := cmd.Flags() + mode, err := flags.GetString("mode") + if err != nil { + return err + } + + gateway, err := flags.GetString("gateway") + if err != nil { + return err + } + + intf, err := flags.GetString("interface") + if err != nil { + return err + } + + switch mode { + case networks.ModeBridged: + if gateway != "" { + return fmt.Errorf("network mode %q does not support specifying gateway", mode) + } + if intf == "" { + return fmt.Errorf("network mode %q requires specifying interface", mode) + } + default: + if gateway == "" { + return fmt.Errorf("network mode %q requires specifying gateway", mode) + } + if intf != "" { + return fmt.Errorf("network mode %q does not support specifying interface", mode) + } + } + + if !strings.Contains(gateway, "/") { + gateway += "/24" + } + gwIP, gwMask, err := net.ParseCIDR(gateway) + if err != nil { + return fmt.Errorf("failed to parse CIDR %q: %w", gateway, err) + } + if gwIP.IsUnspecified() || gwIP.IsLoopback() { + return fmt.Errorf("invalid IP address: %v", gwIP) + } + gwMaskStr := "255.255.255.0" + if gwMask != nil { + gwMaskStr = net.IP(gwMask.Mask).String() + } + // TODO: check IP range collision + + yq := fmt.Sprintf(`.networks.%q = {"mode":%q,"gateway":%q,"netmask":%q,"interface":%q}`, name, mode, gwIP.String(), gwMaskStr, intf) + + return networkApplyYQ(yq) +} + +func networkApplyYQ(yq string) error { + filePath, err := networks.ConfigFile() + if err != nil { + return err + } + yContent, err := os.ReadFile(filePath) + if err != nil { + return err + } + yBytes, err := yqutil.EvaluateExpression(yq, yContent) + if err != nil { + return err + } + if err := os.WriteFile(filePath, yBytes, 0o644); err != nil { + return err + } + return nil +} + +func newNetworkDeleteCommand() *cobra.Command { + cmd := &cobra.Command{ + Use: "delete NETWORK [NETWORK, ...]", + Short: "Delete one or more Lima networks", + Aliases: []string{"remove", "rm"}, + Args: WrapArgsError(cobra.MinimumNArgs(1)), + RunE: networkDeleteAction, + ValidArgsFunction: networkBashComplete, + } + flags := cmd.Flags() + flags.BoolP("force", "f", false, "Force delete (currently always required)") + return cmd +} + +func networkDeleteAction(cmd *cobra.Command, args []string) error { + flags := cmd.Flags() + force, err := flags.GetBool("force") + if err != nil { + return err + } + if !force { + return errors.New("`limactl network delete` currently always requires `--force`") + // Because the command currently does not check whether the network being removed is in use + } + + var yq string + for i, name := range args { + yq += fmt.Sprintf("del(.networks.%q)", name) + if i < len(args)-1 { + yq += " | " + } + } + return networkApplyYQ(yq) +} + +func networkBashComplete(cmd *cobra.Command, _ []string, _ string) ([]string, cobra.ShellCompDirective) { + return bashCompleteNetworkNames(cmd) +} diff --git a/pkg/networks/networks.go b/pkg/networks/networks.go index 12a1555d718..717627fb0da 100644 --- a/pkg/networks/networks.go +++ b/pkg/networks/networks.go @@ -6,15 +6,15 @@ package networks import "net" type Config struct { - Paths Paths `yaml:"paths"` - Group string `yaml:"group,omitempty"` // default: "everyone" - Networks map[string]Network `yaml:"networks"` + Paths Paths `yaml:"paths" json:"paths"` + Group string `yaml:"group,omitempty" json:"group,omitempty"` // default: "everyone" + Networks map[string]Network `yaml:"networks" json:"networks"` } type Paths struct { - SocketVMNet string `yaml:"socketVMNet"` - VarRun string `yaml:"varRun"` - Sudoers string `yaml:"sudoers,omitempty"` + SocketVMNet string `yaml:"socketVMNet" json:"socketVMNet"` + VarRun string `yaml:"varRun" json:"varRun"` + Sudoers string `yaml:"sudoers,omitempty" json:"sudoers,omitempty"` } const ( @@ -24,10 +24,17 @@ const ( ModeBridged = "bridged" ) +var Modes = []string{ + ModeUserV2, + ModeHost, + ModeShared, + ModeBridged, +} + type Network struct { - Mode string `yaml:"mode"` // "host", "shared", or "bridged" - Interface string `yaml:"interface,omitempty"` // only used by "bridged" networks - Gateway net.IP `yaml:"gateway,omitempty"` // only used by "host" and "shared" networks - DHCPEnd net.IP `yaml:"dhcpEnd,omitempty"` // default: same as Gateway, last byte is 254 - NetMask net.IP `yaml:"netmask,omitempty"` // default: 255.255.255.0 + Mode string `yaml:"mode" json:"mode"` // "user-v2", "host", "shared", or "bridged" + Interface string `yaml:"interface,omitempty" json:"interface,omitempty"` // only used by "bridged" networks + Gateway net.IP `yaml:"gateway,omitempty" json:"gateway,omitempty"` // only used by "user-v2", "host" and "shared" networks + DHCPEnd net.IP `yaml:"dhcpEnd,omitempty" json:"dhcpEnd,omitempty"` // default: same as Gateway, last byte is 254 + NetMask net.IP `yaml:"netmask,omitempty" json:"netmask,omitempty"` // default: 255.255.255.0 }