diff --git a/backend/app.py b/backend/app.py index 15021372..8ed2acd0 100644 --- a/backend/app.py +++ b/backend/app.py @@ -64,6 +64,7 @@ from routers.nhrp import nhrp as nhrp_router from routers.pim import pim as pim_router from routers.pim6 import pim6 as pim6_router +from routers.rip import rip as rip_router from routers import version as version_router from routers import events as events_router from routers.events import start_poller, stop_poller @@ -348,6 +349,7 @@ async def get_permissions(request: Request) -> dict: app.include_router(nhrp_router.router) app.include_router(pim_router.router) app.include_router(pim6_router.router) +app.include_router(rip_router.router) app.include_router(version_router.router) app.include_router(events_router.router) diff --git a/backend/routers/rip/__init__.py b/backend/routers/rip/__init__.py new file mode 100644 index 00000000..f4664aac --- /dev/null +++ b/backend/routers/rip/__init__.py @@ -0,0 +1 @@ +"""RIP Protocol Router Package.""" diff --git a/backend/routers/rip/rip.py b/backend/routers/rip/rip.py new file mode 100644 index 00000000..d2bd3a24 --- /dev/null +++ b/backend/routers/rip/rip.py @@ -0,0 +1,373 @@ +"""RIP Protocol Router. + +API endpoints for managing VyOS RIP (Routing Information Protocol) configuration. +Supports version-aware configuration for VyOS 1.4 and 1.5. +""" + +from fastapi import APIRouter, HTTPException, Request +from starlette.concurrency import run_in_threadpool +from pydantic import BaseModel, Field +from typing import List, Dict, Optional, Any +from session_vyos_service import get_session_vyos_service +from vyos_builders import RipBatchBuilder +from fastapi_permissions import require_read_permission, require_write_permission +from rbac_permissions import FeatureGroup +import inspect +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter(prefix="/vyos/rip", tags=["rip"]) + + +# ============================================================================ +# Pydantic Models +# ============================================================================ + + +class RipDistributeListGlobal(BaseModel): + access_list_in: Optional[str] = None + access_list_out: Optional[str] = None + prefix_list_in: Optional[str] = None + prefix_list_out: Optional[str] = None + + +class RipDistributeListInterface(BaseModel): + interface: str + access_list_in: Optional[str] = None + access_list_out: Optional[str] = None + prefix_list_in: Optional[str] = None + prefix_list_out: Optional[str] = None + + +class RipDistributeList(BaseModel): + global_filters: RipDistributeListGlobal = RipDistributeListGlobal() + interface_filters: List[RipDistributeListInterface] = [] + + +class RipMd5Key(BaseModel): + key_id: str + password: str + + +class RipInterface(BaseModel): + name: str + authentication_type: Optional[str] = None + md5_keys: List[RipMd5Key] = [] + plaintext_password: Optional[str] = None + receive_version: Optional[str] = None + send_version: Optional[str] = None + split_horizon: Optional[str] = None + + +class RipNetworkDistance(BaseModel): + prefix: str + distance: Optional[int] = None + access_list: Optional[str] = None + + +class RipRedistribute(BaseModel): + protocol: str + metric: Optional[int] = None + route_map: Optional[str] = None + + +class RipTimers(BaseModel): + update: Optional[int] = None + timeout: Optional[int] = None + garbage_collection: Optional[int] = None + + +class RipConfig(BaseModel): + default_distance: Optional[int] = None + default_information_originate: bool = False + default_metric: Optional[int] = None + route_map: Optional[str] = None + version: Optional[str] = None + networks: List[str] = [] + neighbors: List[str] = [] + routes: List[str] = [] + passive_interfaces: List[str] = [] + distribute_list: RipDistributeList = RipDistributeList() + interfaces: List[RipInterface] = [] + network_distances: List[RipNetworkDistance] = [] + redistribute: List[RipRedistribute] = [] + timers: RipTimers = RipTimers() + + +class RipBatchOperation(BaseModel): + op: str = Field(..., description="Builder method name") + value: Optional[str] = Field(None, description="Comma-separated arguments") + + +class RipBatchRequest(BaseModel): + operations: List[RipBatchOperation] + + +class VyOSResponse(BaseModel): + success: bool + data: Optional[Dict[str, Any]] = None + error: Optional[str] = None + + +# ============================================================================ +# Endpoint 1: Capabilities +# ============================================================================ + + +@router.get("/capabilities") +async def get_rip_capabilities(request: Request): + """Return RIP feature capabilities based on the device VyOS version.""" + await require_read_permission(request, FeatureGroup.RIP) + + try: + service = get_session_vyos_service(request) + builder = RipBatchBuilder(version=service.get_version()) + capabilities = builder.get_capabilities() + + if hasattr(request.state, "instance") and request.state.instance: + capabilities["instance_name"] = request.state.instance.get("name") + capabilities["instance_id"] = request.state.instance.get("id") + + return capabilities + except Exception: + logger.exception("Unhandled error in get_rip_capabilities") + raise HTTPException(status_code=500, detail="Internal server error") + + +# ============================================================================ +# Endpoint 2: Config +# ============================================================================ + + +@router.get("/config", response_model=RipConfig) +async def get_rip_config(http_request: Request, refresh: bool = False): + """Return the full RIP configuration in a normalized format.""" + await require_read_permission(http_request, FeatureGroup.RIP) + + try: + service = get_session_vyos_service(http_request) + full_config = await run_in_threadpool(service.get_full_config, refresh=refresh) + + rip_raw = full_config.get("protocols", {}).get("rip", {}) + + if not rip_raw: + return RipConfig() + + return RipConfig( + default_distance=_safe_int(rip_raw.get("default-distance")), + default_information_originate="originate" in (rip_raw.get("default-information") or {}), + default_metric=_safe_int(rip_raw.get("default-metric")), + route_map=rip_raw.get("route-map"), + version=rip_raw.get("version"), + networks=_to_list(rip_raw.get("network")), + neighbors=_to_list(rip_raw.get("neighbor")), + routes=_to_list(rip_raw.get("route")), + passive_interfaces=_to_list(rip_raw.get("passive-interface")), + distribute_list=_parse_distribute_list(rip_raw.get("distribute-list", {})), + interfaces=_parse_interfaces(rip_raw.get("interface", {})), + network_distances=_parse_network_distances(rip_raw.get("network-distance", {})), + redistribute=_parse_redistribute(rip_raw.get("redistribute", {})), + timers=_parse_timers(rip_raw.get("timers", {})), + ) + except Exception: + logger.exception("Unhandled error in get_rip_config") + raise HTTPException(status_code=500, detail="Internal server error") + + +# ============================================================================ +# Config Parsers +# ============================================================================ + + +def _safe_int(value) -> Optional[int]: + if value is None: + return None + try: + return int(value) + except (ValueError, TypeError): + return None + + +def _to_list(value) -> List[str]: + if value is None: + return [] + if isinstance(value, list): + return value + if isinstance(value, str): + return [value] + if isinstance(value, dict): + return list(value.keys()) + return [] + + +def _parse_distribute_list(raw: dict) -> RipDistributeList: + if not raw: + return RipDistributeList() + + acl_raw = raw.get("access-list", {}) or {} + pl_raw = raw.get("prefix-list", {}) or {} + + global_filters = RipDistributeListGlobal( + access_list_in=acl_raw.get("in") if isinstance(acl_raw, dict) else None, + access_list_out=acl_raw.get("out") if isinstance(acl_raw, dict) else None, + prefix_list_in=pl_raw.get("in") if isinstance(pl_raw, dict) else None, + prefix_list_out=pl_raw.get("out") if isinstance(pl_raw, dict) else None, + ) + + iface_filters: List[RipDistributeListInterface] = [] + for iface, iface_raw in (raw.get("interface", {}) or {}).items(): + if iface_raw is None: + iface_raw = {} + iface_acl = iface_raw.get("access-list", {}) or {} + iface_pl = iface_raw.get("prefix-list", {}) or {} + iface_filters.append(RipDistributeListInterface( + interface=iface, + access_list_in=iface_acl.get("in") if isinstance(iface_acl, dict) else None, + access_list_out=iface_acl.get("out") if isinstance(iface_acl, dict) else None, + prefix_list_in=iface_pl.get("in") if isinstance(iface_pl, dict) else None, + prefix_list_out=iface_pl.get("out") if isinstance(iface_pl, dict) else None, + )) + + return RipDistributeList(global_filters=global_filters, interface_filters=iface_filters) + + +def _parse_interfaces(raw: dict) -> List[RipInterface]: + if not raw: + return [] + + interfaces = [] + for iface_name, cfg in raw.items(): + if cfg is None: + cfg = {} + + auth_raw = cfg.get("authentication", {}) or {} + md5_raw = auth_raw.get("md5", {}) or {} + plaintext = auth_raw.get("plaintext-password") + + md5_keys = [] + for key_id, key_cfg in md5_raw.items(): + if key_cfg is None: + key_cfg = {} + md5_keys.append(RipMd5Key( + key_id=str(key_id), + password=key_cfg.get("password", "") if isinstance(key_cfg, dict) else "", + )) + + auth_type = None + if md5_keys: + auth_type = "md5" + elif plaintext: + auth_type = "plaintext" + + split_horizon_raw = cfg.get("split-horizon", {}) or {} + split_horizon = None + if "poison-reverse" in split_horizon_raw: + split_horizon = "poison-reverse" + elif "disable" in split_horizon_raw: + split_horizon = "disable" + + interfaces.append(RipInterface( + name=iface_name, + authentication_type=auth_type, + md5_keys=md5_keys, + plaintext_password=plaintext, + receive_version=(cfg.get("receive", {}) or {}).get("version"), + send_version=(cfg.get("send", {}) or {}).get("version"), + split_horizon=split_horizon, + )) + + return interfaces + + +def _parse_network_distances(raw: dict) -> List[RipNetworkDistance]: + if not raw: + return [] + + entries = [] + for prefix, cfg in raw.items(): + if cfg is None: + cfg = {} + entries.append(RipNetworkDistance( + prefix=prefix, + distance=_safe_int(cfg.get("distance")), + access_list=cfg.get("access-list"), + )) + return entries + + +def _parse_redistribute(raw: dict) -> List[RipRedistribute]: + if not raw: + return [] + + entries = [] + for protocol, cfg in raw.items(): + if cfg is None: + cfg = {} + entries.append(RipRedistribute( + protocol=protocol, + metric=_safe_int(cfg.get("metric")), + route_map=cfg.get("route-map"), + )) + return entries + + +def _parse_timers(raw: dict) -> RipTimers: + if not raw: + return RipTimers() + return RipTimers( + update=_safe_int(raw.get("update")), + timeout=_safe_int(raw.get("timeout")), + garbage_collection=_safe_int(raw.get("garbage-collection")), + ) + + +# ============================================================================ +# Endpoint 3: Batch Operations +# ============================================================================ + + +@router.post("/batch", response_model=VyOSResponse) +async def rip_batch_configure(http_request: Request, body: RipBatchRequest): + """Execute a batch of RIP configuration operations atomically.""" + await require_write_permission(http_request, FeatureGroup.RIP) + + try: + service = get_session_vyos_service(http_request) + builder = RipBatchBuilder(version=service.get_version()) + + for operation in body.operations: + method = getattr(builder, operation.op) + sig = inspect.signature(method) + params = [p for p in sig.parameters.keys() if p != "self"] + + if len(params) == 0: + method() + elif len(params) == 1: + if operation.value is not None: + method(operation.value) + elif len(params) == 2 and operation.value is not None: + values = operation.value.split(",", 1) + if len(values) == 2: + method(values[0], values[1]) + else: + method(operation.value, "") + elif len(params) == 3 and operation.value is not None: + values = operation.value.split(",", 2) + if len(values) == 3: + method(values[0], values[1], values[2]) + elif len(values) == 2: + method(values[0], values[1], "") + + response = service.execute_batch(builder) + + return VyOSResponse( + success=response.status == 200, + data={"message": "RIP configuration updated"}, + error=response.error if response.error else None, + ) + except AttributeError as e: + raise HTTPException(status_code=400, detail=f"Unknown operation: {str(e)}") + except Exception: + logger.exception("Unhandled error in rip_batch_configure") + raise HTTPException(status_code=500, detail="Internal server error") diff --git a/backend/vyos_builders/__init__.py b/backend/vyos_builders/__init__.py index 41cdbdac..3d9fa7dc 100644 --- a/backend/vyos_builders/__init__.py +++ b/backend/vyos_builders/__init__.py @@ -37,6 +37,7 @@ from .ipsec import IPSecBatchBuilder from .pki import PKIBatchBuilder from .tunnel import TunnelBatchBuilder +from .rip import RipBatchBuilder # Directly use the self-contained builders EthernetBatchBuilder = EthernetInterfaceBuilderMixin @@ -95,6 +96,7 @@ "IPSecBatchBuilder", "PKIBatchBuilder", "TunnelBatchBuilder", + "RipBatchBuilder", "BondingBatchBuilder", "GeneveBatchBuilder", "InputBatchBuilder", diff --git a/backend/vyos_builders/rip/__init__.py b/backend/vyos_builders/rip/__init__.py new file mode 100644 index 00000000..62c25c1a --- /dev/null +++ b/backend/vyos_builders/rip/__init__.py @@ -0,0 +1,4 @@ +"""RIP Protocol Builder Package.""" +from .rip_batch_builder import RipBatchBuilder + +__all__ = ["RipBatchBuilder"] diff --git a/backend/vyos_builders/rip/rip_batch_builder.py b/backend/vyos_builders/rip/rip_batch_builder.py new file mode 100644 index 00000000..e0d70886 --- /dev/null +++ b/backend/vyos_builders/rip/rip_batch_builder.py @@ -0,0 +1,341 @@ +"""RIP Protocol Batch Builder. + +Provides all batch operations for RIP (Routing Information Protocol) configuration. +Covers: global settings, networks, neighbors, static routes, passive interfaces, +distribute lists, interface settings, network distance, redistribute, and timers. +""" + +from typing import List, Dict, Any +from vyos_mappers import CommandMapperRegistry + + +class RipBatchBuilder: + """Complete batch builder for RIP protocol operations.""" + + def __init__(self, version: str): + self.version = version + self._operations: List[Dict[str, Any]] = [] + self.mappers = CommandMapperRegistry.get_all_mappers(version) + self.mapper_key = "rip" + + # ======================================================================== + # Core Batch Operations + # ======================================================================== + + def add_set(self, path: List[str]) -> "RipBatchBuilder": + if path: + self._operations.append({"op": "set", "path": path}) + return self + + def add_delete(self, path: List[str]) -> "RipBatchBuilder": + if path: + self._operations.append({"op": "delete", "path": path}) + return self + + def get_operations(self) -> List[Dict[str, Any]]: + return self._operations.copy() + + def is_empty(self) -> bool: + return len(self._operations) == 0 + + @property + def m(self): + return self.mappers[self.mapper_key] + + # ======================================================================== + # Capabilities + # ======================================================================== + + def get_capabilities(self) -> Dict[str, Any]: + is_v14 = "1.4" in self.version + is_v15 = "1.5" in self.version or "latest" in self.version + return { + "version": self.version, + "version_info": { + "is_1_4": is_v14, + "is_1_5": is_v15, + }, + "features": { + "global_settings": { + "supported": True, + "description": "RIP global settings (distance, metric, version, route-map)", + }, + "networks": { + "supported": True, + "description": "RIP network announcements", + }, + "neighbors": { + "supported": True, + "description": "Unicast neighbor peering", + }, + "static_routes": { + "supported": True, + "description": "RIP static route injection", + }, + "passive_interfaces": { + "supported": True, + "description": "Suppress RIP updates on interfaces", + }, + "distribute_lists": { + "supported": True, + "description": "Filter RIP updates with access/prefix lists", + }, + "interface_settings": { + "supported": True, + "description": "Per-interface authentication, version, and split-horizon", + }, + "network_distance": { + "supported": True, + "description": "Per-source-network administrative distance", + }, + "redistribute": { + "supported": True, + "description": "Redistribute routes from other protocols into RIP", + "protocols": ["babel", "bgp", "connected", "isis", "kernel", "nhrp", "ospf", "static"], + }, + "timers": { + "supported": True, + "description": "RIP update, timeout, and garbage-collection timers", + }, + }, + } + + # ======================================================================== + # Global Settings + # ======================================================================== + + def set_default_distance(self, value: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_default_distance(value)) + + def delete_default_distance(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_default_distance_delete()) + + def set_default_information_originate(self) -> "RipBatchBuilder": + return self.add_set(self.m.get_default_information_originate()) + + def delete_default_information_originate(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_default_information_originate_delete()) + + def set_default_metric(self, value: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_default_metric(value)) + + def delete_default_metric(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_default_metric_delete()) + + def set_route_map(self, value: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_route_map(value)) + + def delete_route_map(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_route_map_delete()) + + def set_version(self, value: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_version(value)) + + def delete_version(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_version_delete()) + + # ======================================================================== + # Networks + # ======================================================================== + + def set_network(self, network: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_network(network)) + + def delete_network(self, network: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_network_delete(network)) + + # ======================================================================== + # Neighbors + # ======================================================================== + + def set_neighbor(self, address: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_neighbor(address)) + + def delete_neighbor(self, address: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_neighbor_delete(address)) + + # ======================================================================== + # Static Routes + # ======================================================================== + + def set_route(self, prefix: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_route(prefix)) + + def delete_route(self, prefix: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_route_delete(prefix)) + + # ======================================================================== + # Passive Interface + # ======================================================================== + + def set_passive_interface(self, iface: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_passive_interface(iface)) + + def delete_passive_interface(self, iface: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_passive_interface_delete(iface)) + + # ======================================================================== + # Distribute List - Global + # ======================================================================== + + def set_distribute_list_access_list_in(self, acl: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_distribute_list_access_list_in(acl)) + + def delete_distribute_list_access_list_in(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_distribute_list_access_list_in_delete()) + + def set_distribute_list_access_list_out(self, acl: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_distribute_list_access_list_out(acl)) + + def delete_distribute_list_access_list_out(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_distribute_list_access_list_out_delete()) + + def set_distribute_list_prefix_list_in(self, prefix_list: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_distribute_list_prefix_list_in(prefix_list)) + + def delete_distribute_list_prefix_list_in(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_distribute_list_prefix_list_in_delete()) + + def set_distribute_list_prefix_list_out(self, prefix_list: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_distribute_list_prefix_list_out(prefix_list)) + + def delete_distribute_list_prefix_list_out(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_distribute_list_prefix_list_out_delete()) + + # ======================================================================== + # Distribute List - Per Interface + # ======================================================================== + + def set_distribute_list_interface_access_list_in(self, iface: str, acl: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_distribute_list_interface_access_list_in(iface, acl)) + + def delete_distribute_list_interface_access_list_in(self, iface: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_distribute_list_interface_access_list_in_delete(iface)) + + def set_distribute_list_interface_access_list_out(self, iface: str, acl: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_distribute_list_interface_access_list_out(iface, acl)) + + def delete_distribute_list_interface_access_list_out(self, iface: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_distribute_list_interface_access_list_out_delete(iface)) + + def set_distribute_list_interface_prefix_list_in(self, iface: str, prefix_list: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_distribute_list_interface_prefix_list_in(iface, prefix_list)) + + def delete_distribute_list_interface_prefix_list_in(self, iface: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_distribute_list_interface_prefix_list_in_delete(iface)) + + def set_distribute_list_interface_prefix_list_out(self, iface: str, prefix_list: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_distribute_list_interface_prefix_list_out(iface, prefix_list)) + + def delete_distribute_list_interface_prefix_list_out(self, iface: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_distribute_list_interface_prefix_list_out_delete(iface)) + + def delete_distribute_list_interface(self, iface: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_distribute_list_interface_delete(iface)) + + # ======================================================================== + # Interface Settings + # ======================================================================== + + def set_interface(self, iface: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_interface(iface)) + + def delete_interface(self, iface: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_interface_delete(iface)) + + def set_interface_authentication_md5_key(self, iface: str, key_id: str, password: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_interface_authentication_md5_key(iface, key_id, password)) + + def delete_interface_authentication_md5(self, iface: str, key_id: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_interface_authentication_md5_delete(iface, key_id)) + + def set_interface_authentication_plaintext(self, iface: str, password: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_interface_authentication_plaintext(iface, password)) + + def delete_interface_authentication(self, iface: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_interface_authentication_delete(iface)) + + def set_interface_receive_version(self, iface: str, value: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_interface_receive_version(iface, value)) + + def delete_interface_receive_version(self, iface: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_interface_receive_version_delete(iface)) + + def set_interface_send_version(self, iface: str, value: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_interface_send_version(iface, value)) + + def delete_interface_send_version(self, iface: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_interface_send_version_delete(iface)) + + def set_interface_split_horizon_disable(self, iface: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_interface_split_horizon_disable(iface)) + + def set_interface_split_horizon_poison_reverse(self, iface: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_interface_split_horizon_poison_reverse(iface)) + + def delete_interface_split_horizon(self, iface: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_interface_split_horizon_delete(iface)) + + # ======================================================================== + # Network Distance + # ======================================================================== + + def set_network_distance(self, prefix: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_network_distance(prefix)) + + def set_network_distance_value(self, prefix: str, value: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_network_distance_value(prefix, value)) + + def set_network_distance_access_list(self, prefix: str, acl: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_network_distance_access_list(prefix, acl)) + + def delete_network_distance(self, prefix: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_network_distance_delete(prefix)) + + # ======================================================================== + # Redistribute + # ======================================================================== + + def set_redistribute(self, protocol: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_redistribute(protocol)) + + def set_redistribute_metric(self, protocol: str, value: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_redistribute_metric(protocol, value)) + + def set_redistribute_route_map(self, protocol: str, value: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_redistribute_route_map(protocol, value)) + + def delete_redistribute(self, protocol: str) -> "RipBatchBuilder": + return self.add_delete(self.m.get_redistribute_delete(protocol)) + + # ======================================================================== + # Timers + # ======================================================================== + + def set_timers_update(self, value: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_timers_update(value)) + + def delete_timers_update(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_timers_update_delete()) + + def set_timers_timeout(self, value: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_timers_timeout(value)) + + def delete_timers_timeout(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_timers_timeout_delete()) + + def set_timers_garbage_collection(self, value: str) -> "RipBatchBuilder": + return self.add_set(self.m.get_timers_garbage_collection(value)) + + def delete_timers_garbage_collection(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_timers_garbage_collection_delete()) + + def delete_timers(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_timers_delete()) + + # ======================================================================== + # Delete entire RIP + # ======================================================================== + + def delete_rip(self) -> "RipBatchBuilder": + return self.add_delete(self.m.get_rip_delete()) diff --git a/backend/vyos_mappers/__init__.py b/backend/vyos_mappers/__init__.py index 99b9ed58..2800591f 100644 --- a/backend/vyos_mappers/__init__.py +++ b/backend/vyos_mappers/__init__.py @@ -101,6 +101,8 @@ from .pim.pim_versions import get_pim_mapper from .pim6 import Pim6Mapper from .pim6.pim6_versions import get_pim6_mapper +from .rip import RipMapper +from .rip.rip_versions import get_rip_mapper # Auto-register all mappers # Ethernet uses factory for version-specific mappers @@ -232,6 +234,8 @@ CommandMapperRegistry.register_feature("pim", get_pim_mapper) # PIMv6 uses factory for version-specific mappers CommandMapperRegistry.register_feature("pim6", get_pim6_mapper) +# RIP uses factory for version-specific mappers +CommandMapperRegistry.register_feature("rip", get_rip_mapper) __all__ = [ "BaseFeatureMapper", diff --git a/backend/vyos_mappers/rip/__init__.py b/backend/vyos_mappers/rip/__init__.py new file mode 100644 index 00000000..0cb13e1f --- /dev/null +++ b/backend/vyos_mappers/rip/__init__.py @@ -0,0 +1,4 @@ +"""RIP Protocol Mapper Package.""" +from .rip import RipMapper + +__all__ = ["RipMapper"] diff --git a/backend/vyos_mappers/rip/rip.py b/backend/vyos_mappers/rip/rip.py new file mode 100644 index 00000000..59ada890 --- /dev/null +++ b/backend/vyos_mappers/rip/rip.py @@ -0,0 +1,253 @@ +"""RIP Protocol Command Mapper.""" +from typing import List +from ..base import BaseFeatureMapper + + +class RipMapper(BaseFeatureMapper): + """Base mapper for RIP protocol configuration paths.""" + + def __init__(self, version: str): + super().__init__(version) + + def _rip(self) -> List[str]: + return ["protocols", "rip"] + + # ======================================================================== + # Global settings + # ======================================================================== + + def get_default_distance(self, value: str) -> List[str]: + return self._rip() + ["default-distance", value] + + def get_default_distance_delete(self) -> List[str]: + return self._rip() + ["default-distance"] + + def get_default_information_originate(self) -> List[str]: + return self._rip() + ["default-information", "originate"] + + def get_default_information_originate_delete(self) -> List[str]: + return self._rip() + ["default-information", "originate"] + + def get_default_metric(self, value: str) -> List[str]: + return self._rip() + ["default-metric", value] + + def get_default_metric_delete(self) -> List[str]: + return self._rip() + ["default-metric"] + + def get_route_map(self, value: str) -> List[str]: + return self._rip() + ["route-map", value] + + def get_route_map_delete(self) -> List[str]: + return self._rip() + ["route-map"] + + def get_version(self, value: str) -> List[str]: + return self._rip() + ["version", value] + + def get_version_delete(self) -> List[str]: + return self._rip() + ["version"] + + # ======================================================================== + # Networks + # ======================================================================== + + def get_network(self, network: str) -> List[str]: + return self._rip() + ["network", network] + + def get_network_delete(self, network: str) -> List[str]: + return self._rip() + ["network", network] + + # ======================================================================== + # Neighbors + # ======================================================================== + + def get_neighbor(self, address: str) -> List[str]: + return self._rip() + ["neighbor", address] + + def get_neighbor_delete(self, address: str) -> List[str]: + return self._rip() + ["neighbor", address] + + # ======================================================================== + # Static routes + # ======================================================================== + + def get_route(self, prefix: str) -> List[str]: + return self._rip() + ["route", prefix] + + def get_route_delete(self, prefix: str) -> List[str]: + return self._rip() + ["route", prefix] + + # ======================================================================== + # Passive interface + # ======================================================================== + + def get_passive_interface(self, iface: str) -> List[str]: + return self._rip() + ["passive-interface", iface] + + def get_passive_interface_delete(self, iface: str) -> List[str]: + return self._rip() + ["passive-interface", iface] + + # ======================================================================== + # Distribute list - global + # ======================================================================== + + def get_distribute_list_access_list_in(self, acl: str) -> List[str]: + return self._rip() + ["distribute-list", "access-list", "in", acl] + + def get_distribute_list_access_list_in_delete(self) -> List[str]: + return self._rip() + ["distribute-list", "access-list", "in"] + + def get_distribute_list_access_list_out(self, acl: str) -> List[str]: + return self._rip() + ["distribute-list", "access-list", "out", acl] + + def get_distribute_list_access_list_out_delete(self) -> List[str]: + return self._rip() + ["distribute-list", "access-list", "out"] + + def get_distribute_list_prefix_list_in(self, prefix_list: str) -> List[str]: + return self._rip() + ["distribute-list", "prefix-list", "in", prefix_list] + + def get_distribute_list_prefix_list_in_delete(self) -> List[str]: + return self._rip() + ["distribute-list", "prefix-list", "in"] + + def get_distribute_list_prefix_list_out(self, prefix_list: str) -> List[str]: + return self._rip() + ["distribute-list", "prefix-list", "out", prefix_list] + + def get_distribute_list_prefix_list_out_delete(self) -> List[str]: + return self._rip() + ["distribute-list", "prefix-list", "out"] + + # ======================================================================== + # Distribute list - per interface + # ======================================================================== + + def get_distribute_list_interface_access_list_in(self, iface: str, acl: str) -> List[str]: + return self._rip() + ["distribute-list", "interface", iface, "access-list", "in", acl] + + def get_distribute_list_interface_access_list_in_delete(self, iface: str) -> List[str]: + return self._rip() + ["distribute-list", "interface", iface, "access-list", "in"] + + def get_distribute_list_interface_access_list_out(self, iface: str, acl: str) -> List[str]: + return self._rip() + ["distribute-list", "interface", iface, "access-list", "out", acl] + + def get_distribute_list_interface_access_list_out_delete(self, iface: str) -> List[str]: + return self._rip() + ["distribute-list", "interface", iface, "access-list", "out"] + + def get_distribute_list_interface_prefix_list_in(self, iface: str, prefix_list: str) -> List[str]: + return self._rip() + ["distribute-list", "interface", iface, "prefix-list", "in", prefix_list] + + def get_distribute_list_interface_prefix_list_in_delete(self, iface: str) -> List[str]: + return self._rip() + ["distribute-list", "interface", iface, "prefix-list", "in"] + + def get_distribute_list_interface_prefix_list_out(self, iface: str, prefix_list: str) -> List[str]: + return self._rip() + ["distribute-list", "interface", iface, "prefix-list", "out", prefix_list] + + def get_distribute_list_interface_prefix_list_out_delete(self, iface: str) -> List[str]: + return self._rip() + ["distribute-list", "interface", iface, "prefix-list", "out"] + + def get_distribute_list_interface_delete(self, iface: str) -> List[str]: + return self._rip() + ["distribute-list", "interface", iface] + + # ======================================================================== + # Interface settings + # ======================================================================== + + def get_interface(self, iface: str) -> List[str]: + return self._rip() + ["interface", iface] + + def get_interface_delete(self, iface: str) -> List[str]: + return self._rip() + ["interface", iface] + + def get_interface_authentication_md5_key(self, iface: str, key_id: str, password: str) -> List[str]: + return self._rip() + ["interface", iface, "authentication", "md5", key_id, "password", password] + + def get_interface_authentication_md5_delete(self, iface: str, key_id: str) -> List[str]: + return self._rip() + ["interface", iface, "authentication", "md5", key_id] + + def get_interface_authentication_plaintext(self, iface: str, password: str) -> List[str]: + return self._rip() + ["interface", iface, "authentication", "plaintext-password", password] + + def get_interface_authentication_delete(self, iface: str) -> List[str]: + return self._rip() + ["interface", iface, "authentication"] + + def get_interface_receive_version(self, iface: str, value: str) -> List[str]: + return self._rip() + ["interface", iface, "receive", "version", value] + + def get_interface_receive_version_delete(self, iface: str) -> List[str]: + return self._rip() + ["interface", iface, "receive", "version"] + + def get_interface_send_version(self, iface: str, value: str) -> List[str]: + return self._rip() + ["interface", iface, "send", "version", value] + + def get_interface_send_version_delete(self, iface: str) -> List[str]: + return self._rip() + ["interface", iface, "send", "version"] + + def get_interface_split_horizon_disable(self, iface: str) -> List[str]: + return self._rip() + ["interface", iface, "split-horizon", "disable"] + + def get_interface_split_horizon_poison_reverse(self, iface: str) -> List[str]: + return self._rip() + ["interface", iface, "split-horizon", "poison-reverse"] + + def get_interface_split_horizon_delete(self, iface: str) -> List[str]: + return self._rip() + ["interface", iface, "split-horizon"] + + # ======================================================================== + # Network distance + # ======================================================================== + + def get_network_distance(self, prefix: str) -> List[str]: + return self._rip() + ["network-distance", prefix] + + def get_network_distance_value(self, prefix: str, value: str) -> List[str]: + return self._rip() + ["network-distance", prefix, "distance", value] + + def get_network_distance_access_list(self, prefix: str, acl: str) -> List[str]: + return self._rip() + ["network-distance", prefix, "access-list", acl] + + def get_network_distance_delete(self, prefix: str) -> List[str]: + return self._rip() + ["network-distance", prefix] + + # ======================================================================== + # Redistribute + # ======================================================================== + + def get_redistribute(self, protocol: str) -> List[str]: + return self._rip() + ["redistribute", protocol] + + def get_redistribute_metric(self, protocol: str, value: str) -> List[str]: + return self._rip() + ["redistribute", protocol, "metric", value] + + def get_redistribute_route_map(self, protocol: str, value: str) -> List[str]: + return self._rip() + ["redistribute", protocol, "route-map", value] + + def get_redistribute_delete(self, protocol: str) -> List[str]: + return self._rip() + ["redistribute", protocol] + + # ======================================================================== + # Timers + # ======================================================================== + + def get_timers_update(self, value: str) -> List[str]: + return self._rip() + ["timers", "update", value] + + def get_timers_update_delete(self) -> List[str]: + return self._rip() + ["timers", "update"] + + def get_timers_timeout(self, value: str) -> List[str]: + return self._rip() + ["timers", "timeout", value] + + def get_timers_timeout_delete(self) -> List[str]: + return self._rip() + ["timers", "timeout"] + + def get_timers_garbage_collection(self, value: str) -> List[str]: + return self._rip() + ["timers", "garbage-collection", value] + + def get_timers_garbage_collection_delete(self) -> List[str]: + return self._rip() + ["timers", "garbage-collection"] + + def get_timers_delete(self) -> List[str]: + return self._rip() + ["timers"] + + # ======================================================================== + # Delete entire RIP + # ======================================================================== + + def get_rip_delete(self) -> List[str]: + return self._rip() diff --git a/backend/vyos_mappers/rip/rip_versions/__init__.py b/backend/vyos_mappers/rip/rip_versions/__init__.py new file mode 100644 index 00000000..b0d37ac6 --- /dev/null +++ b/backend/vyos_mappers/rip/rip_versions/__init__.py @@ -0,0 +1,24 @@ +"""Factory for version-specific RIP protocol mappers.""" +from ..rip import RipMapper +from .v1_4 import RipMapperV1_4 +from .v1_5 import RipMapperV1_5 + + +def get_rip_mapper(version: str): + """Return a version-merged RIP mapper.""" + base = RipMapper(version) + + if "1.4" in version: + version_specific = RipMapperV1_4() + elif "1.5" in version or "latest" in version: + version_specific = RipMapperV1_5() + else: + version_specific = RipMapperV1_5() + + class MergedMapper: + def __getattr__(self, name): + if hasattr(version_specific, name): + return getattr(version_specific, name) + return getattr(base, name) + + return MergedMapper() diff --git a/backend/vyos_mappers/rip/rip_versions/v1_4.py b/backend/vyos_mappers/rip/rip_versions/v1_4.py new file mode 100644 index 00000000..c5db4eaf --- /dev/null +++ b/backend/vyos_mappers/rip/rip_versions/v1_4.py @@ -0,0 +1,9 @@ +"""VyOS 1.4 specific RIP mapper overrides. + +RIP configuration is identical between VyOS 1.4 and 1.5 — no overrides needed. +""" + + +class RipMapperV1_4: + """VyOS 1.4 specific RIP paths. No overrides from base.""" + pass diff --git a/backend/vyos_mappers/rip/rip_versions/v1_5.py b/backend/vyos_mappers/rip/rip_versions/v1_5.py new file mode 100644 index 00000000..aba3da98 --- /dev/null +++ b/backend/vyos_mappers/rip/rip_versions/v1_5.py @@ -0,0 +1,9 @@ +"""VyOS 1.5 specific RIP mapper overrides. + +RIP configuration is identical between VyOS 1.4 and 1.5 — no overrides needed. +""" + + +class RipMapperV1_5: + """VyOS 1.5 specific RIP paths. No overrides from base.""" + pass diff --git a/frontend/src/app/routing/unicast-protocols/page.tsx b/frontend/src/app/routing/unicast-protocols/page.tsx index 3d239397..15d4b126 100644 --- a/frontend/src/app/routing/unicast-protocols/page.tsx +++ b/frontend/src/app/routing/unicast-protocols/page.tsx @@ -8,6 +8,7 @@ import { IsisContent } from "@/components/isis/IsisContent"; import { OpenfabricContent } from "@/components/openfabric/OpenfabricContent"; import { OspfContent } from "@/components/ospf/OspfContent"; import { Ospfv3Content } from "@/components/ospfv3/Ospfv3Content"; +import { RipContent } from "@/components/rip/RipContent"; import { ScrollArea } from "@/components/ui/scroll-area"; import { Separator } from "@/components/ui/separator"; import { Network, ChevronRight } from "lucide-react"; @@ -137,6 +138,10 @@ export default function UnicastProtocolsPage() { ) : selectedProtocol === "openfabric" ? ( + ) : selectedProtocol === "rip" ? ( + + ) : selectedProtocol === "ripng" ? ( + ) : ( )} diff --git a/frontend/src/components/rip/DeleteRipModal.tsx b/frontend/src/components/rip/DeleteRipModal.tsx new file mode 100644 index 00000000..375316ad --- /dev/null +++ b/frontend/src/components/rip/DeleteRipModal.tsx @@ -0,0 +1,74 @@ +"use client"; + +import { useState } from "react"; +import { + AlertDialog, + AlertDialogAction, + AlertDialogCancel, + AlertDialogContent, + AlertDialogDescription, + AlertDialogFooter, + AlertDialogHeader, + AlertDialogTitle, +} from "@/components/ui/alert-dialog"; +import { Loader2 } from "lucide-react"; + +interface DeleteRipModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + itemType: string; + itemName: string; + onConfirm: () => Promise; +} + +export function DeleteRipModal({ + open, + onOpenChange, + itemType, + itemName, + onConfirm, +}: DeleteRipModalProps) { + const [loading, setLoading] = useState(false); + + const handleConfirm = async () => { + setLoading(true); + try { + await onConfirm(); + } finally { + setLoading(false); + } + }; + + return ( + + + + Delete RIP {itemType} + + Are you sure you want to delete the RIP {itemType.toLowerCase()}{" "} + {itemName}? + This action cannot be undone. + + + + + Cancel + + {loading ? ( + <> + + Deleting... + + ) : ( + `Delete ${itemType}` + )} + + + + + ); +} diff --git a/frontend/src/components/rip/RipContent.tsx b/frontend/src/components/rip/RipContent.tsx new file mode 100644 index 00000000..9b5bfed9 --- /dev/null +++ b/frontend/src/components/rip/RipContent.tsx @@ -0,0 +1,1395 @@ +"use client"; + +import { useState, useEffect, useCallback } from "react"; +import { Card, CardContent } from "@/components/ui/card"; +import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs"; +import { Badge } from "@/components/ui/badge"; +import { Button } from "@/components/ui/button"; +import { Checkbox } from "@/components/ui/checkbox"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { + Table, + TableBody, + TableCell, + TableHead, + TableHeader, + TableRow, +} from "@/components/ui/table"; +import { + Network, + Plus, + RefreshCw, + Pencil, + Trash2, + ArrowLeftRight, + Filter, + Save, + Loader2, + X, + Globe, + Lock, +} from "lucide-react"; +import { LoadingSpinner } from "@/components/ui/loading-spinner"; +import { + ripService, + RipConfig, + RipCapabilities, + RipInterface, + RipRedistribute, + RipNetworkDistance, + RipDistributeListGlobal, + RipDistributeListInterface, + RipTimers, +} from "@/lib/api/rip"; +import { routeMapService } from "@/lib/api/route-map"; +import { accessListService } from "@/lib/api/access-list"; +import { prefixListService } from "@/lib/api/prefix-list"; +import { showService } from "@/lib/api/show"; +import { RipInterfaceModal } from "./RipInterfaceModal"; +import { RipRedistributeModal } from "./RipRedistributeModal"; +import { RipNetworkDistanceModal } from "./RipNetworkDistanceModal"; +import { RipDistributeListInterfaceModal } from "./RipDistributeListInterfaceModal"; +import { DeleteRipModal } from "./DeleteRipModal"; +import { usePermissions } from "@/hooks/usePermissions"; +import { FeatureGroup } from "@/lib/api/user-management"; + +export function RipContent() { + const { canWrite } = usePermissions(); + const hasWritePermission = canWrite(FeatureGroup.RIP); + + const [config, setConfig] = useState(null); + const [, setCapabilities] = useState(null); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + const [activeTab, setActiveTab] = useState("overview"); + + const [routeMapNames, setRouteMapNames] = useState([]); + const [accessListNames, setAccessListNames] = useState([]); + const [prefixListNames, setPrefixListNames] = useState([]); + const [systemInterfaces, setSystemInterfaces] = useState([]); + + // ============================================================ + // Overview / Timers editing + // ============================================================ + const [overviewEditing, setOverviewEditing] = useState(false); + const [overviewSaving, setOverviewSaving] = useState(false); + const [overviewError, setOverviewError] = useState(null); + const [ovVersion, setOvVersion] = useState(""); + const [ovDefaultDistance, setOvDefaultDistance] = useState(""); + const [ovDefaultMetric, setOvDefaultMetric] = useState(""); + const [ovRouteMap, setOvRouteMap] = useState(""); + const [ovOriginate, setOvOriginate] = useState(false); + const [ovTimerUpdate, setOvTimerUpdate] = useState(""); + const [ovTimerTimeout, setOvTimerTimeout] = useState(""); + const [ovTimerGc, setOvTimerGc] = useState(""); + + // ============================================================ + // Networks tab: inline add/remove state + // ============================================================ + const [newNetwork, setNewNetwork] = useState(""); + const [networksError, setNetworksError] = useState(null); + const [newNeighbor, setNewNeighbor] = useState(""); + const [neighborsError, setNeighborsError] = useState(null); + const [newRoute, setNewRoute] = useState(""); + const [routesError, setRoutesError] = useState(null); + const [newPassiveIface, setNewPassiveIface] = useState(""); + const [passiveIfaceError, setPassiveIfaceError] = useState(null); + + // ============================================================ + // Interface modal state + // ============================================================ + const [ifaceModalOpen, setIfaceModalOpen] = useState(false); + const [editingIface, setEditingIface] = useState(null); + const [deletingIface, setDeletingIface] = useState(null); + + // ============================================================ + // Redistribute modal state + // ============================================================ + const [redistModalOpen, setRedistModalOpen] = useState(false); + const [editingRedist, setEditingRedist] = useState(null); + const [deletingRedist, setDeletingRedist] = useState(null); + + // ============================================================ + // Network distance modal state + // ============================================================ + const [ndModalOpen, setNdModalOpen] = useState(false); + const [editingNd, setEditingNd] = useState(null); + const [deletingNd, setDeletingNd] = useState(null); + + // ============================================================ + // Distribute list global editing + // ============================================================ + const [dlGlobalEditing, setDlGlobalEditing] = useState(false); + const [dlGlobalSaving, setDlGlobalSaving] = useState(false); + const [dlGlobalError, setDlGlobalError] = useState(null); + const [dlGlobalDraft, setDlGlobalDraft] = useState({}); + + // ============================================================ + // Distribute list interface modal state + // ============================================================ + const [dlIfaceModalOpen, setDlIfaceModalOpen] = useState(false); + const [editingDlIface, setEditingDlIface] = useState(null); + const [deletingDlIface, setDeletingDlIface] = useState(null); + + // ============================================================ + // Load + // ============================================================ + + const loadData = useCallback(async (refresh = false) => { + try { + setLoading(true); + setError(null); + const [configData, capData, routeMaps, accessLists, prefixLists, interfaces] = + await Promise.all([ + ripService.getConfig(refresh), + ripService.getCapabilities(), + routeMapService.getConfig().then((c) => c.route_maps.map((rm) => rm.name)).catch(() => [] as string[]), + accessListService.getConfig().then((c) => c.ipv4_lists.map((al) => al.number)).catch(() => [] as string[]), + prefixListService.getConfig().then((c) => c.ipv4_lists.map((pl) => pl.name)).catch(() => [] as string[]), + showService.getAllInterfaces().then((r) => r.interfaces.map((i) => i.name)).catch(() => [] as string[]), + ]); + setConfig(configData); + setCapabilities(capData); + setRouteMapNames(routeMaps); + setAccessListNames(accessLists); + setPrefixListNames(prefixLists); + setSystemInterfaces(interfaces); + } catch (err) { + setError(err instanceof Error ? err.message : "Failed to load RIP configuration"); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + loadData(); + }, [loadData]); + + // ============================================================ + // Overview handlers + // ============================================================ + + const startEditOverview = () => { + if (!config) return; + setOvVersion(config.version || ""); + setOvDefaultDistance(config.default_distance != null ? String(config.default_distance) : ""); + setOvDefaultMetric(config.default_metric != null ? String(config.default_metric) : ""); + setOvRouteMap(config.route_map || ""); + setOvOriginate(config.default_information_originate); + setOvTimerUpdate(config.timers.update != null ? String(config.timers.update) : ""); + setOvTimerTimeout(config.timers.timeout != null ? String(config.timers.timeout) : ""); + setOvTimerGc(config.timers.garbage_collection != null ? String(config.timers.garbage_collection) : ""); + setOverviewError(null); + setOverviewEditing(true); + }; + + const cancelEditOverview = () => { + setOverviewEditing(false); + setOverviewError(null); + }; + + const saveOverview = async () => { + if (!config) return; + try { + setOverviewSaving(true); + setOverviewError(null); + + const updatedConfig: Partial = { + version: ovVersion || null, + default_distance: ovDefaultDistance.trim() ? parseInt(ovDefaultDistance.trim(), 10) : null, + default_metric: ovDefaultMetric.trim() ? parseInt(ovDefaultMetric.trim(), 10) : null, + route_map: ovRouteMap || null, + default_information_originate: ovOriginate, + }; + + const updatedTimers: RipTimers = { + update: ovTimerUpdate.trim() ? parseInt(ovTimerUpdate.trim(), 10) : null, + timeout: ovTimerTimeout.trim() ? parseInt(ovTimerTimeout.trim(), 10) : null, + garbage_collection: ovTimerGc.trim() ? parseInt(ovTimerGc.trim(), 10) : null, + }; + + await ripService.updateGlobalSettings(config, updatedConfig); + await ripService.updateTimers(config.timers, updatedTimers); + await loadData(true); + setOverviewEditing(false); + } catch (err) { + setOverviewError(err instanceof Error ? err.message : "Failed to save settings"); + } finally { + setOverviewSaving(false); + } + }; + + // ============================================================ + // Networks tab handlers + // ============================================================ + + const handleAddNetwork = async () => { + if (!newNetwork.trim()) return; + try { + setNetworksError(null); + await ripService.addNetwork(newNetwork.trim()); + setNewNetwork(""); + await loadData(true); + } catch (err) { + setNetworksError(err instanceof Error ? err.message : "Failed to add network"); + } + }; + + const handleRemoveNetwork = async (network: string) => { + try { + setNetworksError(null); + await ripService.removeNetwork(network); + await loadData(true); + } catch (err) { + setNetworksError(err instanceof Error ? err.message : "Failed to remove network"); + } + }; + + const handleAddNeighbor = async () => { + if (!newNeighbor.trim()) return; + try { + setNeighborsError(null); + await ripService.addNeighbor(newNeighbor.trim()); + setNewNeighbor(""); + await loadData(true); + } catch (err) { + setNeighborsError(err instanceof Error ? err.message : "Failed to add neighbor"); + } + }; + + const handleRemoveNeighbor = async (address: string) => { + try { + setNeighborsError(null); + await ripService.removeNeighbor(address); + await loadData(true); + } catch (err) { + setNeighborsError(err instanceof Error ? err.message : "Failed to remove neighbor"); + } + }; + + const handleAddRoute = async () => { + if (!newRoute.trim()) return; + try { + setRoutesError(null); + await ripService.addRoute(newRoute.trim()); + setNewRoute(""); + await loadData(true); + } catch (err) { + setRoutesError(err instanceof Error ? err.message : "Failed to add route"); + } + }; + + const handleRemoveRoute = async (prefix: string) => { + try { + setRoutesError(null); + await ripService.removeRoute(prefix); + await loadData(true); + } catch (err) { + setRoutesError(err instanceof Error ? err.message : "Failed to remove route"); + } + }; + + const handleAddPassiveIface = async () => { + if (!newPassiveIface.trim()) return; + try { + setPassiveIfaceError(null); + await ripService.addPassiveInterface(newPassiveIface.trim()); + setNewPassiveIface(""); + await loadData(true); + } catch (err) { + setPassiveIfaceError(err instanceof Error ? err.message : "Failed to add passive interface"); + } + }; + + const handleRemovePassiveIface = async (iface: string) => { + try { + setPassiveIfaceError(null); + await ripService.removePassiveInterface(iface); + await loadData(true); + } catch (err) { + setPassiveIfaceError(err instanceof Error ? err.message : "Failed to remove passive interface"); + } + }; + + // ============================================================ + // Interface handlers + // ============================================================ + + const handleCreateIface = async (iface: RipInterface) => { + await ripService.createInterface(iface); + await loadData(true); + }; + + const handleUpdateIface = async (iface: RipInterface) => { + if (!editingIface) return; + await ripService.updateInterface(editingIface, iface); + await loadData(true); + }; + + const handleDeleteIface = async () => { + if (!deletingIface) return; + await ripService.deleteInterface(deletingIface.name); + setDeletingIface(null); + await loadData(true); + }; + + // ============================================================ + // Redistribute handlers + // ============================================================ + + const handleCreateRedist = async (entry: RipRedistribute) => { + await ripService.createRedistribute(entry); + await loadData(true); + }; + + const handleUpdateRedist = async (entry: RipRedistribute) => { + if (!editingRedist) return; + await ripService.updateRedistribute(editingRedist, entry); + await loadData(true); + }; + + const handleDeleteRedist = async () => { + if (!deletingRedist) return; + await ripService.deleteRedistribute(deletingRedist.protocol); + setDeletingRedist(null); + await loadData(true); + }; + + // ============================================================ + // Network Distance handlers + // ============================================================ + + const handleCreateNd = async (entry: RipNetworkDistance) => { + await ripService.createNetworkDistance(entry); + await loadData(true); + }; + + const handleUpdateNd = async (entry: RipNetworkDistance) => { + if (!editingNd) return; + await ripService.updateNetworkDistance(editingNd, entry); + await loadData(true); + }; + + const handleDeleteNd = async () => { + if (!deletingNd) return; + await ripService.deleteNetworkDistance(deletingNd.prefix); + setDeletingNd(null); + await loadData(true); + }; + + // ============================================================ + // Distribute List Global handlers + // ============================================================ + + const startEditDlGlobal = () => { + if (!config) return; + setDlGlobalDraft({ ...config.distribute_list.global_filters }); + setDlGlobalError(null); + setDlGlobalEditing(true); + }; + + const cancelEditDlGlobal = () => { + setDlGlobalEditing(false); + setDlGlobalError(null); + }; + + const saveDlGlobal = async () => { + if (!config) return; + try { + setDlGlobalSaving(true); + setDlGlobalError(null); + await ripService.updateDistributeListGlobal( + config.distribute_list.global_filters, + dlGlobalDraft + ); + await loadData(true); + setDlGlobalEditing(false); + } catch (err) { + setDlGlobalError(err instanceof Error ? err.message : "Failed to save filters"); + } finally { + setDlGlobalSaving(false); + } + }; + + // ============================================================ + // Distribute List Interface handlers + // ============================================================ + + const handleCreateDlIface = async (entry: RipDistributeListInterface) => { + await ripService.createDistributeListInterface(entry); + await loadData(true); + }; + + const handleUpdateDlIface = async (entry: RipDistributeListInterface) => { + if (!editingDlIface) return; + await ripService.updateDistributeListInterface(editingDlIface, entry); + await loadData(true); + }; + + const handleDeleteDlIface = async () => { + if (!deletingDlIface) return; + await ripService.deleteDistributeListInterface(deletingDlIface.interface); + setDeletingDlIface(null); + await loadData(true); + }; + + // ============================================================ + // Derived values + // ============================================================ + + const networkCount = config?.networks.length ?? 0; + const ifaceCount = config?.interfaces.length ?? 0; + const redistCount = config?.redistribute.length ?? 0; + + const versionBadge = config?.version ? `v${config.version}` : "—"; + + // ============================================================ + // Render helpers + // ============================================================ + + const renderListSection = ( + title: string, + items: string[], + inputValue: string, + onInputChange: (v: string) => void, + onAdd: () => Promise, + onRemove: (item: string) => Promise, + err: string | null, + inputPlaceholder: string, + isDropdown?: boolean, + dropdownOptions?: string[] + ) => ( + + +

