diff --git a/.gitignore b/.gitignore index 82f9275..a437fda 100644 --- a/.gitignore +++ b/.gitignore @@ -60,6 +60,7 @@ cover/ local_settings.py db.sqlite3 db.sqlite3-journal +*.db # Flask stuff: instance/ diff --git a/README.md b/README.md new file mode 100644 index 0000000..81d6336 --- /dev/null +++ b/README.md @@ -0,0 +1,266 @@ +# Route Manager API + +A REST API developed with FastAPI for managing network routes on a Linux machine using the `ip` command. It allows you to query active routes, create new routes, and delete existing routes, with token-based authentication and persistence of scheduled routes to ensure their availability even after service restarts. + +## Table of Contents + +- [Features](#features) +- [Prerequisites](#prerequisites) +- [Installation](#installation) + - [1. Clone the Repository](#1-clone-the-repository) + - [2. Create and Activate a Virtual Environment](#2-create-and-activate-a-virtual-environment) + - [3. Install Dependencies](#3-install-dependencies) + - [4. Configure the Database](#4-configure-the-database) + - [5. Configure the systemd Service](#5-configure-the-systemd-service) +- [Usage](#usage) + - [Available Endpoints](#available-endpoints) + - [Usage Examples with `curl`](#usage-examples-with-curl) +- [API Documentation](#api-documentation) +- [Logging](#logging) +- [Security Considerations](#security-considerations) +- [Contributing](#contributing) +- [License](#license) + +## Features + +- **Query Active Routes:** Retrieve all active network routes on the Linux machine. +- **Create Routes:** Add new routes with options to schedule their creation and deletion. +- **Delete Routes:** Remove existing routes and unschedule their deletion. +- **Authentication:** Protect endpoints using a Bearer token for authentication. +- **Persistence:** Store scheduled routes in a SQLite database to ensure they are reloaded after service restarts. +- **systemd Service:** Integrate with systemd to run the API as a system service. +- **Logging:** Detailed logging of operations and errors for monitoring and debugging. +- **Testing:** Automated tests using `pytest` to validate key functionalities. +- **OpenAPI Documentation:** An `openapi.yaml` file describing the API specifications. + +## Prerequisites + +- **Operating System:** Linux +- **Python:** Version 3.7 or higher +- **Permissions:** Superuser permissions to manage network routes and configure systemd services. + +## Installation + +### 1. Clone the Repository + +Clone this repository to your local machine: + +```bash +git https://github.com/6G-SANDBOX/route-manager-api +cd route-manager-api +``` + +### 2. Create and Activate a Virtual Environment + +It's recommended to use a virtual environment to manage the project's dependencies. + +```bash +python3 -m venv routemgr +source routemgr/bin/activate +``` + +### 3. Install Dependencies + +Install all necessary dependencies using the `requirements.txt` file: + +```bash +pip install -r requirements.txt +``` + +**Contents of `requirements.txt`:** + +```plaintext +fastapi==0.112.2 +uvicorn==0.30.6 +pydantic==2.8.2 +apscheduler==3.10.4 +SQLAlchemy==2.0.32 +``` + +### 4. Configure the Database + +The project uses SQLite to persist scheduled routes. The database is created automatically when you start the application. No additional configuration is required. + +### 5. Configure the systemd Service + +To run the API as a system service, create a systemd unit file. + +#### 5.1. Create the Unit File + +Create a file named `route-manager.service` in `/etc/systemd/system/`: + +```bash +sudo nano /etc/systemd/system/route-manager.service +``` + +#### 5.2. Add Content to the File + +Replace `/path/to/your/app` with the directory where your `main.py` file is located: + +```ini +[Unit] +Description=Route Manager Service +After=network.target + +[Service] +Type=simple +User=root +Group=root +WorkingDirectory=/path/to/your/app +ExecStart=/usr/bin/python3 main.py +Restart=on-failure +RestartSec=10s +Environment=PYTHONUNBUFFERED=1 + +[Install] +WantedBy=multi-user.target +``` + +**Note:** Ensure that the user and group (`root` in this case) have the appropriate permissions for the application directory. + +#### 5.3. Reload systemd and Start the Service + +```bash +sudo systemctl daemon-reload +sudo systemctl enable route-manager.service +sudo systemctl start route-manager.service +``` + +#### 5.4. Verify the Service Status + +```bash +sudo systemctl status route-manager.service +``` + +You should see that the service is active and running. If there are any errors, they will appear in the output of this command. + +## Usage + +### Available Endpoints + +The API offers the following endpoints: + +- **GET /routes:** Retrieve all active routes. +- **POST /routes:** Schedule the creation of a new route. +- **DELETE /routes:** Delete an existing route and remove its schedule. + +### Usage Examples with `curl` + +#### Retrieve Active Routes + +```bash +curl -X GET "http://localhost:8172/routes" \ +-H "Authorization: Bearer this_is_something_secret" \ +-H "Accept: application/json" +``` + +**Successful Response:** + +```json +{ + "routes": [ + "default via 192.168.1.1 dev eth0", + "192.168.1.0/24 dev eth0 proto kernel scope link src 192.168.1.100" + ] +} +``` + +#### Add a Scheduled Route + +```bash +curl -X POST "http://localhost:8172/routes" \ +-H "Authorization: Bearer this_is_something_secret" \ +-H "Content-Type: application/json" \ +-d '{ + "destination": "192.168.20.0/24", + "gateway": "192.168.2.1", + "interface": "eth0", + "create_at": "2024-10-10T12:00:00", + "delete_at": "2024-10-10T18:00:00" +}' +``` + +**Successful Response:** + +```json +{ + "message": "Route scheduled successfully" +} +``` + +#### Delete a Route + +```bash +curl -X DELETE "http://localhost:8172/routes" \ +-H "Authorization: Bearer this_is_something_secret" \ +-H "Content-Type: application/json" \ +-d '{ + "destination": "192.168.2.0/24", + "gateway": "192.168.2.1", + "interface": "eth0" +}' +``` + +**Successful Response:** + +```json +{ + "message": "Route deleted and removed from schedule" +} +``` + +**Response When Route Not Found:** + +```json +{ + "detail": "Route not found" +} +``` + +## API Documentation + +FastAPI automatically generates interactive API documentation accessible at: + +- **Swagger UI:** [http://localhost:8172/docs](http://localhost:8172/docs) +- **Redoc:** [http://localhost:8172/redoc](http://localhost:8172/redoc) + +Additionally, an `openapi.yaml` file is provided that describes the OpenAPI specification of the API. + +### **Using Swagger UI** + +Open your browser and visit [http://localhost:8000/8172](http://localhost:8172/docs) to interact with the API through the Swagger UI interface. + +## Logging + +The application logs detailed information about its operations and errors using Python's standard `logging` module. Logs include: + +- Executed commands and their outputs. +- Route existence checks. +- Route creation and deletion operations. +- Database interactions. +- Authentication events. + +### **Log File Location** + +By default, logs are displayed in the console where the service is running. To redirect logs to a file, modify the logging configuration in `main.py`: + +```python +logging.basicConfig( + filename='app.log', + level=logging.INFO, + format="%(asctime)s - %(levelname)s - %(message)s" +) +``` + +## Security Considerations + +- **Authentication Token:** The API uses a static token for Bearer authentication. Ensure you protect this token and change it to a secure value before deploying to production. +- **Input Validation:** While `pydantic` is used for data validation, consider implementing additional security measures as needed for your environment. + +## License + +This project is licensed under the MIT License. See the [LICENSE](LICENSE) file for more details. + +--- + +**Note:** This project is designed for Linux environments with appropriate permissions to manage network routes. Ensure you understand the commands and configurations used before deploying to a production environment. \ No newline at end of file diff --git a/app/__init__.py b/app/__init__.py new file mode 100644 index 0000000..ee1be86 --- /dev/null +++ b/app/__init__.py @@ -0,0 +1,8 @@ +# app/__init__.py +from fastapi import FastAPI +from app.routers import router + +app = FastAPI() +app.include_router(router) + + diff --git a/app/auth.py b/app/auth.py new file mode 100644 index 0000000..16959c9 --- /dev/null +++ b/app/auth.py @@ -0,0 +1,26 @@ +# app/auth.py +from fastapi import HTTPException, Request +from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials +import logging +from app.config import get_variable + +logger = logging.getLogger(__name__) + +TOKEN = get_variable('TOKEN') + +class TokenAuth(HTTPBearer): + def __init__(self, auto_error: bool = True): + super(TokenAuth, self).__init__(auto_error=auto_error) + + async def __call__(self, request: Request): + credentials: HTTPAuthorizationCredentials = await super(TokenAuth, self).__call__(request) + if credentials: + if credentials.credentials != TOKEN: + logger.warning("Invalid or expired token") + raise HTTPException(status_code=403, detail="Invalid or expired token") + return credentials.credentials + else: + logger.warning("Missing token") + raise HTTPException(status_code=403, detail="Invalid or expired token") + +auth = TokenAuth() diff --git a/app/config.py b/app/config.py new file mode 100644 index 0000000..318fe5e --- /dev/null +++ b/app/config.py @@ -0,0 +1,19 @@ +# app/config.py +import os +import configparser +from pathlib import Path + + +def get_variable(key, config_file=Path(__file__).parent.parent / 'config/config.conf', section='config'): + # Check if the variable exists as an environment variable + value = os.getenv(key) + + if value is None: + # If the environment variable doesn't exist, read from the config file + config = configparser.ConfigParser() + config.read(config_file) + + # Get the value from the configuration file + value = config.get(section, key, fallback=None) + + return value \ No newline at end of file diff --git a/app/database.py b/app/database.py new file mode 100644 index 0000000..f47d8ec --- /dev/null +++ b/app/database.py @@ -0,0 +1,10 @@ +# app/database.py +from sqlalchemy import create_engine +from sqlalchemy.orm import sessionmaker, declarative_base +from app.config import get_variable + +DATABASE_URL = "sqlite:///./" + get_variable('DATABASE_URL') + +engine = create_engine(DATABASE_URL, connect_args={"check_same_thread": False} if "sqlite" in DATABASE_URL else {}) +SessionLocal = sessionmaker(autocommit=False, autoflush=False, bind=engine) +Base = declarative_base() diff --git a/app/models.py b/app/models.py new file mode 100644 index 0000000..39058af --- /dev/null +++ b/app/models.py @@ -0,0 +1,12 @@ +# app/models.py +from sqlalchemy import Column, String, DateTime +from app.database import Base + +class RouteModel(Base): + __tablename__ = "routes" + id = Column(String, primary_key=True, index=True) + destination = Column(String, index=True) + gateway = Column(String, index=True) + interface = Column(String, index=True) + create_at = Column(DateTime, index=True, nullable=True) + delete_at = Column(DateTime, index=True, nullable=True) diff --git a/app/routers/__init__.py b/app/routers/__init__.py new file mode 100644 index 0000000..db74d6a --- /dev/null +++ b/app/routers/__init__.py @@ -0,0 +1,2 @@ +# app/routers/__init__.py +from .routes import router \ No newline at end of file diff --git a/app/routers/routes.py b/app/routers/routes.py new file mode 100644 index 0000000..0444c88 --- /dev/null +++ b/app/routers/routes.py @@ -0,0 +1,115 @@ +# app/routers/routes.py +from fastapi import APIRouter, Depends, HTTPException +from datetime import datetime +from app.schemas import Route +from app.models import RouteModel +from app.database import SessionLocal +from app.auth import auth +from app.utils import run_command, route_exists +from app.scheduler import add_job +import logging + +logger = logging.getLogger(__name__) + +router = APIRouter() + +@router.get("/routes", dependencies=[Depends(auth)]) +def get_routes(): + logger.info("Fetching active routes") + command = "ip route show" + output = run_command(command) + return {"routes": output.splitlines()} + +@router.post("/routes", dependencies=[Depends(auth)]) +def schedule_route(route: Route): + db = SessionLocal() + try: + now = datetime.now() + logger.info(f"Scheduling route: {route}") + + if route_exists(route.destination, route.gateway, route.interface): + logger.warning(f"Route already exists: {route}") + raise HTTPException(status_code=400, detail="Route already exists") + + if route.create_at and (route.create_at > now): + add_job(add_route, 'date', run_date=route.create_at, args=[route]) + else: + add_route(route) + + if route.delete_at and (route.delete_at > now): + add_job(delete_route, 'date', run_date=route.delete_at, args=[route]) + + # Guardar en la base de datos + db_route = RouteModel( + id=f"{route.destination}_{route.gateway}_{route.interface}", + destination=route.destination, + gateway=route.gateway, + interface=route.interface, + create_at=route.create_at, + delete_at=route.delete_at, + ) + db.add(db_route) + db.commit() + db.refresh(db_route) + + return {"message": "Route scheduled successfully"} + except Exception as e: + logger.error(f"Error scheduling route: {e}") + raise HTTPException(status_code=500, detail="Internal Server Error") + finally: + db.close() + +@router.delete("/routes", dependencies=[Depends(auth)]) +def remove_scheduled_route(route: Route): + db = SessionLocal() + try: + logger.info(f"Removing scheduled route: {route}") + + if not route_exists(route.destination, route.gateway, route.interface): + logger.warning(f"Route not found: {route}") + raise HTTPException(status_code=404, detail="Route not found") + + delete_route(route) + + # Remover de la base de datos + db_route = db.query(RouteModel).filter( + RouteModel.destination == route.destination, + RouteModel.gateway == route.gateway, + RouteModel.interface == route.interface + ).first() + if db_route: + db.delete(db_route) + db.commit() + else: + logger.warning(f"Route not found in database: {route}") + + return {"message": "Route deleted and removed from schedule"} + except Exception as e: + logger.error(f"Error removing route: {e}") + raise HTTPException(status_code=500, detail="Internal Server Error") + finally: + db.close() + +def add_route(route: Route): + from app.utils import run_command # Evitar importaciones circulares + logger = logging.getLogger(__name__) + logger.info(f"Adding route: {route}") + command = f"ip route add {route.destination}" + if route.gateway: + command += f" via {route.gateway}" + if route.interface: + command += f" dev {route.interface}" + run_command(command) + logger.info("Route added successfully") + +def delete_route(route: Route): + from app.utils import run_command # Evitar importaciones circulares + logger = logging.getLogger(__name__) + logger.info(f"Deleting route: {route}") + command = f"ip route del {route.destination}" + if route.gateway: + command += f" via {route.gateway}" + if route.interface: + command += f" dev {route.interface}" + run_command(command) + logger.info("Route deleted successfully") diff --git a/app/scheduler.py b/app/scheduler.py new file mode 100644 index 0000000..a437ee9 --- /dev/null +++ b/app/scheduler.py @@ -0,0 +1,12 @@ +# app/scheduler.py +from apscheduler.schedulers.background import BackgroundScheduler +import logging + +logger = logging.getLogger(__name__) + +scheduler = BackgroundScheduler() +scheduler.start() + +def add_job(job_func, trigger, run_date=None, args=None): + logger.info(f"Scheduling job {job_func.__name__} with trigger {trigger} at {run_date}") + scheduler.add_job(job_func, trigger, run_date=run_date, args=args) diff --git a/app/schemas.py b/app/schemas.py new file mode 100644 index 0000000..13bb751 --- /dev/null +++ b/app/schemas.py @@ -0,0 +1,14 @@ +# app/schemas.py +from pydantic import BaseModel +from datetime import datetime +from typing import Optional + +class Route(BaseModel): + destination: str + gateway: Optional[str] = None + interface: Optional[str] = None + create_at: Optional[datetime] = None + delete_at: Optional[datetime] = None + + class Config: + from_attributes = True diff --git a/app/utils.py b/app/utils.py new file mode 100644 index 0000000..ddf61fe --- /dev/null +++ b/app/utils.py @@ -0,0 +1,29 @@ +# app/utils.py +import subprocess +from fastapi import HTTPException +import logging + +logger = logging.getLogger(__name__) + +def run_command(command: str) -> str: + logger.info(f"Executing command: {command}") + try: + result = subprocess.run(command, shell=True, check=True, stdout=subprocess.PIPE, stderr=subprocess.PIPE) + output = result.stdout.decode('utf-8') + logger.info(f"Command output: {output}") + return output + except subprocess.CalledProcessError as e: + logger.error(f"Command failed: {e.stderr.decode('utf-8')}") + raise HTTPException(status_code=500, detail=f"Command failed: {e.stderr.decode('utf-8')}") + +def route_exists(destination: str, gateway: str = None, interface: str = None) -> bool: + command = "ip route show" + output = run_command(command) + search_str = f"{destination}" + if gateway: + search_str += f" via {gateway}" + if interface: + search_str += f" dev {interface}" + exists = search_str in output + logger.info(f"Route exists check: {'Yes' if exists else 'No'} for {search_str}") + return exists diff --git a/config/config.conf b/config/config.conf new file mode 100644 index 0000000..ce64694 --- /dev/null +++ b/config/config.conf @@ -0,0 +1,5 @@ +# config.conf +[config] +DATABASE_URL = routes.db +TOKEN = this_is_something_secret +PORT = 8172 diff --git a/main.py b/main.py new file mode 100644 index 0000000..14c59eb --- /dev/null +++ b/main.py @@ -0,0 +1,58 @@ +# app/main.py +import uvicorn +import logging +from app.database import engine, Base +from app.routers import router +from app.scheduler import scheduler +from app.models import RouteModel +from app.utils import run_command +from app.schemas import Route +from datetime import datetime +from app.config import get_variable + + +# Configure logging +logging.basicConfig(level=logging.INFO, format="%(asctime)s - %(levelname)s - %(message)s") +logger = logging.getLogger(__name__) + +# Create DB tables +Base.metadata.create_all(bind=engine) + +def load_scheduled_routes(): + from app.database import SessionLocal + from app.schemas import Route + from app.models import RouteModel + from app.routers.routes import add_route, delete_route + logger.info("Loading scheduled routes from database") + db = SessionLocal() + try: + routes = db.query(RouteModel).all() + for db_route in routes: + route = Route( + destination=db_route.destination, + gateway=db_route.gateway, + interface=db_route.interface, + create_at=db_route.create_at, + delete_at=db_route.delete_at + ) + now = datetime.now() + if route.create_at and route.create_at > now: + scheduler.add_job(add_route, 'date', run_date=route.create_at, args=[route]) + else: + add_route(route) + + if route.delete_at and route.delete_at > now: + scheduler.add_job(delete_route, 'date', run_date=route.delete_at, args=[route]) + + logger.info(f"Route loaded and scheduled: {route}") + except Exception as e: + logger.error(f"Error loading routes from database: {e}") + finally: + db.close() + +if __name__ == "__main__": + logger.info("Starting Route Manager service - loading routes") + load_scheduled_routes() + PORT = int(get_variable('PORT')) + + uvicorn.run("app:app", host="0.0.0.0", port=PORT, reload=True) diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..be03fed --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi==0.112.2 +uvicorn==0.30.6 +pydantic==2.8.2 +apscheduler==3.10.4 +SQLAlchemy==2.0.32