diff --git a/cmd/limactl/editflags/editflags.go b/cmd/limactl/editflags/editflags.go index ae9fa593e4f..1b14f8fcdee 100644 --- a/cmd/limactl/editflags/editflags.go +++ b/cmd/limactl/editflags/editflags.go @@ -96,6 +96,11 @@ func RegisterCreate(cmd *cobra.Command, commentPrefix string) { }) flags.Bool("plain", false, commentPrefix+"Plain mode. Disables mounts, port forwarding, containerd, etc.") + + flags.StringArray("port-forward", nil, commentPrefix+"Port forwards (host:guest), e.g., '8080:80' or '9090:9090,static=true' for static port-forwards") + _ = cmd.RegisterFlagCompletionFunc("port-forward", func(*cobra.Command, []string, string) ([]string, cobra.ShellCompDirective) { + return []string{"8080:80", "3000:3000", "8080:80,static=true"}, cobra.ShellCompDirectiveNoFileComp + }) } func defaultExprFunc(expr string) func(v *flag.Flag) (string, error) { @@ -104,6 +109,56 @@ func defaultExprFunc(expr string) func(v *flag.Flag) (string, error) { } } +func ParsePortForward(spec string) (hostPort, guestPort string, isStatic bool, err error) { + parts := strings.Split(spec, ",") + if len(parts) > 2 { + return "", "", false, fmt.Errorf("invalid port forward format %q, expected HOST:GUEST or HOST:GUEST,static=true", spec) + } + + portParts := strings.Split(strings.TrimSpace(parts[0]), ":") + if len(portParts) != 2 { + return "", "", false, fmt.Errorf("invalid port forward format %q, expected HOST:GUEST", parts[0]) + } + + hostPort = strings.TrimSpace(portParts[0]) + guestPort = strings.TrimSpace(portParts[1]) + + if len(parts) == 2 { + staticPart := strings.TrimSpace(parts[1]) + if strings.HasPrefix(staticPart, "static=") { + staticValue := strings.TrimPrefix(staticPart, "static=") + isStatic, err = strconv.ParseBool(staticValue) + if err != nil { + return "", "", false, fmt.Errorf("invalid value for static parameter: %q", staticValue) + } + } else { + return "", "", false, fmt.Errorf("invalid parameter %q, expected 'static=' followed by a boolean value", staticPart) + } + } + + return hostPort, guestPort, isStatic, nil +} + +func BuildPortForwardExpression(portForwards []string) (string, error) { + if len(portForwards) == 0 { + return "", nil + } + + expr := `.portForwards += [` + for i, spec := range portForwards { + hostPort, guestPort, isStatic, err := ParsePortForward(spec) + if err != nil { + return "", err + } + expr += fmt.Sprintf(`{"guestPort": %q, "hostPort": %q, "static": %v}`, guestPort, hostPort, isStatic) + if i < len(portForwards)-1 { + expr += "," + } + } + expr += `]` + return expr, nil +} + // YQExpressions returns YQ expressions. func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) { type def struct { @@ -206,6 +261,7 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) { false, false, }, + { "rosetta", func(_ *flag.Flag) (string, error) { @@ -261,6 +317,18 @@ func YQExpressions(flags *flag.FlagSet, newInstance bool) ([]string, error) { {"disk", d(".disk= \"%sGiB\""), false, false}, {"vm-type", d(".vmType = %q"), true, false}, {"plain", d(".plain = %s"), true, false}, + { + "port-forward", + func(_ *flag.Flag) (string, error) { + ss, err := flags.GetStringArray("port-forward") + if err != nil { + return "", err + } + return BuildPortForwardExpression(ss) + }, + false, + false, + }, } var exprs []string for _, def := range defs { diff --git a/cmd/limactl/editflags/editflags_test.go b/cmd/limactl/editflags/editflags_test.go index 2dceac15596..1634da8211a 100644 --- a/cmd/limactl/editflags/editflags_test.go +++ b/cmd/limactl/editflags/editflags_test.go @@ -23,3 +23,137 @@ func TestCompleteMemoryGiB(t *testing.T) { assert.DeepEqual(t, []float32{1, 2, 4}, completeMemoryGiB(8<<30)) assert.DeepEqual(t, []float32{1, 2, 4, 8, 10}, completeMemoryGiB(20<<30)) } + +func TestBuildPortForwardExpression(t *testing.T) { + tests := []struct { + name string + portForwards []string + expected string + expectError bool + }{ + { + name: "empty port forwards", + portForwards: []string{}, + expected: "", + }, + { + name: "single dynamic port forward", + portForwards: []string{"8080:80"}, + expected: `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": false}]`, + }, + { + name: "single static port forward", + portForwards: []string{"8080:80,static=true"}, + expected: `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": true}]`, + }, + { + name: "multiple mixed port forwards", + portForwards: []string{"8080:80", "2222:22,static=true", "3000:3000"}, + expected: `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": false},{"guestPort": "22", "hostPort": "2222", "static": true},{"guestPort": "3000", "hostPort": "3000", "static": false}]`, + }, + { + name: "invalid format - missing colon", + portForwards: []string{"8080"}, + expectError: true, + }, + { + name: "invalid format - too many colons", + portForwards: []string{"8080:80:extra"}, + expectError: true, + }, + { + name: "invalid static parameter", + portForwards: []string{"8080:80,invalid=true"}, + expectError: true, + }, + { + name: "too many parameters", + portForwards: []string{"8080:80,static=true,extra=value"}, + expectError: true, + }, + { + name: "whitespace handling", + portForwards: []string{" 8080 : 80 , static=true "}, + expected: `.portForwards += [{"guestPort": "80", "hostPort": "8080", "static": true}]`, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + result, err := BuildPortForwardExpression(tt.portForwards) + if tt.expectError { + assert.Check(t, err != nil) + } else { + assert.NilError(t, err) + assert.Equal(t, tt.expected, result) + } + }) + } +} + +func TestParsePortForward(t *testing.T) { + tests := []struct { + name string + spec string + hostPort string + guestPort string + isStatic bool + expectError bool + }{ + { + name: "dynamic port forward", + spec: "8080:80", + hostPort: "8080", + guestPort: "80", + isStatic: false, + }, + { + name: "static port forward", + spec: "8080:80,static=true", + hostPort: "8080", + guestPort: "80", + isStatic: true, + }, + { + name: "whitespace handling", + spec: " 8080 : 80 , static=true ", + hostPort: "8080", + guestPort: "80", + isStatic: true, + }, + { + name: "invalid format - missing colon", + spec: "8080", + expectError: true, + }, + { + name: "invalid format - too many colons", + spec: "8080:80:extra", + expectError: true, + }, + { + name: "invalid parameter", + spec: "8080:80,invalid=true", + expectError: true, + }, + { + name: "too many parameters", + spec: "8080:80,static=true,extra=value", + expectError: true, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + hostPort, guestPort, isStatic, err := ParsePortForward(tt.spec) + if tt.expectError { + assert.Check(t, err != nil) + } else { + assert.NilError(t, err) + assert.Equal(t, tt.hostPort, hostPort) + assert.Equal(t, tt.guestPort, guestPort) + assert.Equal(t, tt.isStatic, isStatic) + } + }) + } +} diff --git a/hack/test-nonplain-static-port-forward.sh b/hack/test-nonplain-static-port-forward.sh new file mode 100755 index 00000000000..92a94b95420 --- /dev/null +++ b/hack/test-nonplain-static-port-forward.sh @@ -0,0 +1,25 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright The Lima Authors +# SPDX-License-Identifier: Apache-2.0 + +set -euxo pipefail + +INSTANCE=nonplain-static-port-forward +TEMPLATE=hack/test-templates/static-port-forward.yaml + +limactl delete -f $INSTANCE || true + +limactl start --name=$INSTANCE --tty=false $TEMPLATE + +limactl shell $INSTANCE -- bash -c 'until [ -e /run/nginx.pid ]; do sleep 1; done' +limactl shell $INSTANCE -- bash -c 'until systemctl is-active --quiet test-server-9080; do sleep 1; done' +limactl shell $INSTANCE -- bash -c 'until systemctl is-active --quiet test-server-9070; do sleep 1; done' + +curl -sSf http://127.0.0.1:9090 | grep -i 'nginx' && echo 'Static port forwarding (9090) works in normal mode!' +curl -sSf http://127.0.0.1:9080 | grep -i 'Dynamic port 9080' && echo 'Dynamic port forwarding (9080) works in normal mode!' +curl -sSf http://127.0.0.1:9070 | grep -i 'Dynamic port 9070' && echo 'Dynamic port forwarding (9070) works in normal mode!' + +limactl delete -f $INSTANCE +echo "All tests passed for normal mode - both static and dynamic ports work!" +# EOF diff --git a/hack/test-plain-static-port-forward.sh b/hack/test-plain-static-port-forward.sh new file mode 100755 index 00000000000..fbe70d62742 --- /dev/null +++ b/hack/test-plain-static-port-forward.sh @@ -0,0 +1,35 @@ +#!/usr/bin/env bash + +# SPDX-FileCopyrightText: Copyright The Lima Authors +# SPDX-License-Identifier: Apache-2.0 + +set -euxo pipefail + +INSTANCE=plain-static-port-forward +TEMPLATE=hack/test-templates/static-port-forward.yaml + +limactl delete -f $INSTANCE || true + +limactl start --name=$INSTANCE --plain=true --tty=false $TEMPLATE + +limactl shell $INSTANCE -- bash -c 'until [ -e /run/nginx.pid ]; do sleep 1; done' + +curl -sSf http://127.0.0.1:9090 | grep -i 'nginx' && echo 'Static port forwarding (9090) works in plain mode!' + +if curl -sSf http://127.0.0.1:9080 2>/dev/null; then + echo 'ERROR: Dynamic port 9080 should not be forwarded in plain mode!' + exit 1 +else + echo 'Dynamic port 9080 is correctly NOT forwarded in plain mode.' +fi + +if curl -sSf http://127.0.0.1:9070 2>/dev/null; then + echo 'ERROR: Dynamic port 9070 should not be forwarded in plain mode!' + exit 1 +else + echo 'Dynamic port 9070 is correctly NOT forwarded in plain mode.' +fi + +limactl delete -f $INSTANCE +echo "All tests passed for plain mode - only static ports work!" +# EOF diff --git a/hack/test-templates.sh b/hack/test-templates.sh index 2935907c99e..a6e692a5fe5 100755 --- a/hack/test-templates.sh +++ b/hack/test-templates.sh @@ -50,6 +50,7 @@ declare -A CHECKS=( ["snapshot-online"]="" ["snapshot-offline"]="" ["port-forwards"]="1" + ["static-port-forwards"]="" ["vmnet"]="" ["disk"]="" ["user-v2"]="" @@ -90,6 +91,12 @@ case "$NAME" in CHECKS["param-env-variables"]="1" CHECKS["set-user"]="1" ;; +"static-port-forward") + CHECKS["static-port-forwards"]="1" + CHECKS["port-forwards"]="" + CHECKS["container-engine"]="" + CHECKS["restart"]="" + ;; "docker") CONTAINER_ENGINE="docker" ;; @@ -383,6 +390,13 @@ if [[ -n ${CHECKS["port-forwards"]} ]]; then set +x fi +if [[ -n ${CHECKS["static-port-forwards"]} ]]; then + INFO "Testing static port forwarding functionality" + "${scriptdir}/test-plain-static-port-forward.sh" "$NAME" + "${scriptdir}/test-nonplain-static-port-forward.sh" "$NAME" + INFO "All static port forwarding tests passed!" +fi + if [[ -n ${CHECKS["vmnet"]} ]]; then INFO "Testing vmnet functionality" guestip="$(limactl shell "$NAME" ip -4 -j addr show dev lima0 | jq -r '.[0].addr_info[0].local')" diff --git a/hack/test-templates/static-port-forward.yaml b/hack/test-templates/static-port-forward.yaml new file mode 100644 index 00000000000..67998d54312 --- /dev/null +++ b/hack/test-templates/static-port-forward.yaml @@ -0,0 +1,64 @@ +images: +- location: "https://cloud-images.ubuntu.com/releases/22.04/release/ubuntu-22.04-server-cloudimg-amd64.img" + arch: "x86_64" + +provision: +- mode: system + script: | + apt-get update + apt-get install -y nginx python3 + systemctl enable nginx + systemctl start nginx + + cat > /etc/systemd/system/test-server-9080.service << 'EOF' + [Unit] + Description=Test Server on Port 9080 + After=network.target + + [Service] + Type=simple + User=root + ExecStart=/usr/bin/python3 -m http.server 9080 --bind 127.0.0.1 + Restart=always + + [Install] + WantedBy=multi-user.target + EOF + + cat > /etc/systemd/system/test-server-9070.service << 'EOF' + [Unit] + Description=Test Server on Port 9070 + After=network.target + + [Service] + Type=simple + User=root + ExecStart=/usr/bin/python3 -m http.server 9070 --bind 127.0.0.1 + Restart=always + + [Install] + WantedBy=multi-user.target + EOF + + mkdir -p /var/www/html-9080 + mkdir -p /var/www/html-9070 + + echo '