{title}

+ {hasWritePermission && ( +
+ {isDropdown && dropdownOptions ? ( + + ) : ( + onInputChange(e.target.value)} + onKeyDown={(e) => { if (e.key === "Enter") onAdd(); }} + className="flex-1 font-mono text-sm" + /> + )} + +
+ )} + {err && ( +

{err}

+ )} + {items.length === 0 ? ( +

None configured

+ ) : ( +
+ {items.map((item) => ( +
+ {item} + {hasWritePermission && ( + + )} +
+ ))} +
+ )} +
+
+ ); + + // ============================================================ + // Loading / Error states + // ============================================================ + + if (loading) { + return ( +
+ +
+ ); + } + + if (error && !config) { + return ( +
+

{error}

+ +
+ ); + } + + // ============================================================ + // Render + // ============================================================ + + return ( + <> +
+ {/* Header */} +
+
+
+
+

RIP Protocol

+ {!hasWritePermission && ( + + + Read Only + + )} +
+

+ Routing Information Protocol — distance-vector routing +

+
+ +
+ + {error && ( +
+ {error} +
+ )} + + {/* Stats */} +
+ + +
+
+ +
+
+

{versionBadge}

+

Version

+
+
+
+
+ + +
+
+ +
+
+

{networkCount}

+

Networks

