YueGuobin 187201a08c
feat(controller): offer QEMU the kernel datapath, gated per compute
Kernel eligibility becomes "both endpoints on one compute, each able to
anchor": Docker always (adapters are born as veth pairs), QEMU when that
compute's uBridge can create the persistent TAPs its adapters anchor on. The
question is asked per compute, because the link needs both ends anchored --
so a mixed or half-upgraded deployment degrades to the relay instead of
failing an attach.

probe_tap_support() creates and deletes one throwaway persistent TAP through a
scratch uBridge, cached by binary identity like the tc report; /capabilities
exposes `ubridge_tap` (absent = unknown, which keeps links on the relay).

The compute route for a QEMU adapter NIO took UDPNIO only, so a kernel link
was rejected with 422: its body and response now accept
Union[UDPNIO, BridgeNIO] exactly like Docker's.

Stale "Docker-to-Docker" wording in the config, link and MCP descriptions now
talks about anchors, and docs/features/qemu-kernel-datapath.md documents the
datapath -- verified end-to-end on a real instance: 32/32 kernel-datapath
checks and 29/29 on the relay variant.
2026-10-06 00:21:08 +08:00

282 lines
9.3 KiB
Python

#
# Copyright (C) 2020 GNS3 Technologies Inc.
#
# This program is free software: you can redistribute it and/or modify
# it under the terms of the GNU General Public License as published by
# the Free Software Foundation, either version 3 of the License, or
# (at your option) any later version.
#
# This program is distributed in the hope that it will be useful,
# but WITHOUT ANY WARRANTY; without even the implied warranty of
# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the
# GNU General Public License for more details.
#
# You should have received a copy of the GNU General Public License
# along with this program. If not, see <http://www.gnu.org/licenses/>.
from enum import Enum
from typing import List, Optional, Tuple
from uuid import UUID, uuid4
from pydantic import BaseModel, Field, field_validator
from .labels import Label
class LinkNode(BaseModel):
"""
Link node data.
"""
node_id: UUID
adapter_number: int
port_number: int
label: Optional[Label] = None
class LinkType(str, Enum):
"""
Link type.
"""
ethernet = "ethernet"
serial = "serial"
class LinkStyle(BaseModel):
color: Optional[str] = None
width: Optional[int] = None
type: Optional[int] = None
link_type: Optional[str] = None
bezier_curviness: Optional[int] = None
flowchart_roundness: Optional[int] = None
control_offset: Optional[Tuple[float, float]] = None
class LinkBase(BaseModel):
"""
Link data.
"""
nodes: Optional[List[LinkNode]] = Field(None, min_length=0, max_length=2)
suspend: Optional[bool] = None
link_style: Optional[LinkStyle] = None
filters: Optional[dict] = None
markers: Optional[dict] = Field(
None, description="Traffic-insight markers on this link: name → {bpf, tag, enabled}"
)
show_filters_icon: Optional[bool] = Field(True, description="Show filters icon in Web UI")
class LinkCreate(LinkBase):
link_id: UUID = Field(default_factory=uuid4)
nodes: List[LinkNode] = Field(..., min_length=2, max_length=2)
class LinkUpdate(LinkBase):
pass
class Link(LinkBase):
link_id: UUID
project_id: Optional[UUID] = None
link_type: Optional[LinkType] = None
kernel_datapath: Optional[bool] = Field(
None,
description="Read only property. True when this link is wired through kernel interfaces "
"(veth or TAP anchors enslaved to a per-link Linux bridge, both endpoints on one compute) "
"instead of the uBridge UDP relay",
)
capturing: Optional[bool] = Field(None, description="Read only property. True if a capture running on the link")
capture_file_name: Optional[str] = Field(
None, description="Read only property. The name of the capture file if a capture is running"
)
capture_file_path: Optional[str] = Field(
None, description="Read only property. The full path of the capture file if a capture is running"
)
capture_compute_id: Optional[str] = Field(
None, description="Read only property. The compute identifier where a capture is running"
)
wireshark: Optional[bool] = Field(
False, description="Read only property. True if a Web Wireshark session is active on the link"
)
class UDPPortInfo(BaseModel):
"""
UDP port information.
"""
node_id: UUID
lport: int
rhost: str
rport: int
type: str
class EthernetPortInfo(BaseModel):
"""
Ethernet port information.
"""
node_id: UUID
interface: str
type: str
class LinkCapture(BaseModel):
"""
Link capture data.
"""
data_link_type: str = "DLT_EN10MB"
capture_file_name: Optional[str] = None
wireshark: bool = False
class MarkerCreate(BaseModel):
"""
Body for attaching a traffic-insight marker to a link.
``name`` is optional at the controller REST layer (auto-generated when
absent) but always set when the controller forwards to the compute.
"""
name: Optional[str] = Field(
None,
pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$",
max_length=32,
description="Unique marker name on the link. Auto-generated when absent.",
)
bpf: str
tag: Optional[int] = None
link_id: Optional[str] = None
color: Optional[str] = Field(
None,
description="User-chosen hex color for this marker in the Web UI, e.g. '#ff5722'",
)
highlight_duration: Optional[int] = Field(
None,
ge=1,
description=(
"How long (milliseconds) the Web UI keeps this marker highlighted "
"after a match. Omitted = use the UI default. Pure render hint — "
"stored on the link, never sent to uBridge."
),
)
enabled: Optional[bool] = Field(
None,
description="Whether the marker is active. Defaults to true on creation.",
)
direction: Optional[str] = Field(
None,
pattern=r"^(tx|rx|both)$",
description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only, 'both' or null = both directions.",
)
capture_node_id: Optional[UUID] = Field(
None,
description=(
"Which endpoint's uBridge hosts this marker (the 'observer'). "
"tx/rx in `direction` are interpreted from this node's perspective. "
"Must be one of the link's two endpoints and a marker-capable type. "
"Omitted = server auto-picks (first started marker-capable endpoint)."
),
)
data_link_type: str = Field(
"DLT_EN10MB",
description=(
"pcap link-layer type the marker's BPF compiles against and its "
"capture file is written with (a uBridge `linktype` token). Defaults "
"to DLT_EN10MB (Ethernet), which is omitted from the uBridge command. "
"Only meaningful for serial links: set it to the matching serial DLT "
"from the port's data_link_types — DLT_C_HDLC / DLT_PPP_SERIAL / "
"DLT_FRELAY / DLT_ATM_RFC1483 — so the BPF offsets and pcap decode "
"match the encapsulation configured in IOS. Create-only (changing it "
"would invalidate the pcap)."
),
)
@field_validator("direction", mode="before")
@classmethod
def _both_to_none(cls, v):
return None if v == "both" else v
class MarkerUpdate(BaseModel):
"""
Body for updating a marker — partial update, every field optional.
``bpf`` is optional here (it is required on create). ``capture_node_id`` and
``name`` are create-only / path-driven and intentionally absent; an explicit
``direction: null`` clears the direction back to both (omitting keeps it).
"""
bpf: Optional[str] = None
tag: Optional[int] = None
direction: Optional[str] = Field(
None,
pattern=r"^(tx|rx|both)$",
description="Direction filter; 'both' or an explicit null clears it to both. Omit to keep.",
)
color: Optional[str] = Field(None, description="Hex color render hint, e.g. '#ff5722'")
highlight_duration: Optional[int] = Field(None, ge=1, description="UI highlight duration in ms; null = UI default")
enabled: Optional[bool] = Field(None, description="Toggle the marker on/off (instant).")
@field_validator("direction", mode="before")
@classmethod
def _both_to_none(cls, v):
return None if v == "both" else v
class MarkerDefinitionCreate(BaseModel):
"""
Body for creating / updating a project-level marker definition.
The definition is a template — when applied to a link the marker name is
prefixed with ``global-`` (e.g. ``arp`` → ``global-arp``) so it can never
collide with a per-link private marker.
"""
name: Optional[str] = Field(
None,
pattern=r"^[A-Za-z0-9][A-Za-z0-9_.-]*$",
max_length=32,
description="Unique definition name. Auto-generated when absent.",
)
bpf: str
tag: Optional[int] = None
color: Optional[str] = Field(
None,
description="User-chosen hex color for the marker in the Web UI, e.g. '#ff5722'",
)
highlight_duration: Optional[int] = Field(
None,
ge=1,
description=(
"How long (milliseconds) the Web UI keeps this marker highlighted "
"after a match. Omitted = use the UI default. Pure render hint — "
"stored with the definition, never sent to uBridge."
),
)
direction: Optional[str] = Field(
None,
pattern=r"^(tx|rx|both)$",
description="Direction filter: 'tx' = capture node sending only, 'rx' = capture node receiving only, 'both' or null = both directions.",
)
data_link_type: str = Field(
"DLT_EN10MB",
description=(
"pcap link-layer type for inherited markers on serial links (uBridge "
"`linktype`). Defaults to DLT_EN10MB (Ethernet): the definition then "
"applies only to Ethernet links and serial links are skipped. Set a "
"serial DLT — DLT_C_HDLC / DLT_PPP_SERIAL / DLT_FRELAY / "
"DLT_ATM_RFC1483 — to also cover serial links with that encapsulation; "
"Ethernet links stay EN10MB regardless. Changing it re-fans-out."
),
)
@field_validator("direction", mode="before")
@classmethod
def _both_to_none(cls, v):
return None if v == "both" else v