已合并
fix(network): enable pool refill on empty pool, bound CNI operations with timeout, and enforce IPv4-only networking #178
ytluo创建于 12 天前
fix(network): enable pool refill on empty pool, bound CNI operations with timeout, and enforce IPv4-only networking #178
已合并
ytluo创建于 12 天前
12 个文件变更+139-6
@@ -2,6 +2,8 @@
2 2 
3Network 模块位于 `internal/netstack`,负责为 Sandbox 创建并复用隔离的网络环境。CNI 管理 Sandbox 对外网络,Conch 管理 network namespace、guest tap 和两层网络之间的地址转换。3Network 模块位于 `internal/netstack`,负责为 Sandbox 创建并复用隔离的网络环境。CNI 管理 Sandbox 对外网络,Conch 管理 network namespace、guest tap 和两层网络之间的地址转换。
4 4 
5+当前 Sandbox 数据面和网络策略仅支持 IPv4。VMM cold boot 会禁用 guest IPv6;CNI 返回 IPv6 地址、路由、网关或 DNS 时,Slot 创建失败并回滚。
6+ 
5## 1. 模块组成7## 1. 模块组成
6 8 
7| 组件 | 职责 |9| 组件 | 职责 |
@@ -265,7 +265,7 @@ sandbox.update_network(
265- 只有拒绝列表时,拒绝匹配地址并允许未匹配流量。265- 只有拒绝列表时,拒绝匹配地址并允许未匹配流量。
266- 允许和拒绝列表都为空时,该方向不受列表限制。266- 允许和拒绝列表都为空时,该方向不受列表限制。
267- `allow_internet_access: false` 会额外拒绝未匹配的出站流量,不影响入站规则。267- `allow_internet_access: false` 会额外拒绝未匹配的出站流量,不影响入站规则。
268-- 当前仅接受 IPv4 地址和 IPv4 CIDR;四个列表合计最多 1024 项。268+- 当前 Sandbox 网络策略仅支持 IPv4;IPv6 策略输入会被拒绝,四个列表合计最多 1024 项。
269 269 
270出站规则挂载在 Linux network namespace 内从 guest tap 发出的转发路径,入站规则挂载在转发到 guest tap 的路径。入站规则只过滤平台已经路由到该沙箱的 IP 流量;它不会创建主机监听端口、公开服务、执行 hostname 路由或修改 HTTP 请求。270出站规则挂载在 Linux network namespace 内从 guest tap 发出的转发路径,入站规则挂载在转发到 guest tap 的路径。入站规则只过滤平台已经路由到该沙箱的 IP 流量;它不会创建主机监听端口、公开服务、执行 hostname 路由或修改 HTTP 请求。
271 271 
@@ -115,6 +115,40 @@ func extractCNIDNS(result *types100.Result) (DNSConfig, error) {
115 })115 })
116}116}
117 117 
118+func validateCNIIPv4Only(result *types100.Result) error {
119+ if result == nil {
120+ return fmt.Errorf("cni returned nil result")
121+ }
122+ for _, ipConfig := range result.IPs {
123+ if ipConfig == nil {
124+ continue
125+ }
126+ if ip := ipConfig.Address.IP; len(ip) > 0 && ip.To4() == nil {
127+ return fmt.Errorf("cni returned unsupported IPv6 address %s", ip)
128+ }
129+ if gateway := ipConfig.Gateway; len(gateway) > 0 && gateway.To4() == nil {
130+ return fmt.Errorf("cni returned unsupported IPv6 gateway %s", gateway)
131+ }
132+ }
133+ for _, route := range result.Routes {
134+ if route == nil {
135+ continue
136+ }
137+ if destination := route.Dst.IP; len(destination) > 0 && destination.To4() == nil {
138+ return fmt.Errorf("cni returned unsupported IPv6 route %s", route.Dst.String())
139+ }
140+ if gateway := route.GW; len(gateway) > 0 && gateway.To4() == nil {
141+ return fmt.Errorf("cni returned unsupported IPv6 route gateway %s", gateway)
142+ }
143+ }
144+ for _, raw := range result.DNS.Nameservers {
145+ if nameserver := net.ParseIP(raw); nameserver != nil && nameserver.To4() == nil {
146+ return fmt.Errorf("cni returned unsupported IPv6 DNS server %s", raw)
147+ }
148+ }
149+ return nil
150+}
151+ 
118// SetupSandboxNetwork performs CNI ADD and extracts the sandbox network result. The caller152// SetupSandboxNetwork performs CNI ADD and extracts the sandbox network result. The caller
119// owns rollback on every error because ADD may have taken effect before failing.153// owns rollback on every error because ADD may have taken effect before failing.
120func (m *CNIManager) SetupSandboxNetwork(ctx context.Context, cniID string, netnsPath string) (CNIResult, error) {154func (m *CNIManager) SetupSandboxNetwork(ctx context.Context, cniID string, netnsPath string) (CNIResult, error) {
@@ -125,6 +159,9 @@ func (m *CNIManager) SetupSandboxNetwork(ctx context.Context, cniID string, netn
125 if err != nil {159 if err != nil {
126 return CNIResult{}, fmt.Errorf("failed to setup cni network: %w", err)160 return CNIResult{}, fmt.Errorf("failed to setup cni network: %w", err)
127 }161 }
162+ if err := validateCNIIPv4Only(result); err != nil {
163+ return CNIResult{}, err
164+ }
128 cniIP, err := extractCNIIP(result)165 cniIP, err := extractCNIIP(result)
129 if err != nil {166 if err != nil {
130 return CNIResult{}, fmt.Errorf("failed to extract cni IP: %w", err)167 return CNIResult{}, fmt.Errorf("failed to extract cni IP: %w", err)
@@ -8,6 +8,7 @@ import (
8 "path/filepath"8 "path/filepath"
9 "sort"9 "sort"
10 "strings"10 "strings"
11+ "time"
11 12 
12 cnilibrary "github.com/containernetworking/cni/libcni"13 cnilibrary "github.com/containernetworking/cni/libcni"
13 "github.com/containernetworking/cni/pkg/invoke"14 "github.com/containernetworking/cni/pkg/invoke"
@@ -15,6 +16,12 @@ import (
15 "github.com/containernetworking/cni/pkg/version"16 "github.com/containernetworking/cni/pkg/version"
16)17)
17 18 
19+const cniPluginTimeout = 20 * time.Second
20+ 
21+func cniContext(parent context.Context) (context.Context, context.CancelFunc) {
22+ return context.WithTimeout(parent, cniPluginTimeout)
23+}
24+ 
18type cniNetwork struct {25type cniNetwork struct {
19 config *cnilibrary.NetworkConfigList26 config *cnilibrary.NetworkConfigList
20 ifName string27 ifName string
@@ -130,8 +137,11 @@ func loadedBridgeNetwork(config *cnilibrary.NetworkConfigList) (string, string,
130}137}
131 138 
132func (b *libCNIBackend) Setup(ctx context.Context, containerID, netnsPath string) (*types100.Result, error) {139func (b *libCNIBackend) Setup(ctx context.Context, containerID, netnsPath string) (*types100.Result, error) {
140+ pluginCtx, cancel := cniContext(ctx)
141+ defer cancel()
142+ 
133 network := b.outerNetwork143 network := b.outerNetwork
134- result, err := b.client.AddNetworkList(ctx, network.config, runtimeConf(containerID, netnsPath, network.ifName))144+ result, err := b.client.AddNetworkList(pluginCtx, network.config, runtimeConf(containerID, netnsPath, network.ifName))
135 if err != nil {145 if err != nil {
136 return nil, fmt.Errorf("add CNI network %q: %w", network.config.Name, err)146 return nil, fmt.Errorf("add CNI network %q: %w", network.config.Name, err)
137 }147 }
@@ -143,8 +153,11 @@ func (b *libCNIBackend) Setup(ctx context.Context, containerID, netnsPath string
143}153}
144 154 
145func (b *libCNIBackend) Remove(ctx context.Context, containerID, netnsPath string) error {155func (b *libCNIBackend) Remove(ctx context.Context, containerID, netnsPath string) error {
156+ pluginCtx, cancel := cniContext(ctx)
157+ defer cancel()
158+ 
146 network := b.outerNetwork159 network := b.outerNetwork
147- if err := b.client.DelNetworkList(ctx, network.config, runtimeConf(containerID, netnsPath, network.ifName)); err != nil {160+ if err := b.client.DelNetworkList(pluginCtx, network.config, runtimeConf(containerID, netnsPath, network.ifName)); err != nil {
148 return fmt.Errorf("delete CNI network %q: %w", network.config.Name, err)161 return fmt.Errorf("delete CNI network %q: %w", network.config.Name, err)
149 }162 }
150 return nil163 return nil
@@ -1,14 +1,44 @@
1package netstack1package netstack
2 2 
3import (3import (
4+ "context"
4 "encoding/json"5 "encoding/json"
5 "os"6 "os"
6 "path/filepath"7 "path/filepath"
7 "testing"8 "testing"
9+ "time"
8 10 
9 cnilibrary "github.com/containernetworking/cni/libcni"11 cnilibrary "github.com/containernetworking/cni/libcni"
10)12)
11 13 
14+func TestCNIContextUsesPluginTimeout(t *testing.T) {
15+ earliestDeadline := time.Now().Add(cniPluginTimeout)
16+ ctx, cancel := cniContext(context.Background())
17+ defer cancel()
18+ latestDeadline := time.Now().Add(cniPluginTimeout)
19+ 
20+ deadline, ok := ctx.Deadline()
21+ if !ok {
22+ t.Fatal("cniContext() has no deadline")
23+ }
24+ if deadline.Before(earliestDeadline) || deadline.After(latestDeadline) {
25+ t.Fatalf("cniContext() deadline = %v, want between %v and %v", deadline, earliestDeadline, latestDeadline)
26+ }
27+}
28+ 
29+func TestCNIContextPreservesShorterCallerDeadline(t *testing.T) {
30+ parent, parentCancel := context.WithTimeout(context.Background(), time.Second)
31+ defer parentCancel()
32+ parentDeadline, _ := parent.Deadline()
33+ 
34+ ctx, cancel := cniContext(parent)
35+ defer cancel()
36+ deadline, ok := ctx.Deadline()
37+ if !ok || !deadline.Equal(parentDeadline) {
38+ t.Fatalf("cniContext() deadline = %v, want caller deadline %v", deadline, parentDeadline)
39+ }
40+}
41+ 
12const testBridgeCNIConfig = `{42const testBridgeCNIConfig = `{
13 "cniVersion": "1.0.0",43 "cniVersion": "1.0.0",
14 "name": "conch-test",44 "name": "conch-test",
@@ -51,6 +51,38 @@ func TestExtractCNIDNSRejectsInvalidExplicitServer(t *testing.T) {
51 }51 }
52}52}
53 53 
54+func TestValidateCNIIPv4OnlyRejectsIPv6(t *testing.T) {
55+ _, ipv6Route, _ := net.ParseCIDR("fd00::/64")
56+ _, ipv4Route, _ := net.ParseCIDR("0.0.0.0/0")
57+ tests := []struct {
58+ name string
59+ result *types100.Result
60+ want string
61+ }{
62+ {name: "address", result: &types100.Result{IPs: []*types100.IPConfig{{Address: net.IPNet{IP: net.ParseIP("fd00::2")}}}}, want: "IPv6 address"},
63+ {name: "gateway", result: &types100.Result{IPs: []*types100.IPConfig{{Gateway: net.ParseIP("fd00::1")}}}, want: "IPv6 gateway"},
64+ {name: "route", result: &types100.Result{Routes: []*cnitypes.Route{{Dst: *ipv6Route}}}, want: "IPv6 route"},
65+ {name: "route gateway", result: &types100.Result{Routes: []*cnitypes.Route{{Dst: *ipv4Route, GW: net.ParseIP("fd00::1")}}}, want: "IPv6 route gateway"},
66+ {name: "DNS", result: &types100.Result{DNS: cnitypes.DNS{Nameservers: []string{"2001:4860:4860::8888"}}}, want: "IPv6 DNS"},
67+ }
68+ 
69+ for _, tt := range tests {
70+ t.Run(tt.name, func(t *testing.T) {
71+ if err := validateCNIIPv4Only(tt.result); err == nil || !strings.Contains(err.Error(), tt.want) {
72+ t.Fatalf("validateCNIIPv4Only() error = %v, want substring %q", err, tt.want)
73+ }
74+ })
75+ }
76+ 
77+ if err := validateCNIIPv4Only(&types100.Result{
78+ IPs: []*types100.IPConfig{{Address: net.IPNet{IP: net.ParseIP("10.12.0.2")}, Gateway: net.ParseIP("10.12.0.1")}},
79+ Routes: []*cnitypes.Route{{Dst: *ipv4Route, GW: net.ParseIP("10.12.0.1")}},
80+ DNS: cnitypes.DNS{Nameservers: []string{"8.8.8.8"}},
81+ }); err != nil {
82+ t.Fatalf("validateCNIIPv4Only() rejected IPv4 result: %v", err)
83+ }
84+}
85+ 
54func TestNormalizeCNIManagerConfigPreservesExplicitValues(t *testing.T) {86func TestNormalizeCNIManagerConfigPreservesExplicitValues(t *testing.T) {
55 in := CNIManagerConfig{87 in := CNIManagerConfig{
56 PluginBinDirs: []string{"/custom/bin"},88 PluginBinDirs: []string{"/custom/bin"},
@@ -133,7 +165,6 @@ func TestExtractCNIIP(t *testing.T) {
133 result := &types100.Result{165 result := &types100.Result{
134 Interfaces: []*types100.Interface{{Name: "eth0"}, {Name: "host"}},166 Interfaces: []*types100.Interface{{Name: "eth0"}, {Name: "host"}},
135 IPs: []*types100.IPConfig{167 IPs: []*types100.IPConfig{
136- {Interface: types100.Int(0), Address: net.IPNet{IP: net.ParseIP("fd00::2")}},
137 {Interface: types100.Int(0), Address: net.IPNet{IP: net.ParseIP("10.12.0.2")}},168 {Interface: types100.Int(0), Address: net.IPNet{IP: net.ParseIP("10.12.0.2")}},
138 },169 },
139 }170 }
@@ -494,6 +494,7 @@ func (p *Pool) get(ctx context.Context, sandboxID string) (*Slot, error) {
494 return nil, err494 return nil, err
495 }495 }
496 if errors.Is(err, errWarmPoolEmpty) {496 if errors.Is(err, errWarmPoolEmpty) {
497+ p.signalRefillNeeded()
497 available, capacity := p.warmSlots.Usage()498 available, capacity := p.warmSlots.Usage()
498 ulog.GetLogger().Warn(499 ulog.GetLogger().Warn(
499 "no available network slot in the pool",500 "no available network slot in the pool",
@@ -532,3 +532,20 @@ func TestGetAssignsWarmSlot(t *testing.T) {
532 t.Fatal("Get() did not signal refill")532 t.Fatal("Get() did not signal refill")
533 }533 }
534}534}
535+ 
536+func TestGetEmptyPoolSignalsRefill(t *testing.T) {
537+ p := &Pool{
538+ warmSlots: slotstate.NewQueue[*Slot](1),
539+ refillNeeded: make(chan struct{}, 1),
540+ }
541+ 
542+ _, err := p.Get(context.Background(), "sandbox-a", nil)
543+ if !errors.Is(err, errWarmPoolEmpty) {
544+ t.Fatalf("Get() error = %v, want %v", err, errWarmPoolEmpty)
545+ }
546+ select {
547+ case <-p.refillNeeded:
548+ default:
549+ t.Fatal("Get() did not signal refill for an empty pool")
550+ }
551+}
@@ -39,7 +39,7 @@ const startScriptCLH = `{{ .NSenterPath }} --net={{ .NetNSPath }} -- \
39{{ .PmemArgs }} \39{{ .PmemArgs }} \
40--memory "size=0" \40--memory "size=0" \
41--memory-zone "id=mem0,size={{ .MemorySize }},file={{ .MemoryPath }},shared=on" \41--memory-zone "id=mem0,size={{ .MemorySize }},file={{ .MemoryPath }},shared=on" \
42---cmdline "console=hvc0 root=/dev/ram0 rw debug conch.sandbox_id={{ .SandboxId }}{{ .SharefsCmdline }}" \42+--cmdline "console=hvc0 root=/dev/ram0 rw debug ipv6.disable=1 conch.sandbox_id={{ .SandboxId }}{{ .SharefsCmdline }}" \
43--api-socket fd={{ .ApiSocketFd }} \43--api-socket fd={{ .ApiSocketFd }} \
44--console null \44--console null \
45--net "tap={{ .TapName }}" \45--net "tap={{ .TapName }}" \
@@ -50,6 +50,7 @@ func TestBuildStartCmdUsesConchNetNSPath(t *testing.T) {
50 binPath,50 binPath,
51 `--net "tap=tap0"`,51 `--net "tap=tap0"`,
52 "conch.sandbox_id=sandbox-test",52 "conch.sandbox_id=sandbox-test",
53+ "ipv6.disable=1",
53 } {54 } {
54 if !strings.Contains(script, want) {55 if !strings.Contains(script, want) {
55 t.Fatalf("script missing %q:\n%s", want, script)56 t.Fatalf("script missing %q:\n%s", want, script)
@@ -46,7 +46,7 @@ const startScriptStratovirt = `{{ .NSenterPath }} --net={{ .NetNSPath }} -- \
46-machine {{ .MachineType }}{{ .MachineOpts }} \46-machine {{ .MachineType }}{{ .MachineOpts }} \
47-kernel {{ .KernelPath }} \47-kernel {{ .KernelPath }} \
48-initrd {{ .RootfsPath }} \48-initrd {{ .RootfsPath }} \
49--append "console={{ .ConsoleDevice }} reboot=k quiet panic=1 root=/dev/ram0 rw conch.sandbox_id={{ .SandboxId }}{{ .SharefsCmdline }}" \49+-append "console={{ .ConsoleDevice }} reboot=k quiet panic=1 root=/dev/ram0 rw ipv6.disable=1 conch.sandbox_id={{ .SandboxId }}{{ .SharefsCmdline }}" \
50-m {{ .MemorySize }}M \50-m {{ .MemorySize }}M \
51-smp {{ .CPUBoot }} \51-smp {{ .CPUBoot }} \
52-qmp unix:{{ .VmmSocket }},server,nowait \52-qmp unix:{{ .VmmSocket }},server,nowait \
@@ -48,6 +48,7 @@ func TestStratovirtBuildStartCmd(t *testing.T) {
48 "-qmp unix:/tmp/conch-qmp.sock,server,nowait",48 "-qmp unix:/tmp/conch-qmp.sock,server,nowait",
49 "-device vhost-vsock-pci,id=vsock0,guest-cid=42",49 "-device vhost-vsock-pci,id=vsock0,guest-cid=42",
50 "conch.sandbox_id=sandbox-test",50 "conch.sandbox_id=sandbox-test",
51+ "ipv6.disable=1",
51 "-m 1024M",52 "-m 1024M",
52 } {53 } {
53 if !strings.Contains(script, want) {54 if !strings.Contains(script, want) {