+
+
+
+
+ + +
+
+ +
+
+

{ifaceCount}

+

Interfaces

+
+
+
+
+ + +
+
+ +
+
+

{redistCount}

+

Redistribute

+
+
+
+
+
+
+ + {/* Tabs */} +
+ + + Overview + + Networks + {(config?.networks.length ?? 0) > 0 && ( + {config?.networks.length} + )} + + + Interfaces + {ifaceCount > 0 && {ifaceCount}} + + + Redistribute + {redistCount > 0 && {redistCount}} + + Filters + + + {/* ============================================================ */} + {/* Overview Tab */} + {/* ============================================================ */} + +
+

Global RIP settings and timers

+ {hasWritePermission && ( + !overviewEditing ? ( + + ) : ( +
+ + +
+ ) + )} +
+ + {overviewError && ( +
+
{overviewError}
+
+ )} + +
+ + +

Global Settings

+
+ {/* Version */} +
+ + +
+ + {/* Default Distance */} +
+ +

Administrative distance (1-255)

+ setOvDefaultDistance(e.target.value)} + /> +
+ + {/* Default Metric */} +
+ +

Metric for redistributed routes (1-16)

+ setOvDefaultMetric(e.target.value)} + /> +
+ + {/* Route Map */} +
+ + {overviewEditing ? ( + + ) : ( + + )} +
+ + {/* Default Information Originate */} +
+ setOvOriginate(!!checked)} + /> + +
+
+
+
+ + + +

