Skip to content

Commit 495f1a2

Browse files
Feat add protocol rip support (#259)
* Adding protocol RIP backend * Adding protocol RIP frontend * Potential fix for pull request finding 'Unused variable, import, function or class'
1 parent 0cc5bc1 commit 495f1a2

20 files changed

Lines changed: 4006 additions & 0 deletions

File tree

backend/app.py

Lines changed: 2 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -64,6 +64,7 @@
6464
from routers.nhrp import nhrp as nhrp_router
6565
from routers.pim import pim as pim_router
6666
from routers.pim6 import pim6 as pim6_router
67+
from routers.rip import rip as rip_router
6768
from routers import version as version_router
6869
from routers import events as events_router
6970
from routers.events import start_poller, stop_poller
@@ -348,6 +349,7 @@ async def get_permissions(request: Request) -> dict:
348349
app.include_router(nhrp_router.router)
349350
app.include_router(pim_router.router)
350351
app.include_router(pim6_router.router)
352+
app.include_router(rip_router.router)
351353
app.include_router(version_router.router)
352354
app.include_router(events_router.router)
353355

backend/routers/rip/__init__.py

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
"""RIP Protocol Router Package."""

backend/routers/rip/rip.py

Lines changed: 373 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,373 @@
1+
"""RIP Protocol Router.
2+
3+
API endpoints for managing VyOS RIP (Routing Information Protocol) configuration.
4+
Supports version-aware configuration for VyOS 1.4 and 1.5.
5+
"""
6+
7+
from fastapi import APIRouter, HTTPException, Request
8+
from starlette.concurrency import run_in_threadpool
9+
from pydantic import BaseModel, Field
10+
from typing import List, Dict, Optional, Any
11+
from session_vyos_service import get_session_vyos_service
12+
from vyos_builders import RipBatchBuilder
13+
from fastapi_permissions import require_read_permission, require_write_permission
14+
from rbac_permissions import FeatureGroup
15+
import inspect
16+
import logging
17+
18+
logger = logging.getLogger(__name__)
19+
20+
router = APIRouter(prefix="/vyos/rip", tags=["rip"])
21+
22+
23+
# ============================================================================
24+
# Pydantic Models
25+
# ============================================================================
26+
27+
28+
class RipDistributeListGlobal(BaseModel):
29+
access_list_in: Optional[str] = None
30+
access_list_out: Optional[str] = None
31+
prefix_list_in: Optional[str] = None
32+
prefix_list_out: Optional[str] = None
33+
34+
35+
class RipDistributeListInterface(BaseModel):
36+
interface: str
37+
access_list_in: Optional[str] = None
38+
access_list_out: Optional[str] = None
39+
prefix_list_in: Optional[str] = None
40+
prefix_list_out: Optional[str] = None
41+
42+
43+
class RipDistributeList(BaseModel):
44+
global_filters: RipDistributeListGlobal = RipDistributeListGlobal()
45+
interface_filters: List[RipDistributeListInterface] = []
46+
47+
48+
class RipMd5Key(BaseModel):
49+
key_id: str
50+
password: str
51+
52+
53+
class RipInterface(BaseModel):
54+
name: str
55+
authentication_type: Optional[str] = None
56+
md5_keys: List[RipMd5Key] = []
57+
plaintext_password: Optional[str] = None
58+
receive_version: Optional[str] = None
59+
send_version: Optional[str] = None
60+
split_horizon: Optional[str] = None
61+
62+
63+
class RipNetworkDistance(BaseModel):
64+
prefix: str
65+
distance: Optional[int] = None
66+
access_list: Optional[str] = None
67+
68+
69+
class RipRedistribute(BaseModel):
70+
protocol: str
71+
metric: Optional[int] = None
72+
route_map: Optional[str] = None
73+
74+
75+
class RipTimers(BaseModel):
76+
update: Optional[int] = None
77+
timeout: Optional[int] = None
78+
garbage_collection: Optional[int] = None
79+
80+
81+
class RipConfig(BaseModel):
82+
default_distance: Optional[int] = None
83+
default_information_originate: bool = False
84+
default_metric: Optional[int] = None
85+
route_map: Optional[str] = None
86+
version: Optional[str] = None
87+
networks: List[str] = []
88+
neighbors: List[str] = []
89+
routes: List[str] = []
90+
passive_interfaces: List[str] = []
91+
distribute_list: RipDistributeList = RipDistributeList()
92+
interfaces: List[RipInterface] = []
93+
network_distances: List[RipNetworkDistance] = []
94+
redistribute: List[RipRedistribute] = []
95+
timers: RipTimers = RipTimers()
96+
97+
98+
class RipBatchOperation(BaseModel):
99+
op: str = Field(..., description="Builder method name")
100+
value: Optional[str] = Field(None, description="Comma-separated arguments")
101+
102+
103+
class RipBatchRequest(BaseModel):
104+
operations: List[RipBatchOperation]
105+
106+
107+
class VyOSResponse(BaseModel):
108+
success: bool
109+
data: Optional[Dict[str, Any]] = None
110+
error: Optional[str] = None
111+
112+
113+
# ============================================================================
114+
# Endpoint 1: Capabilities
115+
# ============================================================================
116+
117+
118+
@router.get("/capabilities")
119+
async def get_rip_capabilities(request: Request):
120+
"""Return RIP feature capabilities based on the device VyOS version."""
121+
await require_read_permission(request, FeatureGroup.RIP)
122+
123+
try:
124+
service = get_session_vyos_service(request)
125+
builder = RipBatchBuilder(version=service.get_version())
126+
capabilities = builder.get_capabilities()
127+
128+
if hasattr(request.state, "instance") and request.state.instance:
129+
capabilities["instance_name"] = request.state.instance.get("name")
130+
capabilities["instance_id"] = request.state.instance.get("id")
131+
132+
return capabilities
133+
except Exception:
134+
logger.exception("Unhandled error in get_rip_capabilities")
135+
raise HTTPException(status_code=500, detail="Internal server error")
136+
137+
138+
# ============================================================================
139+
# Endpoint 2: Config
140+
# ============================================================================
141+
142+
143+
@router.get("/config", response_model=RipConfig)
144+
async def get_rip_config(http_request: Request, refresh: bool = False):
145+
"""Return the full RIP configuration in a normalized format."""
146+
await require_read_permission(http_request, FeatureGroup.RIP)
147+
148+
try:
149+
service = get_session_vyos_service(http_request)
150+
full_config = await run_in_threadpool(service.get_full_config, refresh=refresh)
151+
152+
rip_raw = full_config.get("protocols", {}).get("rip", {})
153+
154+
if not rip_raw:
155+
return RipConfig()
156+
157+
return RipConfig(
158+
default_distance=_safe_int(rip_raw.get("default-distance")),
159+
default_information_originate="originate" in (rip_raw.get("default-information") or {}),
160+
default_metric=_safe_int(rip_raw.get("default-metric")),
161+
route_map=rip_raw.get("route-map"),
162+
version=rip_raw.get("version"),
163+
networks=_to_list(rip_raw.get("network")),
164+
neighbors=_to_list(rip_raw.get("neighbor")),
165+
routes=_to_list(rip_raw.get("route")),
166+
passive_interfaces=_to_list(rip_raw.get("passive-interface")),
167+
distribute_list=_parse_distribute_list(rip_raw.get("distribute-list", {})),
168+
interfaces=_parse_interfaces(rip_raw.get("interface", {})),
169+
network_distances=_parse_network_distances(rip_raw.get("network-distance", {})),
170+
redistribute=_parse_redistribute(rip_raw.get("redistribute", {})),
171+
timers=_parse_timers(rip_raw.get("timers", {})),
172+
)
173+
except Exception:
174+
logger.exception("Unhandled error in get_rip_config")
175+
raise HTTPException(status_code=500, detail="Internal server error")
176+
177+
178+
# ============================================================================
179+
# Config Parsers
180+
# ============================================================================
181+
182+
183+
def _safe_int(value) -> Optional[int]:
184+
if value is None:
185+
return None
186+
try:
187+
return int(value)
188+
except (ValueError, TypeError):
189+
return None
190+
191+
192+
def _to_list(value) -> List[str]:
193+
if value is None:
194+
return []
195+
if isinstance(value, list):
196+
return value
197+
if isinstance(value, str):
198+
return [value]
199+
if isinstance(value, dict):
200+
return list(value.keys())
201+
return []
202+
203+
204+
def _parse_distribute_list(raw: dict) -> RipDistributeList:
205+
if not raw:
206+
return RipDistributeList()
207+
208+
acl_raw = raw.get("access-list", {}) or {}
209+
pl_raw = raw.get("prefix-list", {}) or {}
210+
211+
global_filters = RipDistributeListGlobal(
212+
access_list_in=acl_raw.get("in") if isinstance(acl_raw, dict) else None,
213+
access_list_out=acl_raw.get("out") if isinstance(acl_raw, dict) else None,
214+
prefix_list_in=pl_raw.get("in") if isinstance(pl_raw, dict) else None,
215+
prefix_list_out=pl_raw.get("out") if isinstance(pl_raw, dict) else None,
216+
)
217+
218+
iface_filters: List[RipDistributeListInterface] = []
219+
for iface, iface_raw in (raw.get("interface", {}) or {}).items():
220+
if iface_raw is None:
221+
iface_raw = {}
222+
iface_acl = iface_raw.get("access-list", {}) or {}
223+
iface_pl = iface_raw.get("prefix-list", {}) or {}
224+
iface_filters.append(RipDistributeListInterface(
225+
interface=iface,
226+
access_list_in=iface_acl.get("in") if isinstance(iface_acl, dict) else None,
227+
access_list_out=iface_acl.get("out") if isinstance(iface_acl, dict) else None,
228+
prefix_list_in=iface_pl.get("in") if isinstance(iface_pl, dict) else None,
229+
prefix_list_out=iface_pl.get("out") if isinstance(iface_pl, dict) else None,
230+
))
231+
232+
return RipDistributeList(global_filters=global_filters, interface_filters=iface_filters)
233+
234+
235+
def _parse_interfaces(raw: dict) -> List[RipInterface]:
236+
if not raw:
237+
return []
238+
239+
interfaces = []
240+
for iface_name, cfg in raw.items():
241+
if cfg is None:
242+
cfg = {}
243+
244+
auth_raw = cfg.get("authentication", {}) or {}
245+
md5_raw = auth_raw.get("md5", {}) or {}
246+
plaintext = auth_raw.get("plaintext-password")
247+
248+
md5_keys = []
249+
for key_id, key_cfg in md5_raw.items():
250+
if key_cfg is None:
251+
key_cfg = {}
252+
md5_keys.append(RipMd5Key(
253+
key_id=str(key_id),
254+
password=key_cfg.get("password", "") if isinstance(key_cfg, dict) else "",
255+
))
256+
257+
auth_type = None
258+
if md5_keys:
259+
auth_type = "md5"
260+
elif plaintext:
261+
auth_type = "plaintext"
262+
263+
split_horizon_raw = cfg.get("split-horizon", {}) or {}
264+
split_horizon = None
265+
if "poison-reverse" in split_horizon_raw:
266+
split_horizon = "poison-reverse"
267+
elif "disable" in split_horizon_raw:
268+
split_horizon = "disable"
269+
270+
interfaces.append(RipInterface(
271+
name=iface_name,
272+
authentication_type=auth_type,
273+
md5_keys=md5_keys,
274+
plaintext_password=plaintext,
275+
receive_version=(cfg.get("receive", {}) or {}).get("version"),
276+
send_version=(cfg.get("send", {}) or {}).get("version"),
277+
split_horizon=split_horizon,
278+
))
279+
280+
return interfaces
281+
282+
283+
def _parse_network_distances(raw: dict) -> List[RipNetworkDistance]:
284+
if not raw:
285+
return []
286+
287+
entries = []
288+
for prefix, cfg in raw.items():
289+
if cfg is None:
290+
cfg = {}
291+
entries.append(RipNetworkDistance(
292+
prefix=prefix,
293+
distance=_safe_int(cfg.get("distance")),
294+
access_list=cfg.get("access-list"),
295+
))
296+
return entries
297+
298+
299+
def _parse_redistribute(raw: dict) -> List[RipRedistribute]:
300+
if not raw:
301+
return []
302+
303+
entries = []
304+
for protocol, cfg in raw.items():
305+
if cfg is None:
306+
cfg = {}
307+
entries.append(RipRedistribute(
308+
protocol=protocol,
309+
metric=_safe_int(cfg.get("metric")),
310+
route_map=cfg.get("route-map"),
311+
))
312+
return entries
313+
314+
315+
def _parse_timers(raw: dict) -> RipTimers:
316+
if not raw:
317+
return RipTimers()
318+
return RipTimers(
319+
update=_safe_int(raw.get("update")),
320+
timeout=_safe_int(raw.get("timeout")),
321+
garbage_collection=_safe_int(raw.get("garbage-collection")),
322+
)
323+
324+
325+
# ============================================================================
326+
# Endpoint 3: Batch Operations
327+
# ============================================================================
328+
329+
330+
@router.post("/batch", response_model=VyOSResponse)
331+
async def rip_batch_configure(http_request: Request, body: RipBatchRequest):
332+
"""Execute a batch of RIP configuration operations atomically."""
333+
await require_write_permission(http_request, FeatureGroup.RIP)
334+
335+
try:
336+
service = get_session_vyos_service(http_request)
337+
builder = RipBatchBuilder(version=service.get_version())
338+
339+
for operation in body.operations:
340+
method = getattr(builder, operation.op)
341+
sig = inspect.signature(method)
342+
params = [p for p in sig.parameters.keys() if p != "self"]
343+
344+
if len(params) == 0:
345+
method()
346+
elif len(params) == 1:
347+
if operation.value is not None:
348+
method(operation.value)
349+
elif len(params) == 2 and operation.value is not None:
350+
values = operation.value.split(",", 1)
351+
if len(values) == 2:
352+
method(values[0], values[1])
353+
else:
354+
method(operation.value, "")
355+
elif len(params) == 3 and operation.value is not None:
356+
values = operation.value.split(",", 2)
357+
if len(values) == 3:
358+
method(values[0], values[1], values[2])
359+
elif len(values) == 2:
360+
method(values[0], values[1], "")
361+
362+
response = service.execute_batch(builder)
363+
364+
return VyOSResponse(
365+
success=response.status == 200,
366+
data={"message": "RIP configuration updated"},
367+
error=response.error if response.error else None,
368+
)
369+
except AttributeError as e:
370+
raise HTTPException(status_code=400, detail=f"Unknown operation: {str(e)}")
371+
except Exception:
372+
logger.exception("Unhandled error in rip_batch_configure")
373+
raise HTTPException(status_code=500, detail="Internal server error")

0 commit comments

Comments
 (0)