From 46d7011e17c9b64840137463a00f23315f82eee0 Mon Sep 17 00:00:00 2001 From: tonic Date: Sun, 28 Jun 2026 22:37:54 +0800 Subject: [PATCH] feat: add --isolated to fence same-node VMs at the bridge Set the Linux bridge "isolated" flag on a VM's host port (run/clone --isolated) so VMs sharing a node/bridge cannot reach each other; gateway, egress and routed traffic are unaffected. Persisted in the VM config and re-applied on NIC recover. Covers both the TAP-on-bridge backend and the CNI backend (host veth resolved from the CNI ADD result). --- cmd/core/helpers.go | 4 ++++ cmd/vm/commands.go | 2 ++ network/bridge/bridge_linux.go | 6 ++++++ network/cni/lifecycle.go | 27 +++++++++++++++++++++++++++ network/cni/lifecycle_darwin.go | 4 ++++ network/cni/lifecycle_linux.go | 10 ++++++++++ types/config.go | 4 ++++ 7 files changed, 57 insertions(+) diff --git a/cmd/core/helpers.go b/cmd/core/helpers.go index d6a2ce4e..a290be43 100644 --- a/cmd/core/helpers.go +++ b/cmd/core/helpers.go @@ -292,6 +292,7 @@ func VMConfigFromFlags(cmd *cobra.Command, image string) (*types.VMConfig, error noDirectIO, _ := cmd.Flags().GetBool("no-direct-io") windows, _ := cmd.Flags().GetBool("windows") sharedMemory, _ := cmd.Flags().GetBool("shared-memory") + isolated, _ := cmd.Flags().GetBool("isolated") dataDiskRaw, _ := cmd.Flags().GetStringArray("data-disk") if vmName == "" { @@ -325,6 +326,7 @@ func VMConfigFromFlags(cmd *cobra.Command, image string) (*types.VMConfig, error NoDirectIO: noDirectIO, Windows: windows, SharedMemory: sharedMemory, + Isolated: isolated, }, User: user, Password: password, @@ -350,6 +352,7 @@ func CloneVMConfigFromFlags(cmd *cobra.Command, snapCfg types.SnapshotConfig) (* } onDemand, _ := cmd.Flags().GetBool("on-demand") + isolated, _ := cmd.Flags().GetBool("isolated") return &types.VMConfig{ Name: vmName, @@ -366,6 +369,7 @@ func CloneVMConfigFromFlags(cmd *cobra.Command, snapCfg types.SnapshotConfig) (* NoDirectIO: noDirectIO, Windows: snapCfg.Windows, SharedMemory: snapCfg.SharedMemory, + Isolated: isolated, }, OnDemand: onDemand, }, nil diff --git a/cmd/vm/commands.go b/cmd/vm/commands.go index 041468c9..56980f2c 100644 --- a/cmd/vm/commands.go +++ b/cmd/vm/commands.go @@ -287,6 +287,7 @@ func addVMFlags(cmd *cobra.Command) { cmd.Flags().Bool("no-direct-io", false, "disable O_DIRECT on writable disks (use page cache instead; CH only)") cmd.Flags().Bool("windows", false, "Windows guest (UEFI boot, kvm_hyperv=on, no cidata)") cmd.Flags().Bool("shared-memory", false, "enable CH memory shared=on; required to attach vhost-user-fs later (CH only, fixed for VM lifetime)") + cmd.Flags().Bool("isolated", false, "isolate this VM's host bridge port so same-node VMs cannot reach each other (egress/gateway unaffected)") cmd.Flags().StringArray("data-disk", nil, "extra data disk: size=20G[,name=...][,fstype=ext4|none][,mount=/mnt/x][,directio=on|off|auto]; repeatable") } @@ -299,6 +300,7 @@ func addCloneFlags(cmd *cobra.Command) { cmd.Flags().String("bridge", "", "use TAP-on-bridge instead of CNI (value is bridge device, e.g. cni0)") cmd.Flags().Bool("no-direct-io", false, "disable O_DIRECT on writable disks (inherit from snapshot if not set)") cmd.Flags().Bool("on-demand", false, "use UFFD on-demand memory loading for faster clone (CH only; snapshot file must remain on disk)") + cmd.Flags().Bool("isolated", false, "isolate this VM's host bridge port so same-node VMs cannot reach each other (egress/gateway unaffected)") cmd.Flags().Bool("pull", false, "auto-pull base image if not found locally (for cross-node clone)") cmd.Flags().String("from-dir", "", "clone from a snapshot directory (must contain snapshot.json) instead of the local snapshot DB; mutually exclusive with positional SNAPSHOT") } diff --git a/network/bridge/bridge_linux.go b/network/bridge/bridge_linux.go index 27fa1e43..862e4f74 100644 --- a/network/bridge/bridge_linux.go +++ b/network/bridge/bridge_linux.go @@ -105,6 +105,12 @@ func (b *Bridge) Add(ctx context.Context, vmID string, vmCfg *types.VMConfig, sp return nil, fmt.Errorf("add %s to %s: %w", name, b.bridgeDev, mErr) } + if vmCfg.Isolated { + if iErr := netlink.LinkSetIsolated(tap, true); iErr != nil { + return nil, fmt.Errorf("isolate %s: %w", name, iErr) + } + } + _ = netlink.LinkSetLearning(tap, false) if mtu := br.Attrs().MTU; mtu > 0 { _ = netlink.LinkSetMTU(tap, mtu) diff --git a/network/cni/lifecycle.go b/network/cni/lifecycle.go index 4c42f275..ed4044a8 100644 --- a/network/cni/lifecycle.go +++ b/network/cni/lifecycle.go @@ -6,6 +6,7 @@ import ( "fmt" "io/fs" "os" + "strings" "github.com/containernetworking/cni/libcni" cnitypes "github.com/containernetworking/cni/pkg/types" @@ -106,6 +107,16 @@ func (c *CNI) Add(ctx context.Context, vmID string, vmCfg *types.VMConfig, specs return nil, fmt.Errorf("parse CNI result: %w", parseErr) } + if vmCfg.Isolated { + hostVeth, vErr := hostVethFromResult(cniResult) + if vErr != nil { + return nil, fmt.Errorf("isolate %s: %w", vmID, vErr) + } + if iErr := setPortIsolated(hostVeth); iErr != nil { + return nil, fmt.Errorf("isolate %s port %s: %w", vmID, hostVeth, iErr) + } + } + var overrideMAC string if spec.Existing != nil { overrideMAC = spec.Existing.MAC @@ -211,6 +222,22 @@ func ensureNetns(name, nsPath string) (bool, error) { return true, nil } +// hostVethFromResult returns the host-side veth name the bridge CNI plugin +// created. The bridge plugin reports it with an empty Sandbox and a "veth" +// name prefix (the bridge device itself, e.g. cni0, also has an empty Sandbox). +func hostVethFromResult(result cnitypes.Result) (string, error) { + r, err := current.NewResultFromResult(result) + if err != nil { + return "", fmt.Errorf("convert CNI result: %w", err) + } + for _, iface := range r.Interfaces { + if iface.Sandbox == "" && strings.HasPrefix(iface.Name, "veth") { + return iface.Name, nil + } + } + return "", errors.New("no host veth in CNI result") +} + // extractNetworkInfo converts a CNI ADD result into types.Network. func extractNetworkInfo(result cnitypes.Result) (*types.Network, error) { newResult, err := current.NewResultFromResult(result) diff --git a/network/cni/lifecycle_darwin.go b/network/cni/lifecycle_darwin.go index 380e7411..6de57be7 100644 --- a/network/cni/lifecycle_darwin.go +++ b/network/cni/lifecycle_darwin.go @@ -22,3 +22,7 @@ func setupTCRedirect(_, _, _ string, _ int, _ string) (string, error) { func deleteTAPInNetns(_, _ string) error { return errNotSupported } + +func setPortIsolated(_ string) error { + return errNotSupported +} diff --git a/network/cni/lifecycle_linux.go b/network/cni/lifecycle_linux.go index 39800604..e133be78 100644 --- a/network/cni/lifecycle_linux.go +++ b/network/cni/lifecycle_linux.go @@ -52,6 +52,16 @@ func deleteNetns(ctx context.Context, name string) error { }) } +// setPortIsolated sets the bridge "isolated" flag on a host port so it cannot +// forward frames to other isolated ports on the same bridge. +func setPortIsolated(name string) error { + link, err := netlink.LinkByName(name) + if err != nil { + return fmt.Errorf("find %s: %w", name, err) + } + return netlink.LinkSetIsolated(link, true) +} + // deleteTAPInNetns deletes a named TAP device inside target netns. func deleteTAPInNetns(nsPath, tapName string) error { return cns.WithNetNSPath(nsPath, func(_ cns.NetNS) error { diff --git a/types/config.go b/types/config.go index 34acd851..7630055f 100644 --- a/types/config.go +++ b/types/config.go @@ -21,4 +21,8 @@ type Config struct { Windows bool `json:"windows,omitempty"` // Windows guest: UEFI boot, kvm_hyperv=on, no cidata // SharedMemory toggles CH memory shared=on (vhost-user-fs prerequisite); fixed at create, persists through clone/restore. SharedMemory bool `json:"shared_memory,omitempty"` + // Isolated sets the bridge "isolated" flag on the VM's host port so VMs on + // the same node/bridge cannot reach each other (they can still reach the + // gateway and route out). Re-applied on NIC recover. + Isolated bool `json:"isolated,omitempty"` }