Timers

+
+
+ +

How often to send routing updates (default: 30)

+ setOvTimerUpdate(e.target.value)} + /> +
+
+ +

Time before a route is marked invalid (default: 180)

+ setOvTimerTimeout(e.target.value)} + /> +
+
+ +

Time before a stale route is removed (default: 120)

+ setOvTimerGc(e.target.value)} + /> +
+
+
+
+
+
+ + {/* ============================================================ */} + {/* Networks Tab */} + {/* ============================================================ */} + +

+ Configure RIP networks, neighbors, static routes, and passive interfaces +

+
+ {renderListSection( + "RIP Networks", + config?.networks ?? [], + newNetwork, + setNewNetwork, + handleAddNetwork, + handleRemoveNetwork, + networksError, + "e.g. 10.0.0.0/8" + )} + {renderListSection( + "Neighbors", + config?.neighbors ?? [], + newNeighbor, + setNewNeighbor, + handleAddNeighbor, + handleRemoveNeighbor, + neighborsError, + "e.g. 192.168.1.1" + )} + {renderListSection( + "Static Routes", + config?.routes ?? [], + newRoute, + setNewRoute, + handleAddRoute, + handleRemoveRoute, + routesError, + "e.g. 10.0.0.0/8" + )} + {renderListSection( + "Passive Interfaces", + config?.passive_interfaces ?? [], + newPassiveIface, + setNewPassiveIface, + handleAddPassiveIface, + handleRemovePassiveIface, + passiveIfaceError, + "Select or type interface", + true, + systemInterfaces + )} +
+
+ + {/* ============================================================ */} + {/* Interfaces Tab */} + {/* ============================================================ */} + +
+

