Skip to content

Commit 9ba57dd

Browse files
Feat add interface virtual ethernet support (#254)
* Adding interface virtual ethernet backend * Adding interface virtual ethernet frontend * Removed unused imports and vars
1 parent e46f063 commit 9ba57dd

18 files changed

Lines changed: 4402 additions & 12 deletions

File tree

backend/app.py

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -13,7 +13,7 @@
1313

1414
# Import routers
1515
from routers.session import session as session_router
16-
from routers.interfaces import ethernet, dummy, bonding, bridge, geneve, input, l2tpv3, loopback, macsec, openvpn, pppoe, pseudo_ethernet, sstpc
16+
from routers.interfaces import ethernet, dummy, bonding, bridge, geneve, input, l2tpv3, loopback, macsec, openvpn, pppoe, pseudo_ethernet, sstpc, virtual_ethernet
1717
from routers.firewall import groups
1818
from routers.firewall import ipv4 as firewall_ipv4
1919
from routers.firewall import ipv6 as firewall_ipv6
@@ -293,6 +293,7 @@ async def get_permissions(request: Request) -> dict:
293293
app.include_router(pppoe.router)
294294
app.include_router(pseudo_ethernet.router)
295295
app.include_router(sstpc.router)
296+
app.include_router(virtual_ethernet.router)
296297
app.include_router(groups.router)
297298
app.include_router(firewall_ipv4.router)
298299
app.include_router(firewall_ipv6.router)
@@ -368,6 +369,7 @@ async def read_root() -> dict:
368369
"pppoe-interface",
369370
"pseudo-ethernet-interface",
370371
"sstpc-interface",
372+
"virtual-ethernet-interface",
371373
"firewall-groups",
372374
"firewall-ipv4",
373375
"firewall-ipv6",

backend/routers/interfaces/__init__.py

Lines changed: 2 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -4,6 +4,6 @@
44
FastAPI routers for different interface types.
55
"""
66

7-
from . import ethernet, dummy, bonding, bridge, geneve, input, l2tpv3, loopback, macsec, openvpn
7+
from . import ethernet, dummy, bonding, bridge, geneve, input, l2tpv3, loopback, macsec, openvpn, virtual_ethernet
88

9-
__all__ = ["ethernet", "dummy", "bonding", "bridge", "geneve", "input", "l2tpv3", "loopback", "macsec", "openvpn"]
9+
__all__ = ["ethernet", "dummy", "bonding", "bridge", "geneve", "input", "l2tpv3", "loopback", "macsec", "openvpn", "virtual_ethernet"]
Lines changed: 343 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,343 @@
1+
"""
2+
Virtual Ethernet Interface Configuration Endpoints
3+
4+
All virtual-ethernet interface endpoints for VyOS configuration.
5+
A virtual-ethernet interface is one end of a kernel veth pair, commonly used
6+
to connect network namespaces or containers. It supports VLAN sub-interfaces
7+
(vif, vif-s/vif-c QinQ) and optional network namespace assignment (VyOS 1.5+).
8+
"""
9+
10+
import inspect
11+
import logging
12+
from typing import Dict, List, Optional, Any
13+
14+
from fastapi import APIRouter, HTTPException, Request
15+
from pydantic import BaseModel, Field, ConfigDict
16+
from starlette.concurrency import run_in_threadpool
17+
18+
from fastapi_permissions import require_read_permission, require_write_permission
19+
from rbac_permissions import FeatureGroup
20+
from session_vyos_service import get_session_vyos_service
21+
22+
logger = logging.getLogger(__name__)
23+
24+
router = APIRouter(prefix="/vyos/virtual-ethernet", tags=["virtual-ethernet-interface"])
25+
26+
27+
# ============================================================================
28+
# Request / Response Models
29+
# ============================================================================
30+
31+
32+
class BatchOperation(BaseModel):
33+
op: str = Field(..., description="Operation name")
34+
value: Optional[str] = Field(None, description="Operation value (if required)")
35+
36+
37+
class BatchRequest(BaseModel):
38+
interface: str = Field(..., description="Interface name (e.g., veth0)")
39+
operations: List[BatchOperation]
40+
41+
42+
class VyOSResponse(BaseModel):
43+
success: bool
44+
data: Optional[Dict[str, Any]] = None
45+
error: Optional[str] = None
46+
47+
48+
class DhcpOptions(BaseModel):
49+
client_id: Optional[str] = None
50+
host_name: Optional[str] = None
51+
vendor_class_id: Optional[str] = None
52+
user_class: Optional[str] = None
53+
no_default_route: bool = False
54+
default_route_distance: Optional[str] = None
55+
reject: List[str] = Field(default_factory=list)
56+
mtu: bool = False
57+
58+
model_config = ConfigDict(populate_by_name=True)
59+
60+
61+
class Dhcpv6PdInterface(BaseModel):
62+
name: str
63+
address: Optional[str] = None
64+
sla_id: Optional[str] = None
65+
66+
model_config = ConfigDict(populate_by_name=True)
67+
68+
69+
class Dhcpv6PdInstance(BaseModel):
70+
instance: str
71+
length: Optional[str] = None
72+
interfaces: List[Dhcpv6PdInterface] = Field(default_factory=list)
73+
74+
model_config = ConfigDict(populate_by_name=True)
75+
76+
77+
class Dhcpv6Options(BaseModel):
78+
duid: Optional[str] = None
79+
no_release: bool = False
80+
no_request_dns: bool = False
81+
no_request_domain_name: bool = False
82+
parameters_only: bool = False
83+
rapid_commit: bool = False
84+
temporary: bool = False
85+
pd: List[Dhcpv6PdInstance] = Field(default_factory=list)
86+
87+
model_config = ConfigDict(populate_by_name=True)
88+
89+
90+
class IpSettings(BaseModel):
91+
adjust_mss: Optional[str] = None
92+
adjust_mss_clamp_to_pmtu: bool = False
93+
arp_cache_timeout: Optional[str] = None
94+
disable_arp_filter: bool = False
95+
enable_arp_accept: bool = False
96+
enable_arp_announce: bool = False
97+
enable_arp_ignore: bool = False
98+
enable_directed_broadcast: bool = False
99+
enable_proxy_arp: bool = False
100+
proxy_arp_pvlan: bool = False
101+
disable_forwarding: bool = False
102+
source_validation: Optional[str] = None
103+
104+
model_config = ConfigDict(populate_by_name=True)
105+
106+
107+
class Ipv6Settings(BaseModel):
108+
accept_dad: Optional[str] = None
109+
adjust_mss: Optional[str] = None
110+
adjust_mss_clamp_to_pmtu: bool = False
111+
base_reachable_time: Optional[str] = None
112+
disable_forwarding: bool = False
113+
dup_addr_detect_transmits: Optional[str] = None
114+
source_validation: Optional[str] = None
115+
address_autoconf: bool = False
116+
address_eui64: List[str] = Field(default_factory=list)
117+
address_no_default_link_local: bool = False
118+
address_interface_identifier: Optional[str] = None
119+
120+
model_config = ConfigDict(populate_by_name=True)
121+
122+
123+
class MirrorSettings(BaseModel):
124+
ingress: Optional[str] = None
125+
egress: Optional[str] = None
126+
127+
model_config = ConfigDict(populate_by_name=True)
128+
129+
130+
class VifCConfig(BaseModel):
131+
vlan_id: str
132+
description: Optional[str] = None
133+
disabled: bool = False
134+
disable_link_detect: bool = False
135+
addresses: List[str] = Field(default_factory=list)
136+
mtu: Optional[str] = None
137+
mac: Optional[str] = None
138+
vrf: Optional[str] = None
139+
redirect: Optional[str] = None
140+
dhcp_options: Optional[DhcpOptions] = None
141+
dhcpv6_options: Optional[Dhcpv6Options] = None
142+
ip: Optional[IpSettings] = None
143+
ipv6: Optional[Ipv6Settings] = None
144+
mirror: Optional[MirrorSettings] = None
145+
146+
model_config = ConfigDict(populate_by_name=True)
147+
148+
149+
class VifSConfig(BaseModel):
150+
vlan_id: str
151+
description: Optional[str] = None
152+
disabled: bool = False
153+
disable_link_detect: bool = False
154+
addresses: List[str] = Field(default_factory=list)
155+
mtu: Optional[str] = None
156+
mac: Optional[str] = None
157+
vrf: Optional[str] = None
158+
redirect: Optional[str] = None
159+
protocol: Optional[str] = None
160+
dhcp_options: Optional[DhcpOptions] = None
161+
dhcpv6_options: Optional[Dhcpv6Options] = None
162+
ip: Optional[IpSettings] = None
163+
ipv6: Optional[Ipv6Settings] = None
164+
mirror: Optional[MirrorSettings] = None
165+
vif_c: List[VifCConfig] = Field(default_factory=list)
166+
167+
model_config = ConfigDict(populate_by_name=True)
168+
169+
170+
class VifConfig(BaseModel):
171+
vlan_id: str
172+
description: Optional[str] = None
173+
disabled: bool = False
174+
disable_link_detect: bool = False
175+
addresses: List[str] = Field(default_factory=list)
176+
mtu: Optional[str] = None
177+
mac: Optional[str] = None
178+
vrf: Optional[str] = None
179+
redirect: Optional[str] = None
180+
egress_qos: Optional[str] = None
181+
ingress_qos: Optional[str] = None
182+
dhcp_options: Optional[DhcpOptions] = None
183+
dhcpv6_options: Optional[Dhcpv6Options] = None
184+
ip: Optional[IpSettings] = None
185+
ipv6: Optional[Ipv6Settings] = None
186+
mirror: Optional[MirrorSettings] = None
187+
188+
model_config = ConfigDict(populate_by_name=True)
189+
190+
191+
class VirtualEthernetInterfaceConfig(BaseModel):
192+
name: str
193+
type: str
194+
description: Optional[str] = None
195+
disabled: bool = False
196+
peer_name: Optional[str] = None
197+
netns: Optional[str] = None
198+
mtu: Optional[str] = None
199+
vrf: Optional[str] = None
200+
addresses: List[str] = Field(default_factory=list)
201+
dhcp_options: Optional[DhcpOptions] = None
202+
dhcpv6_options: Optional[Dhcpv6Options] = None
203+
vif: List[VifConfig] = Field(default_factory=list)
204+
vif_s: List[VifSConfig] = Field(default_factory=list)
205+
206+
model_config = ConfigDict(populate_by_name=True)
207+
208+
209+
class VirtualEthernetConfigResponse(BaseModel):
210+
interfaces: List[VirtualEthernetInterfaceConfig] = Field(default_factory=list)
211+
total: int = 0
212+
213+
214+
# ============================================================================
215+
# Endpoints
216+
# ============================================================================
217+
218+
219+
@router.get("/capabilities")
220+
async def get_capabilities(request: Request) -> Dict[str, Any]:
221+
"""Return version-aware feature capabilities for virtual-ethernet interfaces."""
222+
await require_read_permission(request, FeatureGroup.INTERFACES)
223+
service = get_session_vyos_service(request)
224+
from vyos_builders.interfaces.virtual_ethernet import VirtualEthernetInterfaceBuilderMixin
225+
builder = VirtualEthernetInterfaceBuilderMixin(version=service.get_version())
226+
return builder.get_capabilities()
227+
228+
229+
@router.get("/config", response_model=VirtualEthernetConfigResponse)
230+
async def get_config(http_request: Request, refresh: bool = False) -> VirtualEthernetConfigResponse:
231+
"""Get all virtual-ethernet interface configurations from VyOS."""
232+
await require_read_permission(http_request, FeatureGroup.INTERFACES)
233+
try:
234+
service = get_session_vyos_service(http_request)
235+
full_config = await run_in_threadpool(service.get_full_config, refresh)
236+
raw_config = full_config.get("interfaces", {}).get("virtual-ethernet", {})
237+
238+
from vyos_mappers.interfaces.virtual_ethernet_versions import get_virtual_ethernet_mapper
239+
mapper = get_virtual_ethernet_mapper(service.get_version())
240+
parsed = mapper.parse_interfaces_of_type(raw_config)
241+
return VirtualEthernetConfigResponse(**parsed)
242+
except Exception:
243+
logger.exception("Unhandled error in get_config")
244+
raise HTTPException(status_code=500, detail="Internal server error")
245+
246+
247+
@router.post("/batch", response_model=VyOSResponse)
248+
async def batch_configure(http_request: Request, request: BatchRequest) -> VyOSResponse:
249+
"""
250+
Configure a virtual-ethernet interface using batch operations.
251+
252+
**Multi-parameter operations:** for builder methods that require more than
253+
the interface name + one value, encode extras in `value` using colon-separated
254+
components (e.g., `vlan_id:address`, `s_vlan_id:c_vlan_id`, `pd_id:length`).
255+
"""
256+
await require_write_permission(http_request, FeatureGroup.INTERFACES)
257+
258+
try:
259+
service = get_session_vyos_service(http_request)
260+
batch = service.create_virtual_ethernet_batch()
261+
262+
for op in request.operations:
263+
if op.op in batch._INTERNAL_BUILDER_METHODS:
264+
raise HTTPException(
265+
status_code=400,
266+
detail=f"Operation '{op.op}' is not a valid interface operation",
267+
)
268+
269+
method = getattr(batch, op.op, None)
270+
if method is None:
271+
raise HTTPException(
272+
status_code=400,
273+
detail=f"Unsupported operation: {op.op}",
274+
)
275+
276+
sig = inspect.signature(method)
277+
params = [p for p in sig.parameters.keys() if p != "self"]
278+
279+
if len(params) == 1:
280+
method(request.interface)
281+
elif len(params) == 2:
282+
if op.value is None:
283+
raise HTTPException(
284+
status_code=400,
285+
detail=f"Operation '{op.op}' requires a value",
286+
)
287+
method(request.interface, op.value)
288+
elif len(params) == 3:
289+
if op.value is None:
290+
raise HTTPException(
291+
status_code=400,
292+
detail=f"Operation '{op.op}' requires a value",
293+
)
294+
parts = op.value.split(":", 1)
295+
if len(parts) != 2:
296+
raise HTTPException(
297+
status_code=400,
298+
detail=f"Operation '{op.op}' requires value in 'param1:param2' format",
299+
)
300+
method(request.interface, parts[0], parts[1])
301+
elif len(params) == 4:
302+
if op.value is None:
303+
raise HTTPException(
304+
status_code=400,
305+
detail=f"Operation '{op.op}' requires a value",
306+
)
307+
parts = op.value.split(":", 2)
308+
if len(parts) != 3:
309+
raise HTTPException(
310+
status_code=400,
311+
detail=f"Operation '{op.op}' requires value in 'param1:param2:param3' format",
312+
)
313+
method(request.interface, parts[0], parts[1], parts[2])
314+
elif len(params) == 5:
315+
if op.value is None:
316+
raise HTTPException(
317+
status_code=400,
318+
detail=f"Operation '{op.op}' requires a value",
319+
)
320+
parts = op.value.split(":", 3)
321+
if len(parts) != 4:
322+
raise HTTPException(
323+
status_code=400,
324+
detail=f"Operation '{op.op}' requires value in 'param1:param2:param3:param4' format",
325+
)
326+
method(request.interface, parts[0], parts[1], parts[2], parts[3])
327+
else:
328+
raise HTTPException(
329+
status_code=400,
330+
detail=f"Operation '{op.op}' has unexpected signature",
331+
)
332+
333+
response = service.execute_batch(batch)
334+
return VyOSResponse(
335+
success=response.status == 200,
336+
data=response.result if isinstance(response.result, dict) else None,
337+
error=response.error if response.error else None,
338+
)
339+
except HTTPException:
340+
raise
341+
except Exception:
342+
logger.exception("Unhandled error in batch_configure")
343+
raise HTTPException(status_code=500, detail="Internal server error")

0 commit comments

Comments
 (0)