Skip to content

Commit 1bf5c85

Browse files
committed
Add network policy filtering for user-v2 networking
Implements egress traffic filtering with: - Protocol, port, IP/CIDR, and domain-based rules - DNS packet snooping for domain-to-IP tracking - ICMP support (ICMPv4/ICMPv6) - partial - awaiting gvisor fix - Policy validation with strict error checking - DNS tracker with 10k domain limit and TTL expiration Usage: limactl network create NAME --policy policy.yaml Signed-off-by: Simon Kaegi <simon.kaegi@gmail.com>
1 parent e21b634 commit 1bf5c85

File tree

17 files changed

+3503
-1
lines changed

17 files changed

+3503
-1
lines changed

cmd/limactl/network.go

Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -10,6 +10,7 @@ import (
1010
"maps"
1111
"net"
1212
"os"
13+
"path/filepath"
1314
"slices"
1415
"strings"
1516
"text/tabwriter"
@@ -18,12 +19,17 @@ import (
1819
"github.com/spf13/cobra"
1920

2021
"github.com/lima-vm/lima/v2/pkg/networks"
22+
"github.com/lima-vm/lima/v2/pkg/networks/usernet"
23+
"github.com/lima-vm/lima/v2/pkg/networks/usernet/filter"
2124
"github.com/lima-vm/lima/v2/pkg/yqutil"
2225
)
2326

2427
const networkCreateExample = ` Create a network:
2528
$ limactl network create foo --gateway 192.168.42.1/24
2629
30+
Create a network with policy filtering:
31+
$ limactl network create secure --gateway 192.168.42.1/24 --policy ~/policy.yaml
32+
2733
Connect VM instances to the newly created network:
2834
$ limactl create --network lima:foo --name vm1
2935
$ limactl create --network lima:foo --name vm2
@@ -144,6 +150,7 @@ func newNetworkCreateCommand() *cobra.Command {
144150
flags.String("gateway", "", "gateway, e.g., \"192.168.42.1/24\"")
145151
flags.String("interface", "", "interface for bridged mode")
146152
_ = cmd.RegisterFlagCompletionFunc("interface", bashFlagCompleteNetworkInterfaceNames)
153+
flags.String("policy", "", "path to policy file (YAML or JSON, user-v2 mode only)")
147154
return cmd
148155
}
149156

@@ -174,6 +181,38 @@ func networkCreateAction(cmd *cobra.Command, args []string) error {
174181
return err
175182
}
176183

184+
policyPath, err := flags.GetString("policy")
185+
if err != nil {
186+
return err
187+
}
188+
189+
// Handle policy file if provided
190+
if policyPath != "" {
191+
// Only user-v2 mode supports filtering
192+
if mode != networks.ModeUserV2 {
193+
logrus.Warnf("Policy filtering is only supported for mode 'user-v2', ignoring --policy flag")
194+
} else {
195+
// Load the policy to validate it
196+
pol, err := filter.LoadPolicy(policyPath)
197+
if err != nil {
198+
return fmt.Errorf("failed to load policy: %w", err)
199+
}
200+
201+
// Save as JSON in the network directory (~/.lima/_networks/<name>/policy.json)
202+
policyJSONPath, err := usernet.PolicyFile(name)
203+
if err != nil {
204+
return fmt.Errorf("failed to get policy path: %w", err)
205+
}
206+
// Ensure network directory exists (follows usernet convention)
207+
if err := os.MkdirAll(filepath.Dir(policyJSONPath), 0o755); err != nil {
208+
return fmt.Errorf("failed to create network directory: %w", err)
209+
}
210+
if err := filter.SavePolicyJSON(pol, policyJSONPath); err != nil {
211+
return fmt.Errorf("failed to save policy: %w", err)
212+
}
213+
}
214+
}
215+
177216
switch mode {
178217
case networks.ModeBridged:
179218
if gateway != "" {

cmd/limactl/usernet.go

Lines changed: 20 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,11 @@ import (
1111
"strconv"
1212
"syscall"
1313

14+
"github.com/sirupsen/logrus"
1415
"github.com/spf13/cobra"
1516

1617
"github.com/lima-vm/lima/v2/pkg/networks/usernet"
18+
"github.com/lima-vm/lima/v2/pkg/networks/usernet/filter"
1719
)
1820

1921
func newUsernetCommand() *cobra.Command {
@@ -31,6 +33,7 @@ func newUsernetCommand() *cobra.Command {
3133
hostagentCommand.Flags().String("subnet", "192.168.5.0/24", "Sets subnet value for the usernet network")
3234
hostagentCommand.Flags().Int("mtu", 1500, "mtu")
3335
hostagentCommand.Flags().StringToString("leases", nil, "Pass default static leases for startup. Eg: '192.168.104.1=52:55:55:b3:bc:d9,192.168.104.2=5a:94:ef:e4:0c:df' ")
36+
hostagentCommand.Flags().String("policy", "", "Path to policy JSON file")
3437
return hostagentCommand
3538
}
3639

@@ -75,6 +78,22 @@ func usernetAction(cmd *cobra.Command, _ []string) error {
7578
return err
7679
}
7780

81+
policyPath, err := cmd.Flags().GetString("policy")
82+
if err != nil {
83+
return err
84+
}
85+
86+
// Parse the policy at the CLI boundary (fail fast on invalid policy)
87+
var policy *filter.Policy
88+
if policyPath != "" {
89+
logrus.Debugf("Loading policy from: %s", policyPath)
90+
policy, err = filter.LoadPolicy(policyPath)
91+
if err != nil {
92+
return fmt.Errorf("failed to load policy: %w", err)
93+
}
94+
logrus.Debugf("Loaded policy with %d rules", len(policy.Rules))
95+
}
96+
7897
os.RemoveAll(endpoint)
7998
os.RemoveAll(qemuSocket)
8099
os.RemoveAll(fdSocket)
@@ -92,5 +111,6 @@ func usernetAction(cmd *cobra.Command, _ []string) error {
92111
FdSocket: fdSocket,
93112
Subnet: subnet,
94113
DefaultLeases: leases,
114+
Policy: policy,
95115
})
96116
}

pkg/networks/usernet/config.go

Lines changed: 10 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -112,6 +112,16 @@ func Leases(name string) (string, error) {
112112
return sockPath, nil
113113
}
114114

115+
// PolicyFile returns the path to the policy JSON file for the given network name.
116+
// For usernet, this is stored in ~/.lima/_networks/<name>/policy.json (not VarRun).
117+
func PolicyFile(name string) (string, error) {
118+
dir, err := dirnames.LimaNetworksDir()
119+
if err != nil {
120+
return "", err
121+
}
122+
return filepath.Join(dir, name, "policy.json"), nil
123+
}
124+
115125
func netmaskToCidr(baseIP, netMask net.IP) (net.IP, *net.IPNet, error) {
116126
size, _ := net.IPMask(netMask.To4()).Size()
117127
return net.ParseCIDR(fmt.Sprintf("%s/%d", baseIP.String(), size))
Lines changed: 133 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,133 @@
1+
# Network Policy Filtering
2+
3+
This package implements egress (outbound) traffic filtering for Lima's user-v2 networking using gVisor's netstack.
4+
5+
## Architecture
6+
7+
### Components
8+
9+
1. **Policy** (`policy.go`) - YAML/JSON policy parsing and validation
10+
2. **DNS Tracker** (`dns.go`) - Tracks domain-to-IP mappings from DNS queries
11+
3. **DNS Snooper** (`dnssnooper.go`) - Intercepts DNS responses to populate tracker
12+
4. **Rules** (`rules.go`) - Converts policy rules to gVisor iptables rules
13+
5. **Filter** (`filter.go`) - Main entry point, wraps VirtualNetwork with filtering
14+
15+
### Flow
16+
17+
```
18+
Policy File (YAML)
19+
20+
LoadPolicy() → Policy struct
21+
22+
virtualnetwork.New() → VirtualNetwork
23+
24+
Filter(vn, policy) → FilteredVirtualNetwork
25+
26+
BuildFilterTable() → gVisor iptables rules
27+
28+
Packets evaluated by matchers:
29+
- DNS Snooper (tracks DNS)
30+
- Protocol Matcher (TCP/UDP/ICMP)
31+
- Port Matcher (destination ports)
32+
- IP Matcher (destination IPs/CIDRs)
33+
34+
Allow or Drop
35+
```
36+
37+
### DNS Tracking via Snooping
38+
39+
Domain-based rules require knowing which IPs belong to which domains. We achieve this by:
40+
41+
1. Installing a DNS snooper matcher as the first iptables rule
42+
2. The snooper inspects all UDP port 53 source traffic (DNS responses)
43+
3. Parses DNS wire format to extract domain→IP mappings and TTL
44+
4. Updates the tracker with TTL-based expiration
45+
5. Snooper always returns `false` (never matches), just has side effects
46+
47+
This approach is:
48+
- **Lightweight**: No extra DNS proxy needed
49+
- **Automatic**: Works with any DNS server
50+
- **Accurate**: Uses actual DNS TTL for cache expiration
51+
52+
### Filter Table Structure
53+
54+
For each policy, we build both IPv4 and IPv6 filter tables with OUTPUT chain rules:
55+
56+
```
57+
Rule 1: DNS Snooper (always returns false)
58+
Rule 2-N: Policy rules (sorted by priority)
59+
Rule N+1: Default DROP
60+
```
61+
62+
### Matchers
63+
64+
Custom matchers implement `stack.Matcher` interface:
65+
66+
- **protocolMatcher**: Matches transport protocol (TCP/UDP/ICMP/ICMPv6)
67+
- **portMatcher**: Matches destination port or port range
68+
- **ipMatcher**: Matches destination IP against CIDR list
69+
- **dnsSnooper**: Parses DNS responses, never matches
70+
71+
### Memory Management
72+
73+
- DNS tracker limited to 10,000 domains (configurable via `MaxDNSRecords`)
74+
- Expired entries cleaned every 5 minutes
75+
- When at capacity, expired entries cleaned first, then oldest evicted
76+
- Uses `sync.RWMutex` for concurrent access
77+
78+
## Usage
79+
80+
### Basic Usage
81+
82+
```go
83+
// Load policy
84+
policy, err := filter.LoadPolicy("/path/to/policy.yaml")
85+
86+
// Create virtual network
87+
vn, err := virtualnetwork.New(config)
88+
89+
// Apply filtering
90+
fvn, err := filter.Filter(vn, policy)
91+
92+
// Use the underlying virtual network
93+
network := fvn.VirtualNetwork()
94+
```
95+
96+
### Policy Format
97+
98+
See `policy.yaml` for example or user documentation at:
99+
https://lima-vm.io/docs/config/network/policy/
100+
101+
## Testing
102+
103+
- `filter_test.go` - Integration tests with gVisor stack
104+
- `dnssnooper_test.go` - DNS parsing and snooping tests
105+
- All tests use real gVisor stack, not mocks
106+
107+
Run tests:
108+
```bash
109+
go test ./pkg/networks/usernet/filter/...
110+
```
111+
112+
## Limitations
113+
114+
1. **DNS only**: Domain tracking only works with standard DNS (UDP port 53)
115+
- DoH (DNS over HTTPS) not supported
116+
- DoT (DNS over TLS) not supported
117+
118+
2. **Egress only**: Only outbound traffic is filtered
119+
- Ingress filtering not implemented
120+
121+
3. **ICMP support is incomplete**: no ICMP forwarder
122+
- See https://github.com/containers/gvisor-tap-vsock/issues/428
123+
124+
4. **Policy immutable**: Policy cannot be updated after Filter() is called
125+
- Requires network restart to apply new policy, by design
126+
`
127+
128+
## Future Enhancements
129+
130+
- [ ] Audit logging
131+
- [ ] Ingress filtering
132+
- [ ] ICMP Forwarder
133+

0 commit comments

Comments
 (0)