+ Per-interface authentication, version, and split-horizon settings +

+ {hasWritePermission && ( + + )} +
+ + {ifaceCount === 0 ? ( + + + +

No interface settings configured

+

+ Add one to configure authentication or split-horizon. +

+ {hasWritePermission && ( + + )} +
+
+ ) : ( + + + + + + Interface + Auth Type + Send Version + Recv Version + Split Horizon + Actions + + + + {config?.interfaces.map((iface) => ( + + {iface.name} + + {iface.authentication_type ? ( + {iface.authentication_type} + ) : ( + + )} + + + {iface.send_version ? ( + v{iface.send_version} + ) : ( + default + )} + + + {iface.receive_version ? ( + v{iface.receive_version} + ) : ( + default + )} + + + {iface.split_horizon ? ( + {iface.split_horizon} + ) : ( + default + )} + + + {hasWritePermission && ( +
+ + +
+ )} +
+
+ ))} +
+
+
+
+ )} +
+ + {/* ============================================================ */} + {/* Redistribute Tab */} + {/* ============================================================ */} + +
+

+ Redistribute routes from other protocols into RIP +

+ {hasWritePermission && ( + + )} +
+ + {redistCount === 0 ? ( + + + +

No redistribution configured

+ {hasWritePermission && ( + + )} +
+
+ ) : ( + + + + + + Protocol + Metric + Route Map + Actions + + + + {config?.redistribute.map((r) => ( + + {r.protocol} + + {r.metric != null ? r.metric : } + + + {r.route_map ? ( + {r.route_map} + ) : ( + + )} + + + {hasWritePermission && ( +
+ + +
+ )} +
+
+ ))} +
+
+
+
+ )} +
+ + {/* ============================================================ */} + {/* Filters Tab */} + {/* ============================================================ */} + +
+ {/* Distribute Lists Section */} +
+

Distribute Lists

+ + {/* Global Filters */} +
+
+

Global Filters

