Repository: lima-vm/lima

Issue:
Review default portforwarding rules
@norio-nomura wrote on Slack:

> Why is the default portforward rule's guestIP "127.0.0.1"?

The simple (unhelpful) answer is: it is a rule with all fields filled in by defaults:

```yaml
portForwards:
- {}
```

## Background

When I originally implemented `portForwards` I made a mistake: I interpreted `guestIP: 0.0.0.0` as matching the binding to any interface, not just binding to `net.IPv4zero`.

Obviously this should not be the default `guestIP` for forwarding.

In hindsight I wish I had used `*` for this extreme wildcard behaviour (or not implemented it at all, as it does not seem useful).

But to keep backwards compatibility I later added the `GuestIPMustBeZero` boolean to the rules to mean "only match ports listening on `0.0.0.0`". I use it like this:

```yaml
portForwards:
- guestIPMustBeZero: true
  hostIP: 0.0.0.0
```

That way when a container binds to `127.0.0.1` inside the VM it forwards to `127.0.0.1` on the host, but if it binds to `0.0.0.0` in the VM, then it also binds to `0.0.0.0` on the host. But no other interfaces have their port bindings forwarded.

### Should the default forwarding rule (to `127.0.0.1`) change?

@norio-nomura suggested:

> "0.0.0.0" or "::"?

I don't think we should forward all bound ports, just `0.0.0.0` and `127.0.0.1`. And by default we should only forward to localhost, not to the external interfaces on the host. So I would support using this as the default rules (added some default values for clarity):

```yaml
portForwards:
- guestIPMustBeZero: true
  guestIP: 0.0.0.0
  hostIP: 127.0.0.1
- guestIP: 127.0.0.1
  hostIP: 127.0.0.1
```

These are the lowest priority rules, so the user can always override them in their `lima.yaml`.

## Should we fix the weird matching of `guestIP: 0.0.0.0`?

I would like the useful semantics be the default. We can keep the original semantics as an option, just in case I'm wrong that nobody uses them.

### Option 1: with a deprecation cycle

We can make `GuestIPMustBeZero` into a `*bool`, so we can know if it was explicitly set to `false`, or not specified.

Then we can issue a warning if `lima.yaml` contains a rule `guestIP: 0.0.0.0` and does not explicitly set `GuestIPMustBeZero`:

```
portForwards[1].guestIP is 0.0.0.0 semantic will change in Lima 3.0 see https://...
```

Then we add `guestIP: *` as a shortcut (that will not warn) for

```yaml
portForwards:
- guestIP: 0.0.0.0
  guestIPMustBeZero: false
```

In Lima 3.0 we get rid of the warning and switch the default of `guestIPMustBeZero` from `false` to `true`.

### Option 2: Incompatible change

We can decide that the current semantics are unlikely to be used by anyone. So we could announce it in the release notes and make the switch to the final state from Option 1 already for Lima 2.0.

Hints from issue comments:
@AkihiroSuda: > ## Should we fix the weird matching of guestIP: 0.0.0.0?
> ### Option 2: Incompatible change
>
> We can decide that the current semantics are unlikely to be used by anyone. So we could announce it in the release notes and make the switch to the final state from Option 1 already for Lima 2.0.

SGTM@jandubois: > I don't think we should forward all bound ports, just `0.0.0.0` and `127.0.0.1`.

I know we decided not to change this part, but the way I read the code, any port bound to `0.0.0.0` already matches any `guestIP` anyways, so the current default rule should already forward ports bound to `0.0.0.0` in the VM to `127.0.0.1` on the host.

@norio-nomura Is this not the case?

