已合并
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
已合并
共 12 个文件变更+139-6
| @@ -2,6 +2,8 @@ | |||
| 2 | 2 | ||
| 3 | Network 模块位于 `internal/netstack`,负责为 Sandbox 创建并复用隔离的网络环境。CNI 管理 Sandbox 对外网络,Conch 管理 network namespace、guest tap 和两层网络之间的地址转换。 | 3 | Network 模块位于 `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 caller | 152 | // 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. |
| 120 | func (m *CNIManager) SetupSandboxNetwork(ctx context.Context, cniID string, netnsPath string) (CNIResult, error) { | 154 | func (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 | + | ||
| 18 | type cniNetwork struct { | 25 | type cniNetwork struct { |
| 19 | config *cnilibrary.NetworkConfigList | 26 | config *cnilibrary.NetworkConfigList |
| 20 | ifName string | 27 | ifName string |
| @@ -130,8 +137,11 @@ func loadedBridgeNetwork(config *cnilibrary.NetworkConfigList) (string, string, | |||
| 130 | } | 137 | } |
| 131 | 138 | ||
| 132 | func (b *libCNIBackend) Setup(ctx context.Context, containerID, netnsPath string) (*types100.Result, error) { | 139 | func (b *libCNIBackend) Setup(ctx context.Context, containerID, netnsPath string) (*types100.Result, error) { |
| 140 | + pluginCtx, cancel := cniContext(ctx) | ||
| 141 | + defer cancel() | ||
| 142 | + | ||
| 133 | network := b.outerNetwork | 143 | 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 | ||
| 145 | func (b *libCNIBackend) Remove(ctx context.Context, containerID, netnsPath string) error { | 155 | func (b *libCNIBackend) Remove(ctx context.Context, containerID, netnsPath string) error { |
| 156 | + pluginCtx, cancel := cniContext(ctx) | ||
| 157 | + defer cancel() | ||
| 158 | + | ||
| 146 | network := b.outerNetwork | 159 | 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 nil | 163 | return nil |
| @@ -1,14 +1,44 @@ | |||
| 1 | package netstack | 1 | package netstack |
| 2 | 2 | ||
| 3 | import ( | 3 | import ( |
| 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 | + | ||
| 12 | const testBridgeCNIConfig = `{ | 42 | const 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 | + | ||
| 54 | func TestNormalizeCNIManagerConfigPreservesExplicitValues(t *testing.T) { | 86 | func 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, err | 494 | 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) { |