+ {hasWritePermission && ( + !dlGlobalEditing ? ( + + ) : ( +
+ + +
+ ) + )} +
+ + {dlGlobalError && ( +
+
{dlGlobalError}
+
+ )} + + + +
+ {(["access_list_in", "access_list_out", "prefix_list_in", "prefix_list_out"] as const).map((field) => { + const label = field === "access_list_in" ? "Access List In" + : field === "access_list_out" ? "Access List Out" + : field === "prefix_list_in" ? "Prefix List In" + : "Prefix List Out"; + const isAcl = field.startsWith("access"); + const names = isAcl ? accessListNames : prefixListNames; + const currentVal = dlGlobalEditing + ? (dlGlobalDraft[field] ?? "") + : (config?.distribute_list.global_filters[field] ?? ""); + + return ( +
+ + {dlGlobalEditing ? ( + + ) : ( + + )} +
+ ); + })} +
+
+
+
+ + {/* Per-Interface Filters */} +
+
+

Per-Interface Filters

+ {hasWritePermission && ( + + )} +
+ + {(config?.distribute_list.interface_filters.length ?? 0) === 0 ? ( + + +

No per-interface filters configured

+
+
+ ) : ( + + + + + + Interface + ACL In + ACL Out + PL In + PL Out + Actions + + + + {config?.distribute_list.interface_filters.map((f) => ( + + {f.interface} + {f.access_list_in ?? } + {f.access_list_out ?? } + {f.prefix_list_in ?? } + {f.prefix_list_out ?? } + + {hasWritePermission && ( +
+ + +
+ )} +
+
+ ))} +
+
+
+
+ )} +
+
+ + {/* Network Distance Section */} +
+
+

Network Distance