The other change (that I'm still going to make) only affects the other case: the binding port is NOT `0.0.0.0` but the `guestIP` in the rule specifies `0.0.0.0`. This will match unless `GuestIPMustBeZero` is `true`.

In Lima 2.0 `GuestIPMustBeZero` will be `true` by default, so it no this longer matches unless the user explicitly sets it to `false`.@norio-nomura: How do you think about `hack/test-port-forwarding.pl default socat` passing with following additional tests:
```yaml
- guestIP: "0.0.0.0"
  guestPort: 5001

  # ignore:  192.168.5.15   5001 → 127.0.0.1 5001

- guestIPMustBeZero: true
  guestPort: 5002

  # ignore:  192.168.5.15   5002 → 127.0.0.1 5002

- guestIPMustBeZero: false
  guestPort: 5003

  # ignore:  192.168.5.15   5003 → 127.0.0.1 5003

- guestIP: "127.0.0.1"
  guestPort: 5004

  # ignore:  192.168.5.15   5004 → 127.0.0.1 5004
```
I'm still puzzled by the current implementation that these tests passes.@jandubois: > ```yaml
> - guestIP: "0.0.0.0"
>   guestPort: 5001
> 
>   # ignore:  192.168.5.15   5001 → 127.0.0.1 5001
> ```

I would have expected this to fail because the rule should have matched (guestIPMustBeZero is `false` by default)

> ```yaml
> - guestIPMustBeZero: true
>   guestPort: 5002
> 
>   # ignore:  192.168.5.15   5002 → 127.0.0.1 5002
> ```

I expect this to pass: the bind address is not 0.0.0.0
 
> ```yaml
> - guestIPMustBeZero: false
>   guestPort: 5003
> 
>   # ignore:  192.168.5.15   5003 → 127.0.0.1 5003
> ```

I would expect this to fail; it is essentially the same as the 5001 test above
 
> ```yaml
> - guestIP: "127.0.0.1"
>   guestPort: 5004
> 
>   # ignore:  192.168.5.15   5004 → 127.0.0.1 5004
> ```

This one should pass again because the guestIP doesn't match.

How are you running the test? Locally or in CI? If locally, what are the exact commands?@norio-nomura: > How are you running the test? Locally or in CI? If locally, what are the exact commands?

When I tried it yesterday, all the tests were passed, but when I tried it now, the port 5001 test did not pass.
The following is the log I tried with 1c53f93f3a7365065915977fbb2465410c96057d.
```console
$ git checkout master
Switched to branch 'master'
Your branch is up to date with 'upstream/master'.
$ make CONFIG_GUESTAGENT_COMPRESS=n
make: Nothing to be done for `all'.
$ pbpaste>>hack/test-port-forwarding.pl
$ git diff hack/test-port-forwarding.pl
diff --git a/hack/test-port-forwarding.pl b/hack/test-port-forwarding.pl
index 269b10a8..3558fdff 100755
--- a/hack/test-port-forwarding.pl
+++ b/hack/test-port-forwarding.pl
@@ -415,3 +415,22 @@ portForwards:
   hostSocket: port5000.sock
 
   # forward: 127.0.0.1 5000 → sockDir/port5000.sock
+- guestIP: "0.0.0.0"
+  guestPort: 5001
+
+  # ignore:  192.168.5.15   5001 → 127.0.0.1 5001
+
+- guestIPMustBeZero: true
+  guestPort: 5002
+
+  # ignore:  192.168.5.15   5002 → 127.0.0.1 5002
+
+- guestIPMustBeZero: false
+  guestPort: 5003
+
+  # ignore:  192.168.5.15   5003 → 127.0.0.1 5003
+
+- guestIP: "127.0.0.1"
+  guestPort: 5004
+
+  # ignore:  192.168.5.15   5004 → 127.0.0.1 5004
\ No newline at end of file
$ _output/bin/limactl --version
limactl version 2.0.0-alpha.2-133-g1c53f93f
$ _output/bin/limactl create --tty=false
WARN[0000] Template locator "template://_images/ubuntu" should be written "template:_images/ubuntu" since Lima v2.0 
WARN[0000] Template locator "template://_default/mounts" should be written "template:_default/mounts" since Lima v2.0 
INFO[0000] Terminal is not available, proceeding without opening an editor 
INFO[0000] Attempting to download the image              arch=aarch64 digest="sha256:a7b84e640e4dd1f1de036653fbdc18dc0600b5293e89c0958fc7de507c550cca" location="https://cloud-images.ubuntu.com/releases/questing/release-20251007/ubuntu-25.10-server-cloudimg-arm64.img"
INFO[0000] Using cache "/Users/norio/Library/Caches/lima/download/by-url-sha256/39f3c49d722d2c017f53b829658abcd810fcb283b1265cfc8125b6aeceedda85/data" 
INFO[0003] Attempting to download the nerdctl archive    arch=aarch64 digest="sha256:2f98346ed3dcaf47cfd6461a5685e582284329a1ece1d952ade881b9fe7491e8" location="https://github.com/containerd/nerdctl/releases/download/v2.1.6/nerdctl-full-2.1.6-linux-arm64.tar.gz"
INFO[0003] Using cache "/Users/norio/Library/Caches/lima/download/by-url-sha256/d82077f1308e8d063805c4c1f761e1c3fd636e18cab7e25919a18e7a0838afa2/data" 
INFO[0003] Run `limactl start default` to start the instance. 
$ hack/test-port-forwarding.pl ~/.lima/default/lima.yaml
WARN[0000] No instance matching /Users/norio/.lima/default/lima.yaml found. 
FATA[0000] unmatched instances                          
$ _output/bin/limactl start --log-level=error
ERRO[0022] [hostagent] failed to listen udp: listen udp 0.0.0.0:323: bind: address already in use 
ERRO[0022] [hostagent] failed to listen udp: listen udp 0.0.0.0:323: bind: address already in use 
$ _output/bin/limactl shell default sudo apt-get -U install -y socat
Hit:1 http://ports.ubuntu.com/ubuntu-ports questing InRelease
Hit:2 http://ports.ubuntu.com/ubuntu-ports questing-updates InRelease
Hit:3 http://ports.ubuntu.com/ubuntu-ports questing-backports InRelease
Hit:4 http://ports.ubuntu.com/ubuntu-ports questing-security InRelease
Get:5 http://ports.ubuntu.com/ubuntu-ports questing-security/main arm64 Components [208 B]
Reading package lists... Done      
Reading package lists... Done
Building dependency tree... Done
Reading state information... Done
Solving dependencies... Done
The following NEW packages will be installed:
  socat
0 upgraded, 1 newly installed, 0 to remove and 2 not upgraded.
Need to get 0 B/394 kB of archives.
After this operation, 1785 kB of additional disk space will be used.
Selecting previously unselected package socat.
(Reading database ... 91503 files and directories currently installed.)
Preparing to unpack .../socat_1.8.0.3-1build1_arm64.deb ...
Unpacking socat (1.8.0.3-1build1) ...
Setting up socat (1.8.0.3-1build1) ...
Processing triggers for man-db (2.13.1-1) ...
Scanning processes...                                                                                                                                                                                                
Scanning linux images...                                                                                                                                                                                             

Running kernel seems to be up-to-date (ABI upgrades are not detected).

No services need to be restarted.

No containers need to be restarted.

No user sessions are running outdated binaries.

No VM guests are running outdated hypervisor (qemu) binaries on this host.
$ brew install socat
Warning: socat 1.8.0.3 is already installed and up-to-date.
To reinstall 1.8.0.3, run:
  brew reinstall socat
$ hack/test-port-forwarding.pl default socat
Running: socat -u STDIN TCP:127.0.0.1:3000,connect-timeout=1
2025/10/18 09:36:04 socat[15412] W connect(5, LEN=16 AF=2 127.0.0.1:3000, 16): Connection refused
2025/10/18 09:36:04 socat[15412] E TCP:127.0.0.1:3000: Connection refused
Running: socat -u STDIN TCP:127.0.0.1:2001,connect-timeout=1
Running: socat -u STDIN TCP:127.0.0.1:2002,connect-timeout=1
Running: socat -u STDIN TCP:127.0.0.1:3010,connect-timeout=1
2025/10/18 09:36:04 socat[15415] W connect(5, LEN=16 AF=2 127.0.0.1:3010, 16): Connection refused
2025/10/18 09:36:04 socat[15415] E TCP:127.0.0.1:3010: Connection refused
Running: socat -u STDIN TCP:127.0.0.1:3011,connect-timeout=1
2025/10/18 09:36:04 socat[15416] W connect(5, LEN=16 AF=2 127.0.0.1:3011, 16): Connection refused
2025/10/18 09:36:04 socat[15416] E TCP:127.0.0.1:3011: Connection refused
Running: socat -u STDIN TCP:127.0.0.1:2020,connect-timeout=1
Running: socat -u STDIN TCP:127.0.0.1:2021,connect-timeout=1
Running: socat -u STDIN TCP:127.0.0.1:2022,connect-timeout=1
Running: socat -u STDIN TCP:127.0.0.1:2023,connect-timeout=1
Running: socat -u STDIN TCP:127.0.0.1:2024,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:2030,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:2031,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:2032,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:2033,connect-timeout=1
Running: socat -u STDIN TCP:127.0.0.1:300,connect-timeout=1
Running: socat -u STDIN TCP:0.0.0.0:310,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4000,connect-timeout=1
Running: socat -u STDIN TCP6:[::]:4010,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4020,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4021,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4022,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4023,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4024,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4025,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4030,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4031,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4032,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4033,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4034,connect-timeout=1
Running: socat -u STDIN TCP:192.168.12.29:4035,connect-timeout=1
Running: socat -u STDIN TCP:127.0.0.1:4040,connect-timeout=1
Running: socat -u STDIN TCP:127.0.0.1:4041,connect-timeout=1
Running: socat -u STDIN TCP:127.0.0.1:4043,connect-timeout=1
2025/10/18 09:36:05 socat[15444] W connect(5, LEN=16 AF=2 127.0.0.1:4043, 16): Connection refused
2025/10/18 09:36:05 socat[15444] E TCP:127.0.0.1:4043: Connection refused
Running: socat -u STDIN TCP:127.0.0.1:4044,connect-timeout=1
2025/10/18 09:36:05 socat[15445] W connect(5, LEN=16 AF=2 127.0.0.1:4044, 16): Connection refused
2025/10/18 09:36:05 socat[15445] E TCP:127.0.0.1:4044: Connection refused
Running: socat -u STDIN UNIX-CONNECT:/Users/norio/.lima/default/sock/port5000.sock
Running: socat -u STDIN TCP:127.0.0.1:5001,connect-timeout=1
Running: socat -u STDIN TCP:127.0.0.1:5002,connect-timeout=1
2025/10/18 09:36:05 socat[15448] W connect(5, LEN=16 AF=2 127.0.0.1:5002, 16): Connection refused
2025/10/18 09:36:05 socat[15448] E TCP:127.0.0.1:5002: Connection refused
Running: socat -u STDIN TCP:127.0.0.1:5003,connect-timeout=1
2025/10/18 09:36:05 socat[15449] W connect(5, LEN=16 AF=2 127.0.0.1:5003, 16): Connection refused
2025/10/18 09:36:05 socat[15449] E TCP:127.0.0.1:5003: Connection refused
Running: socat -u STDIN TCP:127.0.0.1:5004,connect-timeout=1
2025/10/18 09:36:05 socat[15450] W connect(5, LEN=16 AF=2 127.0.0.1:5004, 16): Connection refused
2025/10/18 09:36:05 socat[15450] E TCP:127.0.0.1:5004: Connection refused
✅ Not forwarding TCP 127.0.0.2:3000
✅ Forwarding TCP from 127.0.0.3:3001 to 127.0.0.1:2001
✅ Forwarding TCP from 0.0.0.0:3002 to 127.0.0.1:2002
✅ Not forwarding TCP 0.0.0.0:3010
✅ Not forwarding TCP 127.0.0.1:3011
✅ Forwarding TCP from 127.0.0.2:3020 to 127.0.0.1:2020
✅ Forwarding TCP from 127.0.0.1:3021 to 127.0.0.1:2021
✅ Forwarding TCP from 0.0.0.0:3022 to 127.0.0.1:2022
✅ Forwarding TCP from [::]:3023 to 127.0.0.1:2023
✅ Forwarding TCP from [::1]:3024 to 127.0.0.1:2024
✅ Forwarding TCP from 127.0.0.1:3030 to 192.168.12.29:2030
✅ Forwarding TCP from 0.0.0.0:3031 to 192.168.12.29:2031
✅ Forwarding TCP from [::]:3032 to 192.168.12.29:2032
✅ Forwarding TCP from [::1]:3033 to 192.168.12.29:2033
✅ Forwarding TCP from 127.0.0.1:300 to 127.0.0.1:300
✅ Forwarding TCP from 127.0.0.1:310 to 0.0.0.0:310
✅ Forwarding TCP from 192.168.5.15:4000 to 192.168.12.29:4000
✅ Forwarding TCP from [::1]:4010 to [::]:4010
✅ Forwarding TCP from 127.0.0.1:4020 to 192.168.12.29:4020
✅ Forwarding TCP from 127.0.0.2:4021 to 192.168.12.29:4021
✅ Forwarding TCP from 192.168.5.15:4022 to 192.168.12.29:4022
✅ Forwarding TCP from 0.0.0.0:4023 to 192.168.12.29:4023
✅ Forwarding TCP from [::]:4024 to 192.168.12.29:4024
✅ Forwarding TCP from [::1]:4025 to 192.168.12.29:4025
✅ Forwarding TCP from 127.0.0.1:4030 to 192.168.12.29:4030
✅ Forwarding TCP from 127.0.0.2:4031 to 192.168.12.29:4031
✅ Forwarding TCP from 192.168.5.15:4032 to 192.168.12.29:4032
✅ Forwarding TCP from 0.0.0.0:4033 to 192.168.12.29:4033
✅ Forwarding TCP from [::]:4034 to 192.168.12.29:4034
✅ Forwarding TCP from [::1]:4035 to 192.168.12.29:4035
✅ Forwarding TCP from 0.0.0.0:4040 to 127.0.0.1:4040
✅ Forwarding TCP from [::]:4041 to 127.0.0.1:4041
✅ Not forwarding TCP 127.0.0.1:4043
✅ Not forwarding TCP 192.168.5.15:4044
✅ Forwarding TCP from 127.0.0.1:5000 to /Users/norio/.lima/default/sock/port5000.sock
❌ Not forwarding TCP 192.168.5.15:5001
   Message missing from ha.stderr.log
   Guest received: 'Not forwarding TCP 192.168.5.15:5001' (instead of nothing)
✅ Not forwarding TCP 192.168.5.15:5002
✅ Not forwarding TCP 192.168.5.15:5003
✅ Not forwarding TCP 192.168.5.15:5004
😕 Unexpected log message: Forwarding TCP from 192.168.5.15:5001 to 127.0.0.1:5001
```
As far as the source is concerned, 5003 is the same rule as 5004, not 5001.@norio-nomura: Confirmed that #4221 changed to pass the 5001 test.@norio-nomura: With #4221, can it be assumed that the behavior of `portforwards` is the same as the behavior of SSH's `-L` option (except for `guestSocket`)?@jandubois: > As far as the source is concerned, 5003 is the same rule as 5004, not 5001.

Oh, you are right, I was confused.

> Confirmed that https://github.com/lima-vm/lima/pull/4221 changed to pass the 5001 test.

Sounds like you are the perfect person to do the review now. 😄

> With [#4221](https://github.com/lima-vm/lima/pull/4221), can it be assumed that the behavior of `portforwards` is the same as the behavior of SSH's `-L` option (except for `guestSocket`)?

I think so, but I'm slightly hesitant to confirm because I haven't not checked what `ssh -L` is documented to do.

Solution patch:
diff --git a/hack/test-port-forwarding.pl b/hack/test-port-forwarding.pl
index 269b10a8..4c586dbf 100755
--- a/hack/test-port-forwarding.pl
+++ b/hack/test-port-forwarding.pl
@@ -303,16 +303,19 @@ portForwards:
   ignore: true
 
 - guestIP: 0.0.0.0
+  guestIPMustBeZero: false
   guestPortRange: [3010, 3019]
   hostPortRange: [2010, 2019]
   ignore: true
 
 - guestIP: 0.0.0.0
+  guestIPMustBeZero: false
   guestPortRange: [3000, 3029]
   hostPortRange: [2000, 2029]
 
 # The following rule is completely shadowed by the previous one and has no effect
 - guestIP: 0.0.0.0
+  guestIPMustBeZero: false
   guestPortRange: [3020, 3029]
   hostPortRange: [2020, 2029]
   ignore: true
@@ -323,7 +326,7 @@ portForwards:
   # Blocking 127.0.0.2 cannot block forwarding from 0.0.0.0
   # forward: 0.0.0.0   3002 → 127.0.0.1 2002
 
-  # Blocking 0.0.0.0 will block forwarding from any interface
+  # Blocking 0.0.0.0 will block forwarding from any interface because guestIPMustBeZero is false
   # ignore: 0.0.0.0   3010
   # ignore: 127.0.0.1 3011
 
@@ -344,14 +347,41 @@ portForwards:
   # forward: ::        3032 → ipv4 2032
   # forward: ::1       3033 → ipv4 2033
 
-- guestPortRange: [300, 309]
+- guestPortRange: [300, 304]
 
-  # forward: 127.0.0.1 300 → 127.0.0.1 300
+  # forward: 127.0.0.1    300 → 127.0.0.1 300
+  # forward: 0.0.0.0      301 → 127.0.0.1 301
+  # forward: ::           302 → 127.0.0.1 302
+  # forward: ::1          303 → 127.0.0.1 303
+  # ignore:  192.168.5.15 304 → 127.0.0.1 304
 
-- guestPortRange: [310, 319]
+- guestPortRange: [305, 309]
+  guestIPMustBeZero: false
+
+  # forward: 127.0.0.1    325 → 127.0.0.1 325
+  # forward: 0.0.0.0      326 → 127.0.0.1 326
+  # forward: ::           327 → 127.0.0.1 327
+  # forward: ::1          328 → 127.0.0.1 328
+  # ignore:  192.168.5.15 329 → 127.0.0.1 329
+
+- guestPortRange: [310, 314]
+  hostIP: 0.0.0.0
+
+  # forward: 127.0.0.1    310 → 0.0.0.0 310
+  # forward: 0.0.0.0      311 → 0.0.0.0 311
+  # forward: ::           312 → 0.0.0.0 312
+  # forward: ::1          313 → 0.0.0.0 313
+  # ignore:  192.168.5.15 314 → 0.0.0.0 314
+
+- guestPortRange: [315, 319]
+  guestIPMustBeZero: false
   hostIP: 0.0.0.0
 
-  # forward: 127.0.0.1 310 → 0.0.0.0 310
+  # forward: 127.0.0.1    315 → 0.0.0.0 315
+  # forward: 0.0.0.0      316 → 0.0.0.0 316
+  # forward: ::           317 → 0.0.0.0 317
+  # forward: ::1          318 → 0.0.0.0 318
+  # ignore:  192.168.5.15 319 → 0.0.0.0 319
 
   # Things we can't test:
   # - Accessing a forward from a different interface (e.g. connect to ipv4 to connect to 0.0.0.0)
@@ -382,6 +412,7 @@ portForwards:
   # forward: ::1          4025 → ipv4 4025
 
 - guestIP: "0.0.0.0"
+  guestIPMustBeZero: false
   guestPortRange: [4030, 4039]
   hostIP: "ipv4"
 
@@ -396,6 +427,7 @@ portForwards:
   guestPortRange: [4040, 4049]
 
 - guestIP: "0.0.0.0"
+  guestIPMustBeZero: false
   guestPortRange: [4040, 4049]
   ignore: true
 
@@ -414,4 +446,21 @@ portForwards:
 - guestPort: 5000
   hostSocket: port5000.sock
 
-  # forward: 127.0.0.1 5000 → sockDir/port5000.sock
+  # forward: 127.0.0.1    5000 → sockDir/port5000.sock
+
+- guestPort: 5001
+  hostSocket: port5001.sock
+
+  # ignore:  192.168.5.15 5001 → sockDir/port5001.sock
+
+- guestPort: 5002
+  guestIPMustBeZero: false
+  hostSocket: port5002.sock
+
+  # forward: 127.0.0.1    5002 → sockDir/port5002.sock
+
+- guestPort: 5003
+  guestIPMustBeZero: false
+  hostSocket: port5003.sock
+
+  # ignore:  192.168.5.15 5003 → sockDir/port5003.sock
diff --git a/pkg/hostagent/port.go b/pkg/hostagent/port.go
index cf792386..1186a397 100644
--- a/pkg/hostagent/port.go
+++ b/pkg/hostagent/port.go
@@ -69,7 +69,7 @@ func (pf *portForwarder) forwardingAddresses(guest *api.IPPort) (hostAddr, guest
 		case guestIP.IsUnspecified():
 		case guestIP.Equal(rule.GuestIP):
 		case guestIP.Equal(net.IPv6loopback) && rule.GuestIP.Equal(IPv4loopback1):
-		case rule.GuestIP.IsUnspecified() && !rule.GuestIPMustBeZero:
+		case rule.GuestIP.IsUnspecified() && !*rule.GuestIPMustBeZero:
 			// When GuestIPMustBeZero is true, then 0.0.0.0 must be an exact match, which is already
 			// handled above by the guest.IP.IsUnspecified() condition.
 		default:
diff --git a/pkg/limatype/lima_yaml.go b/pkg/limatype/lima_yaml.go
index 0933e45e..360260ca 100644
--- a/pkg/limatype/lima_yaml.go
+++ b/pkg/limatype/lima_yaml.go
@@ -285,7 +285,7 @@ const (
 )
 
 type PortForward struct {
-	GuestIPMustBeZero bool   `yaml:"guestIPMustBeZero,omitempty" json:"guestIPMustBeZero,omitempty"`
+	GuestIPMustBeZero *bool  `yaml:"guestIPMustBeZero,omitempty" json:"guestIPMustBeZero,omitempty"`
 	GuestIP           net.IP `yaml:"guestIP,omitempty" json:"guestIP,omitempty"`
 	GuestPort         int    `yaml:"guestPort,omitempty" json:"guestPort,omitempty"`
 	GuestPortRange    [2]int `yaml:"guestPortRange,omitempty" json:"guestPortRange,omitempty"`
diff --git a/pkg/limayaml/defaults.go b/pkg/limayaml/defaults.go
index 1f617dbd..fc138add 100644
--- a/pkg/limayaml/defaults.go
+++ b/pkg/limayaml/defaults.go
@@ -882,12 +882,15 @@ func FillPortForwardDefaults(rule *limatype.PortForward, instDir string, user li
 		rule.Proto = limatype.ProtoAny
 	}
 	if rule.GuestIP == nil {
-		if rule.GuestIPMustBeZero {
+		if rule.GuestIPMustBeZero != nil && *rule.GuestIPMustBeZero {
 			rule.GuestIP = net.IPv4zero
 		} else {
 			rule.GuestIP = IPv4loopback1
 		}
 	}
+	if rule.GuestIPMustBeZero == nil {
+		rule.GuestIPMustBeZero = ptr.Of(rule.GuestIP.Equal(net.IPv4zero))
+	}
 	if rule.HostIP == nil {
 		rule.HostIP = IPv4loopback1
 	}
diff --git a/pkg/limayaml/validate.go b/pkg/limayaml/validate.go
index e13844e7..f823803b 100644
--- a/pkg/limayaml/validate.go
+++ b/pkg/limayaml/validate.go
@@ -285,7 +285,7 @@ func Validate(y *limatype.LimaYAML, warn bool) error {
 	}
 	for i, rule := range y.PortForwards {
 		field := fmt.Sprintf("portForwards[%d]", i)
-		if rule.GuestIPMustBeZero && !rule.GuestIP.Equal(net.IPv4zero) {
+		if *rule.GuestIPMustBeZero && !rule.GuestIP.Equal(net.IPv4zero) {
 			errs = errors.Join(errs, fmt.Errorf("field `%s.guestIPMustBeZero` can only be true when field `%s.guestIP` is 0.0.0.0", field, field))
 		}
 		if rule.GuestPort != 0 {
diff --git a/pkg/portfwd/forward.go b/pkg/portfwd/forward.go
index e442cc35..003fca40 100644
--- a/pkg/portfwd/forward.go
+++ b/pkg/portfwd/forward.go
@@ -83,9 +83,9 @@ func (fw *Forwarder) forwardingAddresses(guest *api.IPPort) (hostAddr, guestAddr
 		case guestIP.IsUnspecified():
 		case guestIP.Equal(rule.GuestIP):
 		case guestIP.Equal(net.IPv6loopback) && rule.GuestIP.Equal(IPv4loopback1):
-		case rule.GuestIP.IsUnspecified() && !rule.GuestIPMustBeZero:
+		case rule.GuestIP.IsUnspecified() && !*rule.GuestIPMustBeZero:
 			// When GuestIPMustBeZero is true, then 0.0.0.0 must be an exact match, which is already
-			// handled above by the guest.IP.IsUnspecified() condition.
+			// handled above by the guestIP.IsUnspecified() condition.
 		default:
 			continue
 		}
diff --git a/templates/default.yaml b/templates/default.yaml
index 0e28c09d..fcbf7cd4 100644
--- a/templates/default.yaml
+++ b/templates/default.yaml
@@ -491,9 +491,9 @@ networks:
 #   ignore: true     # don't forward these ports (guestPortRange, in this case 1-65535)
 #
 # - guestPort: 7443
-#   guestIP: "0.0.0.0"       # Will match *any* interface
-#   guestIPMustBeZero: true  # Restrict matching to 0.0.0.0 binds only
-#   hostIP: "0.0.0.0"        # Forwards to 0.0.0.0, exposing it externally
+#   guestIP: "0.0.0.0"        # Will match *any* interface
+#   guestIPMustBeZero: false  # 0.0.0.0 matches any bound interface, not just 0.0.0.0 itself
+#   hostIP: "0.0.0.0"         # Forwards to 0.0.0.0, exposing it externally
 #
 # - guestSocket: "/run/user/{{.UID}}/my.sock"
 #   hostSocket: mysocket



Test patch:
diff --git a/pkg/limayaml/defaults_test.go b/pkg/limayaml/defaults_test.go
index 55227992..b52d407f 100644
--- a/pkg/limayaml/defaults_test.go
+++ b/pkg/limayaml/defaults_test.go
@@ -124,12 +124,13 @@ func TestFillDefault(t *testing.T) {
 	}
 
 	defaultPortForward := limatype.PortForward{
-		GuestIP:        IPv4loopback1,
-		GuestPortRange: [2]int{1, 65535},
-		HostIP:         IPv4loopback1,
-		HostPortRange:  [2]int{1, 65535},
-		Proto:          limatype.ProtoAny,
-		Reverse:        false,
+		GuestIP:           IPv4loopback1,
+		GuestIPMustBeZero: ptr.Of(false),
+		GuestPortRange:    [2]int{1, 65535},
+		HostIP:            IPv4loopback1,
+		HostPortRange:     [2]int{1, 65535},
+		Proto:             limatype.ProtoAny,
+		Reverse:           false,
 	}
 
 	// ------------------------------------------------------------------------------------
@@ -386,13 +387,14 @@ func TestFillDefault(t *testing.T) {
 			net.ParseIP("1.1.1.1"),
 		},
 		PortForwards: []limatype.PortForward{{
-			GuestIP:        IPv4loopback1,
-			GuestPort:      80,
-			GuestPortRange: [2]int{80, 80},
-			HostIP:         IPv4loopback1,
-			HostPort:       80,
-			HostPortRange:  [2]int{80, 80},
-			Proto:          limatype.ProtoTCP,
+			GuestIP:           IPv4loopback1,
+			GuestIPMustBeZero: ptr.Of(false),
+			GuestPort:         80,
+			GuestPortRange:    [2]int{80, 80},
+			HostIP:            IPv4loopback1,
+			HostPort:          80,
+			HostPortRange:     [2]int{80, 80},
+			Proto:             limatype.ProtoTCP,
 		}},
 		CopyToHost: []limatype.CopyToHost{{}},
 		Env: map[string]string{
@@ -599,13 +601,14 @@ func TestFillDefault(t *testing.T) {
 			net.ParseIP("2.2.2.2"),
 		},
 		PortForwards: []limatype.PortForward{{
-			GuestIP:        IPv4loopback1,
-			GuestPort:      88,
-			GuestPortRange: [2]int{88, 88},
-			HostIP:         IPv4loopback1,
-			HostPort:       8080,
-			HostPortRange:  [2]int{8080, 8080},
-			Proto:          limatype.ProtoTCP,
+			GuestIP:           IPv4loopback1,
+			GuestIPMustBeZero: ptr.Of(false),
+			GuestPort:         88,
+			GuestPortRange:    [2]int{88, 88},
+			HostIP:            IPv4loopback1,
+			HostPort:          8080,
+			HostPortRange:     [2]int{8080, 8080},
+			Proto:             limatype.ProtoTCP,
 		}},
 		CopyToHost: []limatype.CopyToHost{{}},
 		Env: map[string]string{