Dynamic port 9080

' > /var/www/html-9080/index.html + echo '

Dynamic port 9070

' > /var/www/html-9070/index.html + + systemctl daemon-reload + systemctl enable test-server-9080 + systemctl enable test-server-9070 + systemctl start test-server-9080 + systemctl start test-server-9070 + +portForwards: +- guestPort: 80 + hostPort: 9090 + static: true +- guestPort: 9080 + hostPort: 9080 + static: false +- guestPort: 9070 + hostPort: 9070 + static: false diff --git a/pkg/hostagent/hostagent.go b/pkg/hostagent/hostagent.go index c0452c8ba72..d9fb97f2def 100644 --- a/pkg/hostagent/hostagent.go +++ b/pkg/hostagent/hostagent.go @@ -420,7 +420,7 @@ func (a *HostAgent) Info(_ context.Context) (*hostagentapi.Info, error) { func (a *HostAgent) startHostAgentRoutines(ctx context.Context) error { if *a.instConfig.Plain { - logrus.Info("Running in plain mode. Mounts, port forwarding, containerd, etc. will be ignored. Guest agent will not be running.") + logrus.Info("Running in plain mode. Mounts, dynamic port forwarding, containerd, etc. will be ignored. Guest agent will not be running. Static port forwarding is allowed.") } a.onClose = append(a.onClose, func() error { logrus.Debugf("shutting down the SSH master") @@ -478,9 +478,17 @@ sudo chown -R "${USER}" /run/host-services` return errors.Join(unlockErrs...) }) } + + staticPortForwards, err := a.separateStaticPortForwards() + if err != nil { + errs = append(errs, err) + } + + a.addStaticPortForwardsFromList(ctx, staticPortForwards) if !*a.instConfig.Plain { go a.watchGuestAgentEvents(ctx) } + if err := a.waitForRequirements("optional", a.optionalRequirements()); err != nil { errs = append(errs, err) } @@ -606,6 +614,47 @@ func (a *HostAgent) watchGuestAgentEvents(ctx context.Context) { } } +func (a *HostAgent) addStaticPortForwardsFromList(ctx context.Context, staticPortForwards []limayaml.PortForward) { + for _, rule := range staticPortForwards { + if rule.GuestSocket == "" { + guest := &guestagentapi.IPPort{ + Ip: rule.GuestIP.String(), + Port: int32(rule.GuestPort), + Protocol: rule.Proto, + } + local, remote := a.portForwarder.forwardingAddresses(guest) + if local != "" { + logrus.Infof("Setting up static TCP forwarding from %s to %s", remote, local) + if err := forwardTCP(ctx, a.sshConfig, a.sshLocalPort, local, remote, verbForward); err != nil { + logrus.WithError(err).Warnf("failed to set up static TCP forwarding %s -> %s", remote, local) + } + } + } + } +} + +func (a *HostAgent) separateStaticPortForwards() ([]limayaml.PortForward, error) { + staticPortForwards := make([]limayaml.PortForward, 0, len(a.instConfig.PortForwards)) + nonStaticPortForwards := make([]limayaml.PortForward, 0, len(a.instConfig.PortForwards)) + + for i := range len(a.instConfig.PortForwards) { + rule := a.instConfig.PortForwards[i] + if rule.Static { + logrus.Debugf("Found static port forward: guest=%d host=%d", rule.GuestPort, rule.HostPort) + staticPortForwards = append(staticPortForwards, rule) + } else { + logrus.Debugf("Found non-static port forward: guest=%d host=%d", rule.GuestPort, rule.HostPort) + nonStaticPortForwards = append(nonStaticPortForwards, rule) + } + } + + logrus.Debugf("Static port forwards: %d, Non-static port forwards: %d", len(staticPortForwards), len(nonStaticPortForwards)) + + a.instConfig.PortForwards = nonStaticPortForwards + + return staticPortForwards, nil +} + func isGuestAgentSocketAccessible(ctx context.Context, client *guestagentclient.GuestAgentClient) bool { _, err := client.Info(ctx) return err == nil diff --git a/pkg/limayaml/defaults.go b/pkg/limayaml/defaults.go index 629cbcc3f97..d3150781567 100644 --- a/pkg/limayaml/defaults.go +++ b/pkg/limayaml/defaults.go @@ -915,8 +915,8 @@ func fixUpForPlainMode(y *LimaYAML) { if !*y.Plain { return } + deleteNonStaticPortForwards(&y.PortForwards) y.Mounts = nil - y.PortForwards = nil y.Containerd.System = ptr.Of(false) y.Containerd.User = ptr.Of(false) y.Rosetta.BinFmt = ptr.Of(false) @@ -924,6 +924,17 @@ func fixUpForPlainMode(y *LimaYAML) { y.TimeZone = ptr.Of("") } +// deleteNonStaticPortForwards removes all non-static port forwarding rules in case of Plain mode. +func deleteNonStaticPortForwards(portForwards *[]PortForward) { + staticPortForwards := make([]PortForward, 0, len(*portForwards)) + for _, rule := range *portForwards { + if rule.Static { + staticPortForwards = append(staticPortForwards, rule) + } + } + *portForwards = staticPortForwards +} + func executeGuestTemplate(format, instDir string, user User, param map[string]string) (bytes.Buffer, error) { tmpl, err := template.New("").Parse(format) if err == nil { diff --git a/pkg/limayaml/defaults_test.go b/pkg/limayaml/defaults_test.go index 70f6810c855..7166f1a40f5 100644 --- a/pkg/limayaml/defaults_test.go +++ b/pkg/limayaml/defaults_test.go @@ -762,3 +762,120 @@ func TestContainerdDefault(t *testing.T) { archives := defaultContainerdArchives() assert.Assert(t, len(archives) > 0) } + +func TestStaticPortForwarding(t *testing.T) { + tests := []struct { + name string + config LimaYAML + expected []PortForward + }{ + { + name: "plain mode with static port forwards", + config: LimaYAML{ + Plain: ptr.Of(true), + PortForwards: []PortForward{ + { + GuestPort: 8080, + HostPort: 8080, + Static: true, + }, + { + GuestPort: 9000, + HostPort: 9000, + Static: false, + }, + { + GuestPort: 8081, + HostPort: 8081, + }, + }, + }, + expected: []PortForward{ + { + GuestPort: 8080, + HostPort: 8080, + Static: true, + }, + }, + }, + { + name: "non-plain mode with static port forwards", + config: LimaYAML{ + Plain: ptr.Of(false), + PortForwards: []PortForward{ + { + GuestPort: 8080, + HostPort: 8080, + Static: true, + }, + { + GuestPort: 9000, + HostPort: 9000, + Static: false, + }, + }, + }, + expected: []PortForward{ + { + GuestPort: 8080, + HostPort: 8080, + Static: true, + }, + { + GuestPort: 9000, + HostPort: 9000, + Static: false, + }, + }, + }, + { + name: "plain mode with no static port forwards", + config: LimaYAML{ + Plain: ptr.Of(true), + PortForwards: []PortForward{ + { + GuestPort: 8080, + HostPort: 8080, + Static: false, + }, + { + GuestPort: 9000, + HostPort: 9000, + }, + }, + }, + expected: []PortForward{}, + }, + } + + for _, tt := range tests { + t.Run(tt.name, func(t *testing.T) { + fixUpForPlainMode(&tt.config) + + if *tt.config.Plain { + for _, pf := range tt.config.PortForwards { + if !pf.Static { + t.Errorf("Non-static port forward found in plain mode: %+v", pf) + } + } + } + + assert.Equal(t, len(tt.config.PortForwards), len(tt.expected), + "Expected %d port forwards, got %d", len(tt.expected), len(tt.config.PortForwards)) + + for i, expected := range tt.expected { + if i >= len(tt.config.PortForwards) { + t.Errorf("Missing port forward at index %d", i) + continue + } + actual := tt.config.PortForwards[i] + assert.Equal(t, expected.Static, actual.Static, + "Port forward %d: expected Static=%v, got %v", i, expected.Static, actual.Static) + assert.Equal(t, expected.GuestPort, actual.GuestPort, + "Port forward %d: expected GuestPort=%d, got %d", i, expected.GuestPort, actual.GuestPort) + assert.Equal(t, expected.HostPort, actual.HostPort, + "Port forward %d: expected HostPort=%d, got %d", i, expected.HostPort, actual.HostPort) + } + }) + } +} diff --git a/pkg/limayaml/limayaml.go b/pkg/limayaml/limayaml.go index d72e5b602c8..115ec168002 100644 --- a/pkg/limayaml/limayaml.go +++ b/pkg/limayaml/limayaml.go @@ -285,6 +285,7 @@ type PortForward struct { Proto Proto `yaml:"proto,omitempty" json:"proto,omitempty"` Reverse bool `yaml:"reverse,omitempty" json:"reverse,omitempty"` Ignore bool `yaml:"ignore,omitempty" json:"ignore,omitempty"` + Static bool `yaml:"static,omitempty" json:"static,omitempty"` } type CopyToHost struct { diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go index 9b959f25d1a..111473af5f4 100644 --- a/pkg/limayaml/validate.go +++ b/pkg/limayaml/validate.go @@ -415,6 +415,16 @@ func Validate(y *LimaYAML, warn bool) error { } } } + if y.Plain != nil && *y.Plain { + const portRangeWarnThreshold = 10 + for i, rule := range y.PortForwards { + guestRange := rule.GuestPortRange[1] - rule.GuestPortRange[0] + 1 + hostRange := rule.HostPortRange[1] - rule.HostPortRange[0] + 1 + if guestRange > portRangeWarnThreshold || hostRange > portRangeWarnThreshold { + logrus.Warnf("[plain mode] portForwards[%d] covers a range of more than %d ports (guest: %d, host: %d). All ports will be forwarded unconditionally, which may be inefficient.", i, portRangeWarnThreshold, guestRange, hostRange) + } + } + } return errs } diff --git a/pkg/portfwd/forward.go b/pkg/portfwd/forward.go index 7d6c001c5c4..05fa37e0691 100644 --- a/pkg/portfwd/forward.go +++ b/pkg/portfwd/forward.go @@ -35,6 +35,10 @@ func NewPortForwarder(rules []limayaml.PortForward, ignoreTCP, ignoreUDP bool) * func (fw *Forwarder) OnEvent(ctx context.Context, client *guestagentclient.GuestAgentClient, ev *api.Event) { for _, f := range ev.LocalPortsAdded { + // Before forwarding, check if any static rule matches this port otherwise it will be forwarded twice and cause a port conflict + if fw.isPortStaticallyForwarded(f) { + continue + } local, remote := fw.forwardingAddresses(f) if local == "" { if !fw.ignoreTCP && f.Protocol == "tcp" { @@ -91,6 +95,18 @@ func (fw *Forwarder) forwardingAddresses(guest *api.IPPort) (hostAddr, guestAddr return "", guest.HostString() } +func (fw *Forwarder) isPortStaticallyForwarded(guest *api.IPPort) bool { + for _, rule := range fw.rules { + if !rule.Static { + continue + } + if guest.Port >= int32(rule.GuestPortRange[0]) && guest.Port <= int32(rule.GuestPortRange[1]) { + return true + } + } + return false +} + func hostAddress(rule limayaml.PortForward, guest *api.IPPort) string { if rule.HostSocket != "" { return rule.HostSocket