+ {hasWritePermission && ( + + )} +
+ + {(config?.network_distances.length ?? 0) === 0 ? ( + + +

No network distance entries configured

+ {hasWritePermission && ( + + )} +
+
+ ) : ( + + + + + + Network Prefix + Distance + Access List + Actions + + + + {config?.network_distances.map((nd) => ( + + {nd.prefix} + {nd.distance ?? } + + {nd.access_list ? ( + {nd.access_list} + ) : ( + + )} + + + {hasWritePermission && ( +
+ + +
+ )} +
+
+ ))} +
+
+
+
+ )} +
+
+
+
+
+
+ + {/* Modals */} + { + setIfaceModalOpen(open); + if (!open) setEditingIface(null); + }} + existingInterface={editingIface} + onSubmit={editingIface ? handleUpdateIface : handleCreateIface} + /> + + { if (!open) setDeletingIface(null); }} + itemType="Interface" + itemName={deletingIface?.name ?? ""} + onConfirm={handleDeleteIface} + /> + + { + setRedistModalOpen(open); + if (!open) setEditingRedist(null); + }} + existingEntry={editingRedist} + existingProtocols={config?.redistribute.map((r) => r.protocol) ?? []} + routeMapNames={routeMapNames} + onSubmit={editingRedist ? handleUpdateRedist : handleCreateRedist} + /> + + { if (!open) setDeletingRedist(null); }} + itemType="Redistribution" + itemName={deletingRedist?.protocol ?? ""} + onConfirm={handleDeleteRedist} + /> + + { + setNdModalOpen(open); + if (!open) setEditingNd(null); + }} + existingEntry={editingNd} + existingPrefixes={config?.network_distances.map((nd) => nd.prefix) ?? []} + accessListNames={accessListNames} + onSubmit={editingNd ? handleUpdateNd : handleCreateNd} + /> + + { if (!open) setDeletingNd(null); }} + itemType="Network Distance" + itemName={deletingNd?.prefix ?? ""} + onConfirm={handleDeleteNd} + /> + + { + setDlIfaceModalOpen(open); + if (!open) setEditingDlIface(null); + }} + existingEntry={editingDlIface} + existingInterfaces={config?.distribute_list.interface_filters.map((f) => f.interface) ?? []} + availableInterfaces={systemInterfaces} + accessListNames={accessListNames} + prefixListNames={prefixListNames} + onSubmit={editingDlIface ? handleUpdateDlIface : handleCreateDlIface} + /> + + { if (!open) setDeletingDlIface(null); }} + itemType="Interface Filter" + itemName={deletingDlIface?.interface ?? ""} + onConfirm={handleDeleteDlIface} + /> + + ); +} diff --git a/frontend/src/components/rip/RipDistributeListInterfaceModal.tsx b/frontend/src/components/rip/RipDistributeListInterfaceModal.tsx new file mode 100644 index 00000000..91a98cef --- /dev/null +++ b/frontend/src/components/rip/RipDistributeListInterfaceModal.tsx @@ -0,0 +1,223 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AlertCircle, Loader2 } from "lucide-react"; +import type { RipDistributeListInterface } from "@/lib/api/rip"; + +interface RipDistributeListInterfaceModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (entry: RipDistributeListInterface) => Promise; + existingEntry?: RipDistributeListInterface | null; + existingInterfaces: string[]; + availableInterfaces: string[]; + accessListNames: string[]; + prefixListNames: string[]; +} + +export function RipDistributeListInterfaceModal({ + open, + onOpenChange, + onSubmit, + existingEntry, + existingInterfaces, + availableInterfaces, + accessListNames, + prefixListNames, +}: RipDistributeListInterfaceModalProps) { + const isEditMode = !!existingEntry; + + const [iface, setIface] = useState(""); + const [aclIn, setAclIn] = useState(""); + const [aclOut, setAclOut] = useState(""); + const [plIn, setPlIn] = useState(""); + const [plOut, setPlOut] = useState(""); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const selectableInterfaces = isEditMode + ? availableInterfaces + : availableInterfaces.filter((i) => !existingInterfaces.includes(i)); + + useEffect(() => { + if (!open) return; + if (existingEntry) { + setIface(existingEntry.interface); + setAclIn(existingEntry.access_list_in || ""); + setAclOut(existingEntry.access_list_out || ""); + setPlIn(existingEntry.prefix_list_in || ""); + setPlOut(existingEntry.prefix_list_out || ""); + } else { + setIface(""); + setAclIn(""); + setAclOut(""); + setPlIn(""); + setPlOut(""); + setError(null); + } + }, [open, existingEntry]); + + const handleClose = () => { + setIface(""); + setAclIn(""); + setAclOut(""); + setPlIn(""); + setPlOut(""); + setError(null); + onOpenChange(false); + }; + + const validate = (): string | null => { + if (!iface) return "Please select an interface"; + return null; + }; + + const handleSubmit = async () => { + const validationError = validate(); + if (validationError) { + setError(validationError); + return; + } + + const entry: RipDistributeListInterface = { + interface: iface, + access_list_in: aclIn || null, + access_list_out: aclOut || null, + prefix_list_in: plIn || null, + prefix_list_out: plOut || null, + }; + + try { + setLoading(true); + setError(null); + await onSubmit(entry); + handleClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Operation failed"); + } finally { + setLoading(false); + } + }; + + const renderAclSelect = (value: string, onChange: (v: string) => void, id: string) => ( + + ); + + const renderPlSelect = (value: string, onChange: (v: string) => void, id: string) => ( + + ); + + return ( + + + + + {isEditMode ? "Edit Interface Filter" : "Add Interface Filter"} + + + Configure distribute list filters for a specific interface. + + + +
+ {/* Interface */} +
+ + +
+ +
+
+ + {renderAclSelect(aclIn, setAclIn, "rip-dl-acl-in")} +
+
+ + {renderAclSelect(aclOut, setAclOut, "rip-dl-acl-out")} +
+
+ + {renderPlSelect(plIn, setPlIn, "rip-dl-pl-in")} +
+
+ + {renderPlSelect(plOut, setPlOut, "rip-dl-pl-out")} +
+
+
+ + {error && ( +
+ +
{error}
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/src/components/rip/RipInterfaceModal.tsx b/frontend/src/components/rip/RipInterfaceModal.tsx new file mode 100644 index 00000000..ed6a266c --- /dev/null +++ b/frontend/src/components/rip/RipInterfaceModal.tsx @@ -0,0 +1,315 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { AlertCircle, Loader2, Plus, Trash2 } from "lucide-react"; +import type { RipInterface, RipMd5Key } from "@/lib/api/rip"; +import { showService } from "@/lib/api/show"; + +interface RipInterfaceModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (config: RipInterface) => Promise; + existingInterface?: RipInterface | null; +} + +export function RipInterfaceModal({ + open, + onOpenChange, + onSubmit, + existingInterface, +}: RipInterfaceModalProps) { + const isEditMode = !!existingInterface; + + const [name, setName] = useState(""); + const [authType, setAuthType] = useState(""); + const [md5Keys, setMd5Keys] = useState([]); + const [plaintextPassword, setPlaintextPassword] = useState(""); + const [sendVersion, setSendVersion] = useState(""); + const [receiveVersion, setReceiveVersion] = useState(""); + const [splitHorizon, setSplitHorizon] = useState(""); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [availableInterfaces, setAvailableInterfaces] = useState([]); + + useEffect(() => { + if (!open) return; + showService.getAllInterfaces() + .then((res) => setAvailableInterfaces(res.interfaces.map((i) => i.name))) + .catch(() => {}); + + if (existingInterface) { + setName(existingInterface.name); + setAuthType(existingInterface.authentication_type || ""); + setMd5Keys(existingInterface.md5_keys.map((k) => ({ ...k }))); + setPlaintextPassword(existingInterface.plaintext_password || ""); + setSendVersion(existingInterface.send_version || ""); + setReceiveVersion(existingInterface.receive_version || ""); + setSplitHorizon(existingInterface.split_horizon || ""); + } else { + resetForm(); + } + }, [open, existingInterface]); + + const resetForm = () => { + setName(""); + setAuthType(""); + setMd5Keys([]); + setPlaintextPassword(""); + setSendVersion(""); + setReceiveVersion(""); + setSplitHorizon(""); + setError(null); + }; + + const handleClose = () => { + resetForm(); + onOpenChange(false); + }; + + const addMd5Key = () => { + setMd5Keys([...md5Keys, { key_id: "", password: "" }]); + }; + + const removeMd5Key = (idx: number) => { + setMd5Keys(md5Keys.filter((_, i) => i !== idx)); + }; + + const updateMd5Key = (idx: number, field: "key_id" | "password", value: string) => { + const updated = [...md5Keys]; + updated[idx] = { ...updated[idx], [field]: value }; + setMd5Keys(updated); + }; + + const validate = (): string | null => { + if (!name) return "Please select an interface"; + if (authType === "md5") { + for (const key of md5Keys) { + if (!key.key_id) return "All MD5 keys must have a key ID"; + const id = parseInt(key.key_id, 10); + if (isNaN(id) || id < 1 || id > 255) return "MD5 key ID must be 1-255"; + if (!key.password) return "All MD5 keys must have a password"; + if (key.password.length > 16) return "MD5 key password must be ≤16 characters"; + } + } + if (authType === "plaintext") { + if (plaintextPassword.length > 16) return "Plaintext password must be ≤16 characters"; + } + return null; + }; + + const handleSubmit = async () => { + const validationError = validate(); + if (validationError) { + setError(validationError); + return; + } + + const config: RipInterface = { + name, + authentication_type: authType || null, + md5_keys: authType === "md5" ? md5Keys : [], + plaintext_password: authType === "plaintext" ? plaintextPassword || null : null, + send_version: sendVersion || null, + receive_version: receiveVersion || null, + split_horizon: splitHorizon || null, + }; + + try { + setLoading(true); + setError(null); + await onSubmit(config); + handleClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Operation failed"); + } finally { + setLoading(false); + } + }; + + return ( + + + + + {isEditMode ? "Edit RIP Interface" : "Add RIP Interface"} + + + {isEditMode + ? `Modify RIP settings for ${existingInterface?.name}.` + : "Configure per-interface RIP authentication, version, and split-horizon."} + + + + +
+ {/* Interface */} +
+ + +
+ + {/* Authentication */} +
+ + + + {authType === "md5" && ( +
+

MD5 key pairs (key ID 1-255, password ≤16 chars)

+ {md5Keys.map((key, idx) => ( +
+ updateMd5Key(idx, "key_id", e.target.value)} + className="w-24" + /> + updateMd5Key(idx, "password", e.target.value)} + className="flex-1" + /> + +
+ ))} + +
+ )} + + {authType === "plaintext" && ( +
+ setPlaintextPassword(e.target.value)} + /> +
+ )} +
+ + {/* Send / Receive Version */} +
+
+ + +
+
+ + +
+
+ + {/* Split Horizon */} +
+ + +
+
+
+ + {error && ( +
+ +
{error}
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/src/components/rip/RipNetworkDistanceModal.tsx b/frontend/src/components/rip/RipNetworkDistanceModal.tsx new file mode 100644 index 00000000..00e776fb --- /dev/null +++ b/frontend/src/components/rip/RipNetworkDistanceModal.tsx @@ -0,0 +1,191 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AlertCircle, Loader2 } from "lucide-react"; +import type { RipNetworkDistance } from "@/lib/api/rip"; + +interface RipNetworkDistanceModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (entry: RipNetworkDistance) => Promise; + existingEntry?: RipNetworkDistance | null; + existingPrefixes: string[]; + accessListNames: string[]; +} + +export function RipNetworkDistanceModal({ + open, + onOpenChange, + onSubmit, + existingEntry, + existingPrefixes, + accessListNames, +}: RipNetworkDistanceModalProps) { + const isEditMode = !!existingEntry; + + const [prefix, setPrefix] = useState(""); + const [distance, setDistance] = useState(""); + const [accessList, setAccessList] = useState(""); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + useEffect(() => { + if (!open) return; + if (existingEntry) { + setPrefix(existingEntry.prefix); + setDistance(existingEntry.distance != null ? String(existingEntry.distance) : ""); + setAccessList(existingEntry.access_list || ""); + } else { + setPrefix(""); + setDistance(""); + setAccessList(""); + setError(null); + } + }, [open, existingEntry]); + + const handleClose = () => { + setPrefix(""); + setDistance(""); + setAccessList(""); + setError(null); + onOpenChange(false); + }; + + const validate = (): string | null => { + if (!prefix.trim()) return "Network prefix is required"; + if (!isEditMode && existingPrefixes.includes(prefix.trim())) { + return "This prefix is already configured"; + } + if (!distance.trim()) return "Distance is required"; + const dist = parseInt(distance.trim(), 10); + if (isNaN(dist) || dist < 1 || dist > 255) return "Distance must be between 1 and 255"; + return null; + }; + + const handleSubmit = async () => { + const validationError = validate(); + if (validationError) { + setError(validationError); + return; + } + + const entry: RipNetworkDistance = { + prefix: prefix.trim(), + distance: parseInt(distance.trim(), 10), + access_list: accessList || null, + }; + + try { + setLoading(true); + setError(null); + await onSubmit(entry); + handleClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Operation failed"); + } finally { + setLoading(false); + } + }; + + return ( + + + + + {isEditMode ? "Edit Network Distance" : "Add Network Distance"} + + + Set administrative distance for routes from a specific network. + + + +
+ {/* Prefix */} +
+ + setPrefix(e.target.value)} + disabled={isEditMode} + className={isEditMode ? "bg-muted font-mono" : "font-mono"} + /> +
+ + {/* Distance */} +
+ + setDistance(e.target.value)} + /> +
+ + {/* Access List */} +
+ + +
+
+ + {error && ( +
+ +
{error}
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/src/components/rip/RipRedistributeModal.tsx b/frontend/src/components/rip/RipRedistributeModal.tsx new file mode 100644 index 00000000..a516a91b --- /dev/null +++ b/frontend/src/components/rip/RipRedistributeModal.tsx @@ -0,0 +1,200 @@ +"use client"; + +import { useState, useEffect } from "react"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogHeader, + DialogTitle, +} from "@/components/ui/dialog"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import { Label } from "@/components/ui/label"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; +import { AlertCircle, Loader2 } from "lucide-react"; +import type { RipRedistribute } from "@/lib/api/rip"; + +const ALL_PROTOCOLS = ["babel", "bgp", "connected", "isis", "kernel", "nhrp", "ospf", "static"]; + +interface RipRedistributeModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; + onSubmit: (entry: RipRedistribute) => Promise; + existingEntry?: RipRedistribute | null; + existingProtocols: string[]; + routeMapNames: string[]; +} + +export function RipRedistributeModal({ + open, + onOpenChange, + onSubmit, + existingEntry, + existingProtocols, + routeMapNames, +}: RipRedistributeModalProps) { + const isEditMode = !!existingEntry; + + const [protocol, setProtocol] = useState(""); + const [metric, setMetric] = useState(""); + const [routeMap, setRouteMap] = useState(""); + + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + + const availableProtocols = isEditMode + ? ALL_PROTOCOLS + : ALL_PROTOCOLS.filter((p) => !existingProtocols.includes(p)); + + useEffect(() => { + if (!open) return; + if (existingEntry) { + setProtocol(existingEntry.protocol); + setMetric(existingEntry.metric != null ? String(existingEntry.metric) : ""); + setRouteMap(existingEntry.route_map || ""); + } else { + setProtocol(""); + setMetric(""); + setRouteMap(""); + setError(null); + } + }, [open, existingEntry]); + + const handleClose = () => { + setProtocol(""); + setMetric(""); + setRouteMap(""); + setError(null); + onOpenChange(false); + }; + + const validate = (): string | null => { + if (!protocol) return "Please select a protocol"; + if (metric.trim()) { + const val = parseInt(metric.trim(), 10); + if (isNaN(val) || val < 1 || val > 16) return "Metric must be between 1 and 16"; + } + return null; + }; + + const handleSubmit = async () => { + const validationError = validate(); + if (validationError) { + setError(validationError); + return; + } + + const entry: RipRedistribute = { + protocol, + metric: metric.trim() ? parseInt(metric.trim(), 10) : null, + route_map: routeMap || null, + }; + + try { + setLoading(true); + setError(null); + await onSubmit(entry); + handleClose(); + } catch (err) { + setError(err instanceof Error ? err.message : "Operation failed"); + } finally { + setLoading(false); + } + }; + + return ( + + + + + {isEditMode ? "Edit Redistribution" : "Add Redistribution"} + + + {isEditMode + ? `Modify redistribution settings for ${existingEntry?.protocol}.` + : "Configure a new protocol to redistribute into RIP."} + + + +
+ {/* Protocol */} +
+ + +
+ + {/* Metric */} +
+ + setMetric(e.target.value)} + /> +
+ + {/* Route Map */} +
+ + +
+
+ + {error && ( +
+ +
{error}
+
+ )} + + + + + +
+
+ ); +} diff --git a/frontend/src/lib/api/rip.ts b/frontend/src/lib/api/rip.ts new file mode 100644 index 00000000..54690d72 --- /dev/null +++ b/frontend/src/lib/api/rip.ts @@ -0,0 +1,577 @@ +import { apiClient } from "./client"; + +// ============================================================================ +// TypeScript Interfaces +// ============================================================================ + +export interface RipDistributeListGlobal { + access_list_in?: string | null; + access_list_out?: string | null; + prefix_list_in?: string | null; + prefix_list_out?: string | null; +} + +export interface RipDistributeListInterface { + interface: string; + access_list_in?: string | null; + access_list_out?: string | null; + prefix_list_in?: string | null; + prefix_list_out?: string | null; +} + +export interface RipDistributeList { + global_filters: RipDistributeListGlobal; + interface_filters: RipDistributeListInterface[]; +} + +export interface RipMd5Key { + key_id: string; + password: string; +} + +export interface RipInterface { + name: string; + authentication_type?: string | null; + md5_keys: RipMd5Key[]; + plaintext_password?: string | null; + receive_version?: string | null; + send_version?: string | null; + split_horizon?: string | null; +} + +export interface RipNetworkDistance { + prefix: string; + distance?: number | null; + access_list?: string | null; +} + +export interface RipRedistribute { + protocol: string; + metric?: number | null; + route_map?: string | null; +} + +export interface RipTimers { + update?: number | null; + timeout?: number | null; + garbage_collection?: number | null; +} + +export interface RipConfig { + default_distance?: number | null; + default_information_originate: boolean; + default_metric?: number | null; + route_map?: string | null; + version?: string | null; + networks: string[]; + neighbors: string[]; + routes: string[]; + passive_interfaces: string[]; + distribute_list: RipDistributeList; + interfaces: RipInterface[]; + network_distances: RipNetworkDistance[]; + redistribute: RipRedistribute[]; + timers: RipTimers; +} + +export interface RipCapabilities { + version: string; + version_info: { + is_1_4: boolean; + is_1_5: boolean; + }; + features: { + global_settings: { supported: boolean; description: string }; + networks: { supported: boolean; description: string }; + neighbors: { supported: boolean; description: string }; + static_routes: { supported: boolean; description: string }; + passive_interfaces: { supported: boolean; description: string }; + distribute_lists: { supported: boolean; description: string }; + interface_settings: { supported: boolean; description: string }; + network_distance: { supported: boolean; description: string }; + redistribute: { supported: boolean; description: string; protocols: string[] }; + timers: { supported: boolean; description: string }; + }; + instance_name?: string; + instance_id?: string; +} + +export interface RipBatchOperation { + op: string; + value?: string; +} + +export interface RipBatchRequest { + operations: RipBatchOperation[]; +} + +export interface VyOSResponse { + success: boolean; + data?: Record | null; + error?: string | null; +} + +// ============================================================================ +// API Service +// ============================================================================ + +class RipService { + async getCapabilities(): Promise { + return apiClient.get("/vyos/rip/capabilities"); + } + + async getConfig(refresh = false): Promise { + return apiClient.get("/vyos/rip/config", { + refresh: refresh.toString(), + }); + } + + async refreshConfig(): Promise { + return apiClient.post("/vyos/config/refresh"); + } + + async batchConfigure(request: RipBatchRequest): Promise { + const result = await apiClient.post("/vyos/rip/batch", request); + if (!result.success) throw new Error(result.error || "Operation failed"); + await this.refreshConfig(); + return result; + } + + // ========================================================================== + // Global Settings + // ========================================================================== + + async updateGlobalSettings( + original: RipConfig, + updated: Partial + ): Promise { + const ops: RipBatchOperation[] = []; + + if (updated.version !== original.version) { + ops.push( + updated.version + ? { op: "set_version", value: updated.version } + : { op: "delete_version" } + ); + } + + if (updated.default_distance !== original.default_distance) { + ops.push( + updated.default_distance != null + ? { op: "set_default_distance", value: String(updated.default_distance) } + : { op: "delete_default_distance" } + ); + } + + if (updated.default_metric !== original.default_metric) { + ops.push( + updated.default_metric != null + ? { op: "set_default_metric", value: String(updated.default_metric) } + : { op: "delete_default_metric" } + ); + } + + if (updated.route_map !== original.route_map) { + ops.push( + updated.route_map + ? { op: "set_route_map", value: updated.route_map } + : { op: "delete_route_map" } + ); + } + + if ( + updated.default_information_originate !== undefined && + updated.default_information_originate !== original.default_information_originate + ) { + ops.push( + updated.default_information_originate + ? { op: "set_default_information_originate" } + : { op: "delete_default_information_originate" } + ); + } + + if (ops.length === 0) return { success: true, data: null }; + return this.batchConfigure({ operations: ops }); + } + + async updateTimers( + original: RipTimers, + updated: RipTimers + ): Promise { + const ops: RipBatchOperation[] = []; + + if (updated.update !== original.update) { + ops.push( + updated.update != null + ? { op: "set_timers_update", value: String(updated.update) } + : { op: "delete_timers_update" } + ); + } + + if (updated.timeout !== original.timeout) { + ops.push( + updated.timeout != null + ? { op: "set_timers_timeout", value: String(updated.timeout) } + : { op: "delete_timers_timeout" } + ); + } + + if (updated.garbage_collection !== original.garbage_collection) { + ops.push( + updated.garbage_collection != null + ? { op: "set_timers_garbage_collection", value: String(updated.garbage_collection) } + : { op: "delete_timers_garbage_collection" } + ); + } + + if (ops.length === 0) return { success: true, data: null }; + return this.batchConfigure({ operations: ops }); + } + + // ========================================================================== + // Networks / Neighbors / Routes / Passive Interfaces + // ========================================================================== + + async addNetwork(network: string): Promise { + return this.batchConfigure({ operations: [{ op: "set_network", value: network }] }); + } + + async removeNetwork(network: string): Promise { + return this.batchConfigure({ operations: [{ op: "delete_network", value: network }] }); + } + + async addNeighbor(address: string): Promise { + return this.batchConfigure({ operations: [{ op: "set_neighbor", value: address }] }); + } + + async removeNeighbor(address: string): Promise { + return this.batchConfigure({ operations: [{ op: "delete_neighbor", value: address }] }); + } + + async addRoute(prefix: string): Promise { + return this.batchConfigure({ operations: [{ op: "set_route", value: prefix }] }); + } + + async removeRoute(prefix: string): Promise { + return this.batchConfigure({ operations: [{ op: "delete_route", value: prefix }] }); + } + + async addPassiveInterface(iface: string): Promise { + return this.batchConfigure({ operations: [{ op: "set_passive_interface", value: iface }] }); + } + + async removePassiveInterface(iface: string): Promise { + return this.batchConfigure({ operations: [{ op: "delete_passive_interface", value: iface }] }); + } + + // ========================================================================== + // Interface Settings + // ========================================================================== + + async createInterface(config: RipInterface): Promise { + const ops: RipBatchOperation[] = [{ op: "set_interface", value: config.name }]; + this._buildInterfaceOps(ops, config); + return this.batchConfigure({ operations: ops }); + } + + async updateInterface( + original: RipInterface, + updated: RipInterface + ): Promise { + const ops: RipBatchOperation[] = []; + const name = original.name; + + // Authentication — delete old, set new + const origAuthType = original.authentication_type; + const newAuthType = updated.authentication_type; + + if (origAuthType !== newAuthType || JSON.stringify(original.md5_keys) !== JSON.stringify(updated.md5_keys) || original.plaintext_password !== updated.plaintext_password) { + if (origAuthType) { + ops.push({ op: "delete_interface_authentication", value: name }); + } + if (newAuthType === "md5") { + for (const key of updated.md5_keys) { + ops.push({ op: "set_interface_authentication_md5_key", value: `${name},${key.key_id},${key.password}` }); + } + } else if (newAuthType === "plaintext" && updated.plaintext_password) { + ops.push({ op: "set_interface_authentication_plaintext", value: `${name},${updated.plaintext_password}` }); + } + } + + if (updated.send_version !== original.send_version) { + ops.push( + updated.send_version + ? { op: "set_interface_send_version", value: `${name},${updated.send_version}` } + : { op: "delete_interface_send_version", value: name } + ); + } + + if (updated.receive_version !== original.receive_version) { + ops.push( + updated.receive_version + ? { op: "set_interface_receive_version", value: `${name},${updated.receive_version}` } + : { op: "delete_interface_receive_version", value: name } + ); + } + + if (updated.split_horizon !== original.split_horizon) { + if (original.split_horizon) { + ops.push({ op: "delete_interface_split_horizon", value: name }); + } + if (updated.split_horizon === "disable") { + ops.push({ op: "set_interface_split_horizon_disable", value: name }); + } else if (updated.split_horizon === "poison-reverse") { + ops.push({ op: "set_interface_split_horizon_poison_reverse", value: name }); + } + } + + if (ops.length === 0) return { success: true, data: null }; + return this.batchConfigure({ operations: ops }); + } + + async deleteInterface(name: string): Promise { + return this.batchConfigure({ operations: [{ op: "delete_interface", value: name }] }); + } + + private _buildInterfaceOps(ops: RipBatchOperation[], config: RipInterface): void { + const name = config.name; + if (config.authentication_type === "md5") { + for (const key of config.md5_keys) { + ops.push({ op: "set_interface_authentication_md5_key", value: `${name},${key.key_id},${key.password}` }); + } + } else if (config.authentication_type === "plaintext" && config.plaintext_password) { + ops.push({ op: "set_interface_authentication_plaintext", value: `${name},${config.plaintext_password}` }); + } + if (config.send_version) { + ops.push({ op: "set_interface_send_version", value: `${name},${config.send_version}` }); + } + if (config.receive_version) { + ops.push({ op: "set_interface_receive_version", value: `${name},${config.receive_version}` }); + } + if (config.split_horizon === "disable") { + ops.push({ op: "set_interface_split_horizon_disable", value: name }); + } else if (config.split_horizon === "poison-reverse") { + ops.push({ op: "set_interface_split_horizon_poison_reverse", value: name }); + } + } + + // ========================================================================== + // Redistribute + // ========================================================================== + + async createRedistribute(entry: RipRedistribute): Promise { + const ops: RipBatchOperation[] = [{ op: "set_redistribute", value: entry.protocol }]; + if (entry.metric != null) { + ops.push({ op: "set_redistribute_metric", value: `${entry.protocol},${entry.metric}` }); + } + if (entry.route_map) { + ops.push({ op: "set_redistribute_route_map", value: `${entry.protocol},${entry.route_map}` }); + } + return this.batchConfigure({ operations: ops }); + } + + async updateRedistribute( + original: RipRedistribute, + updated: RipRedistribute + ): Promise { + const ops: RipBatchOperation[] = []; + const proto = original.protocol; + + if (updated.metric !== original.metric) { + ops.push( + updated.metric != null + ? { op: "set_redistribute_metric", value: `${proto},${updated.metric}` } + : { op: "set_redistribute", value: proto } + ); + } + + if (updated.route_map !== original.route_map) { + if (updated.route_map) { + ops.push({ op: "set_redistribute_route_map", value: `${proto},${updated.route_map}` }); + } else { + ops.push({ op: "delete_redistribute", value: proto }); + ops.push({ op: "set_redistribute", value: proto }); + if (updated.metric != null) { + ops.push({ op: "set_redistribute_metric", value: `${proto},${updated.metric}` }); + } + } + } + + if (ops.length === 0) return { success: true, data: null }; + return this.batchConfigure({ operations: ops }); + } + + async deleteRedistribute(protocol: string): Promise { + return this.batchConfigure({ operations: [{ op: "delete_redistribute", value: protocol }] }); + } + + // ========================================================================== + // Network Distance + // ========================================================================== + + async createNetworkDistance(entry: RipNetworkDistance): Promise { + const ops: RipBatchOperation[] = [{ op: "set_network_distance", value: entry.prefix }]; + if (entry.distance != null) { + ops.push({ op: "set_network_distance_value", value: `${entry.prefix},${entry.distance}` }); + } + if (entry.access_list) { + ops.push({ op: "set_network_distance_access_list", value: `${entry.prefix},${entry.access_list}` }); + } + return this.batchConfigure({ operations: ops }); + } + + async updateNetworkDistance( + original: RipNetworkDistance, + updated: RipNetworkDistance + ): Promise { + const ops: RipBatchOperation[] = []; + const prefix = original.prefix; + + if (updated.distance !== original.distance) { + if (updated.distance != null) { + ops.push({ op: "set_network_distance_value", value: `${prefix},${updated.distance}` }); + } + } + + if (updated.access_list !== original.access_list) { + if (updated.access_list) { + ops.push({ op: "set_network_distance_access_list", value: `${prefix},${updated.access_list}` }); + } else { + ops.push({ op: "delete_network_distance", value: prefix }); + ops.push({ op: "set_network_distance", value: prefix }); + if (updated.distance != null) { + ops.push({ op: "set_network_distance_value", value: `${prefix},${updated.distance}` }); + } + } + } + + if (ops.length === 0) return { success: true, data: null }; + return this.batchConfigure({ operations: ops }); + } + + async deleteNetworkDistance(prefix: string): Promise { + return this.batchConfigure({ operations: [{ op: "delete_network_distance", value: prefix }] }); + } + + // ========================================================================== + // Distribute List - Global + // ========================================================================== + + async updateDistributeListGlobal( + original: RipDistributeListGlobal, + updated: RipDistributeListGlobal + ): Promise { + const ops: RipBatchOperation[] = []; + + if (updated.access_list_in !== original.access_list_in) { + ops.push( + updated.access_list_in + ? { op: "set_distribute_list_access_list_in", value: updated.access_list_in } + : { op: "delete_distribute_list_access_list_in" } + ); + } + + if (updated.access_list_out !== original.access_list_out) { + ops.push( + updated.access_list_out + ? { op: "set_distribute_list_access_list_out", value: updated.access_list_out } + : { op: "delete_distribute_list_access_list_out" } + ); + } + + if (updated.prefix_list_in !== original.prefix_list_in) { + ops.push( + updated.prefix_list_in + ? { op: "set_distribute_list_prefix_list_in", value: updated.prefix_list_in } + : { op: "delete_distribute_list_prefix_list_in" } + ); + } + + if (updated.prefix_list_out !== original.prefix_list_out) { + ops.push( + updated.prefix_list_out + ? { op: "set_distribute_list_prefix_list_out", value: updated.prefix_list_out } + : { op: "delete_distribute_list_prefix_list_out" } + ); + } + + if (ops.length === 0) return { success: true, data: null }; + return this.batchConfigure({ operations: ops }); + } + + // ========================================================================== + // Distribute List - Per Interface + // ========================================================================== + + async createDistributeListInterface( + entry: RipDistributeListInterface + ): Promise { + const ops: RipBatchOperation[] = []; + const iface = entry.interface; + if (entry.access_list_in) { + ops.push({ op: "set_distribute_list_interface_access_list_in", value: `${iface},${entry.access_list_in}` }); + } + if (entry.access_list_out) { + ops.push({ op: "set_distribute_list_interface_access_list_out", value: `${iface},${entry.access_list_out}` }); + } + if (entry.prefix_list_in) { + ops.push({ op: "set_distribute_list_interface_prefix_list_in", value: `${iface},${entry.prefix_list_in}` }); + } + if (entry.prefix_list_out) { + ops.push({ op: "set_distribute_list_interface_prefix_list_out", value: `${iface},${entry.prefix_list_out}` }); + } + if (ops.length === 0) return { success: true, data: null }; + return this.batchConfigure({ operations: ops }); + } + + async updateDistributeListInterface( + original: RipDistributeListInterface, + updated: RipDistributeListInterface + ): Promise { + const ops: RipBatchOperation[] = []; + const iface = original.interface; + + if (updated.access_list_in !== original.access_list_in) { + ops.push( + updated.access_list_in + ? { op: "set_distribute_list_interface_access_list_in", value: `${iface},${updated.access_list_in}` } + : { op: "delete_distribute_list_interface_access_list_in", value: iface } + ); + } + + if (updated.access_list_out !== original.access_list_out) { + ops.push( + updated.access_list_out + ? { op: "set_distribute_list_interface_access_list_out", value: `${iface},${updated.access_list_out}` } + : { op: "delete_distribute_list_interface_access_list_out", value: iface } + ); + } + + if (updated.prefix_list_in !== original.prefix_list_in) { + ops.push( + updated.prefix_list_in + ? { op: "set_distribute_list_interface_prefix_list_in", value: `${iface},${updated.prefix_list_in}` } + : { op: "delete_distribute_list_interface_prefix_list_in", value: iface } + ); + } + + if (updated.prefix_list_out !== original.prefix_list_out) { + ops.push( + updated.prefix_list_out + ? { op: "set_distribute_list_interface_prefix_list_out", value: `${iface},${updated.prefix_list_out}` } + : { op: "delete_distribute_list_interface_prefix_list_out", value: iface } + ); + } + + if (ops.length === 0) return { success: true, data: null }; + return this.batchConfigure({ operations: ops }); + } + + async deleteDistributeListInterface(iface: string): Promise { + return this.batchConfigure({ + operations: [{ op: "delete_distribute_list_interface", value: iface }], + }); + } +} + +export const ripService = new RipService();