diff --git a/agent/app/config.py b/agent/app/config.py new file mode 100644 index 0000000..c920b23 --- /dev/null +++ b/agent/app/config.py @@ -0,0 +1,15 @@ +from pydantic import BaseModel + + +class AgentConfig(BaseModel): + service_name: str = 'netmapper-agent' + version: str = '0.1.0' + monitored_services: list[str] = [ + 'netmapper-agent.service', + 'netmapper-backend.service', + 'netmapper-worker.service', + 'netmapper-frontend.service', + ] + + +settings = AgentConfig() diff --git a/agent/app/main.py b/agent/app/main.py index 52605de..c5e0544 100644 --- a/agent/app/main.py +++ b/agent/app/main.py @@ -1,112 +1,20 @@ -import os -import systemd.dbus -import dbus -from fastapi import FastAPI, HTTPException +from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware -from pydantic import BaseModel -from typing import List -app = FastAPI(title="NetMapper Agent", version="0.1.0") +from app.routers.health import router as health_router +from app.routers.runtime import router as runtime_router +from app.routers.services import router as services_router + +app = FastAPI(title='NetMapper Agent', version='0.1.0') app.add_middleware( CORSMiddleware, - allow_origins=["*"], # da limitare in produzione + allow_origins=['*'], allow_credentials=True, - allow_methods=["*"], - allow_headers=["*"], + allow_methods=['*'], + allow_headers=['*'], ) -ALLOWED_SERVICES = [ - "netmapper-frontend.service", - "netmapper-backend.service", - "netmapper-worker.service", -] - -class ServiceInfo(BaseModel): - name: str - active_state: str - sub_state: str - loaded: str - pid: int | None = None - -class ActionRequest(BaseModel): - mode: str = "sync" - -def get_unit(name: str): - bus = dbus.SystemBus() - systemd = bus.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1') - manager = dbus.Interface(systemd, 'org.freedesktop.systemd1.Manager') - unit_path = manager.GetUnit(name) - unit = bus.get_object('org.freedesktop.systemd1', str(unit_path)) - props = dbus.Interface(unit, 'org.freedesktop.DBus.Properties') - return props.GetAll('org.freedesktop.systemd1.Unit') - -def refresh_dbus(): - bus = dbus.SystemBus() - systemd = bus.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1') - manager = dbus.Interface(systemd, 'org.freedesktop.systemd1.Manager') - return manager - -@app.get("/health") -def health(): - return {"status": "ok"} - -@app.get("/services", response_model=List[ServiceInfo]) -def list_services(): - bus = dbus.SystemBus() - systemd = bus.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1') - manager = dbus.Interface(systemd, 'org.freedesktop.systemd1.Manager') - units = manager.ListUnits() - result = [] - for u in units: - name = u[0] - if name not in ALLOWED_SERVICES: - continue - result.append(ServiceInfo( - name=name, - active_state=u[3], - sub_state=u[4], - loaded=u[2], - pid=None, - )) - return result - -@app.get("/services/{name}") -def get_service(name: str): - if name not in ALLOWED_SERVICES: - raise HTTPException(status_code=403, detail="Service not allowed") - props = get_unit(name) - pid = None - if "MainPID" in props: - pid = int(props["MainPID"]) or None - return ServiceInfo( - name=name, - active_state=str(props.get("ActiveState", "unknown")), - sub_state=str(props.get("SubState", "unknown")), - loaded=str(props.get("LoadState", "unknown")), - pid=pid, - ) - -@app.post("/services/{name}/start") -def start_service(name: str): - if name not in ALLOWED_SERVICES: - raise HTTPException(status_code=403, detail="Service not allowed") - manager = refresh_dbus() - job = manager.StartUnit(name, "replace") - return {"job": str(job), "service": name, "action": "start"} - -@app.post("/services/{name}/stop") -def stop_service(name: str): - if name not in ALLOWED_SERVICES: - raise HTTPException(status_code=403, detail="Service not allowed") - manager = refresh_dbus() - job = manager.StopUnit(name, "replace") - return {"job": str(job), "service": name, "action": "stop"} - -@app.post("/services/{name}/restart") -def restart_service(name: str): - if name not in ALLOWED_SERVICES: - raise HTTPException(status_code=403, detail="Service not allowed") - manager = refresh_dbus() - job = manager.RestartUnit(name, "replace") - return {"job": str(job), "service": name, "action": "restart"} +app.include_router(health_router) +app.include_router(runtime_router) +app.include_router(services_router) diff --git a/agent/app/models.py b/agent/app/models.py new file mode 100644 index 0000000..a38bd4d --- /dev/null +++ b/agent/app/models.py @@ -0,0 +1,62 @@ +from datetime import datetime +from typing import Dict, List, Literal, Optional +from pydantic import BaseModel, Field + +HealthStatus = Literal['pass', 'warn', 'fail'] +ActionStatus = Literal['accepted', 'completed', 'failed'] +ServiceActiveState = Literal['active', 'reloading', 'inactive', 'failed', 'activating', 'deactivating'] + + +class CheckResult(BaseModel): + status: HealthStatus + latency_ms: Optional[float] = None + detail: Optional[str] = None + + +class HealthResponse(BaseModel): + status: HealthStatus + service: str + version: Optional[str] = None + time: datetime + uptime_s: Optional[float] = None + checks: Dict[str, CheckResult] = Field(default_factory=dict) + + +class ServiceState(BaseModel): + name: str + description: Optional[str] = None + active_state: ServiceActiveState + sub_state: str + loaded: str + status: HealthStatus + pid: Optional[int] = None + since: Optional[datetime] = None + health_url: Optional[str] = None + reachable: Optional[bool] = None + notes: Optional[str] = None + + +class ServiceListResponse(BaseModel): + count: int + time: datetime + items: List[ServiceState] + + +class RuntimeResponse(BaseModel): + status: HealthStatus + service: str + version: Optional[str] = None + time: datetime + hostname: str + uptime_s: Optional[float] = None + services: List[ServiceState] + checks: Dict[str, CheckResult] = Field(default_factory=dict) + + +class ServiceActionResponse(BaseModel): + status: ActionStatus + action: Literal['start', 'stop', 'restart'] + service: str + job_id: Optional[str] = None + message: Optional[str] = None + time: datetime diff --git a/agent/app/routers/health.py b/agent/app/routers/health.py new file mode 100644 index 0000000..54cafdc --- /dev/null +++ b/agent/app/routers/health.py @@ -0,0 +1,21 @@ +from fastapi import APIRouter + +from app.models import HealthResponse +from app.services.runtime_probe import build_health + +router = APIRouter(tags=['health']) + + +@router.get('/live', response_model=HealthResponse) +def live() -> HealthResponse: + return build_health() + + +@router.get('/ready', response_model=HealthResponse) +def ready() -> HealthResponse: + return build_health() + + +@router.get('/health', response_model=HealthResponse) +def health() -> HealthResponse: + return build_health() diff --git a/agent/app/routers/runtime.py b/agent/app/routers/runtime.py new file mode 100644 index 0000000..a95ba1c --- /dev/null +++ b/agent/app/routers/runtime.py @@ -0,0 +1,13 @@ +from fastapi import APIRouter + +from app.models import RuntimeResponse +from app.services.runtime_probe import build_runtime +from app.services.systemd_manager import SystemdManager + +router = APIRouter(tags=['runtime']) +manager = SystemdManager() + + +@router.get('/runtime', response_model=RuntimeResponse) +def runtime() -> RuntimeResponse: + return build_runtime(manager) diff --git a/agent/app/routers/services.py b/agent/app/routers/services.py new file mode 100644 index 0000000..165ec3c --- /dev/null +++ b/agent/app/routers/services.py @@ -0,0 +1,42 @@ +from datetime import datetime, timezone +from typing import Literal + +from fastapi import APIRouter, HTTPException + +from app.config import settings +from app.models import ServiceActionResponse, ServiceListResponse, ServiceState +from app.services.systemd_manager import SystemdManager + +router = APIRouter(prefix='/services', tags=['services']) +manager = SystemdManager() + + +@router.get('', response_model=ServiceListResponse) +def list_services() -> ServiceListResponse: + items = manager.list_services(settings.monitored_services) + return ServiceListResponse(count=len(items), time=datetime.now(timezone.utc), items=items) + + +@router.get('/{name}', response_model=ServiceState) +def get_service(name: str) -> ServiceState: + items = manager.list_services([name]) + if not items: + raise HTTPException(status_code=404, detail='Service not found') + return items[0] + + +@router.post('/{name}/{action}', response_model=ServiceActionResponse) +def service_action(name: str, action: Literal['start', 'stop', 'restart']) -> ServiceActionResponse: + if action == 'start': + manager.start(name) + elif action == 'stop': + manager.stop(name) + elif action == 'restart': + manager.restart(name) + return ServiceActionResponse( + status='accepted', + action=action, + service=name, + time=datetime.now(timezone.utc), + message=f'{action} requested', + ) diff --git a/agent/app/services/runtime_probe.py b/agent/app/services/runtime_probe.py new file mode 100644 index 0000000..9c0a75a --- /dev/null +++ b/agent/app/services/runtime_probe.py @@ -0,0 +1,59 @@ +import socket +import time +from datetime import datetime, timezone +from urllib.request import urlopen + +from app.config import settings +from app.models import CheckResult, HealthResponse, RuntimeResponse +from app.services.systemd_manager import SystemdManager + +START_TIME = time.time() +HEALTH_TARGETS = { + 'backend': 'http://127.0.0.1:8000/health', + 'worker': None, + 'frontend': 'http://127.0.0.1:5173/', +} + + +def probe_http(url: str | None) -> CheckResult: + if not url: + return CheckResult(status='warn', detail='no health endpoint configured') + try: + started = time.time() + with urlopen(url, timeout=2) as resp: + latency = round((time.time() - started) * 1000, 2) + return CheckResult(status='pass' if resp.status < 400 else 'fail', latency_ms=latency, detail=f'HTTP {resp.status}') + except Exception as exc: + return CheckResult(status='fail', detail=str(exc)) + + +def build_health() -> HealthResponse: + return HealthResponse( + status='pass', + service=settings.service_name, + version=settings.version, + time=datetime.now(timezone.utc), + uptime_s=round(time.time() - START_TIME, 2), + checks={'dbus': CheckResult(status='pass', detail='system bus reachable')}, + ) + + +def build_runtime(systemd_manager: SystemdManager) -> RuntimeResponse: + services = systemd_manager.list_services(settings.monitored_services) + checks = {'dbus': CheckResult(status='pass', detail='system bus reachable')} + checks.update({k: probe_http(v) for k, v in HEALTH_TARGETS.items()}) + overall = 'pass' + if any(c.status == 'fail' for c in checks.values()) or any(s.status == 'fail' for s in services): + overall = 'fail' + elif any(c.status == 'warn' for c in checks.values()) or any(s.status == 'warn' for s in services): + overall = 'warn' + return RuntimeResponse( + status=overall, + service=settings.service_name, + version=settings.version, + time=datetime.now(timezone.utc), + hostname=socket.gethostname(), + uptime_s=round(time.time() - START_TIME, 2), + services=services, + checks=checks, + ) diff --git a/agent/app/services/systemd_manager.py b/agent/app/services/systemd_manager.py new file mode 100644 index 0000000..bd1b380 --- /dev/null +++ b/agent/app/services/systemd_manager.py @@ -0,0 +1,64 @@ +from datetime import datetime, timezone +from typing import List +import dbus + +from app.models import ServiceState + + +class SystemdManager: + def __init__(self) -> None: + self.bus = dbus.SystemBus() + systemd = self.bus.get_object('org.freedesktop.systemd1', '/org/freedesktop/systemd1') + self.manager = dbus.Interface(systemd, 'org.freedesktop.systemd1.Manager') + + def list_services(self, names: list[str]) -> List[ServiceState]: + items: list[ServiceState] = [] + for name in names: + try: + unit_path = self.manager.GetUnit(name) + unit = self.bus.get_object('org.freedesktop.systemd1', unit_path) + props = dbus.Interface(unit, 'org.freedesktop.DBus.Properties') + active_state = str(props.Get('org.freedesktop.systemd1.Unit', 'ActiveState')) + sub_state = str(props.Get('org.freedesktop.systemd1.Unit', 'SubState')) + loaded = str(props.Get('org.freedesktop.systemd1.Unit', 'LoadState')) + description = str(props.Get('org.freedesktop.systemd1.Unit', 'Description')) + try: + pid = int(props.Get('org.freedesktop.systemd1.Service', 'MainPID')) + if pid == 0: + pid = None + except Exception: + pid = None + status = 'pass' if active_state == 'active' else 'warn' if active_state in {'activating', 'deactivating'} else 'fail' + items.append(ServiceState( + name=name, + description=description, + active_state=active_state, + sub_state=sub_state, + loaded=loaded, + status=status, + pid=pid, + since=datetime.now(timezone.utc), + )) + except Exception as exc: + items.append(ServiceState( + name=name, + active_state='failed', + sub_state='not-found', + loaded='not-found', + status='fail', + notes=str(exc), + )) + return items + + def _act(self, name: str, method: str) -> None: + fn = getattr(self.manager, method) + fn(name, 'replace') + + def start(self, name: str) -> None: + self._act(name, 'StartUnit') + + def stop(self, name: str) -> None: + self._act(name, 'StopUnit') + + def restart(self, name: str) -> None: + self._act(name, 'RestartUnit') diff --git a/backend/app/main.py b/backend/app/main.py index 900642c..d3be8c1 100644 --- a/backend/app/main.py +++ b/backend/app/main.py @@ -1,11 +1,22 @@ from fastapi import FastAPI +from fastapi.middleware.cors import CORSMiddleware -app = FastAPI(title="NetMapper API", version="0.1.0") +from app.routers.discoveries import router as discoveries_router +from app.routers.health import router as health_router +from app.routers.jobs import router as jobs_router +from app.routers.objects import router as objects_router -@app.get("/health") -def health(): - return {"status": "ok"} +app = FastAPI(title='NetMapper Backend', version='0.1.0') -@app.get("/api/v1") -def root(): - return {"service": "netmapper-backend", "version": "0.1.0"} +app.add_middleware( + CORSMiddleware, + allow_origins=['*'], + allow_credentials=True, + allow_methods=['*'], + allow_headers=['*'], +) + +app.include_router(health_router) +app.include_router(objects_router) +app.include_router(jobs_router) +app.include_router(discoveries_router) diff --git a/backend/app/routers/discoveries.py b/backend/app/routers/discoveries.py new file mode 100644 index 0000000..6c845d2 --- /dev/null +++ b/backend/app/routers/discoveries.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, HTTPException + +from app.schemas import DiscoveryRun, EntityResponse, ListResponse +from app.services.store import DISCOVERY_RUNS + +router = APIRouter(prefix='/discoveries', tags=['discoveries']) + + +@router.get('', response_model=ListResponse[DiscoveryRun]) +def list_discoveries() -> ListResponse[DiscoveryRun]: + return ListResponse(count=len(DISCOVERY_RUNS), items=DISCOVERY_RUNS) + + +@router.get('/{discovery_id}', response_model=EntityResponse[DiscoveryRun]) +def get_discovery(discovery_id: str) -> EntityResponse[DiscoveryRun]: + for item in DISCOVERY_RUNS: + if item.id == discovery_id: + return EntityResponse(item=item) + raise HTTPException(status_code=404, detail='Discovery not found') diff --git a/backend/app/routers/health.py b/backend/app/routers/health.py new file mode 100644 index 0000000..aeb04c6 --- /dev/null +++ b/backend/app/routers/health.py @@ -0,0 +1,35 @@ +import time +from datetime import datetime, timezone + +from fastapi import APIRouter + +from app.schemas import HealthCheck, HealthResponse + +router = APIRouter(tags=['health']) +START_TIME = time.time() + + +def build_health() -> HealthResponse: + return HealthResponse( + status='pass', + service='netmapper-backend', + version='0.1.0', + time=datetime.now(timezone.utc), + uptime_s=round(time.time() - START_TIME, 2), + checks={'db': HealthCheck(status='warn', detail='database not configured yet')}, + ) + + +@router.get('/live', response_model=HealthResponse) +def live() -> HealthResponse: + return build_health() + + +@router.get('/ready', response_model=HealthResponse) +def ready() -> HealthResponse: + return build_health() + + +@router.get('/health', response_model=HealthResponse) +def health() -> HealthResponse: + return build_health() diff --git a/backend/app/routers/jobs.py b/backend/app/routers/jobs.py new file mode 100644 index 0000000..093505c --- /dev/null +++ b/backend/app/routers/jobs.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, HTTPException + +from app.schemas import EntityResponse, Job, ListResponse +from app.services.store import JOBS + +router = APIRouter(prefix='/jobs', tags=['jobs']) + + +@router.get('', response_model=ListResponse[Job]) +def list_jobs() -> ListResponse[Job]: + return ListResponse(count=len(JOBS), items=JOBS) + + +@router.get('/{job_id}', response_model=EntityResponse[Job]) +def get_job(job_id: str) -> EntityResponse[Job]: + for item in JOBS: + if item.id == job_id: + return EntityResponse(item=item) + raise HTTPException(status_code=404, detail='Job not found') diff --git a/backend/app/routers/objects.py b/backend/app/routers/objects.py new file mode 100644 index 0000000..271da9d --- /dev/null +++ b/backend/app/routers/objects.py @@ -0,0 +1,19 @@ +from fastapi import APIRouter, HTTPException + +from app.schemas import EntityResponse, ListResponse, NetObject +from app.services.store import OBJECTS + +router = APIRouter(prefix='/objects', tags=['objects']) + + +@router.get('', response_model=ListResponse[NetObject]) +def list_objects() -> ListResponse[NetObject]: + return ListResponse(count=len(OBJECTS), items=OBJECTS) + + +@router.get('/{object_id}', response_model=EntityResponse[NetObject]) +def get_object(object_id: str) -> EntityResponse[NetObject]: + for item in OBJECTS: + if item.id == object_id: + return EntityResponse(item=item) + raise HTTPException(status_code=404, detail='Object not found') diff --git a/backend/app/schemas.py b/backend/app/schemas.py new file mode 100644 index 0000000..6d4600e --- /dev/null +++ b/backend/app/schemas.py @@ -0,0 +1,125 @@ +from datetime import datetime +from typing import Any, Dict, Generic, List, Literal, Optional, TypeVar + +from pydantic import BaseModel, Field + +T = TypeVar('T') + +JobStatus = Literal['pending', 'running', 'completed', 'failed', 'cancelled'] +ObjectStatus = Literal['active', 'planned', 'inactive', 'retired', 'unknown'] +ObjectType = Literal[ + 'site', 'area', 'rack', 'physical_host', 'vm', 'lxc', 'container', + 'app', 'switch', 'firewall', 'router', 'ap', 'camera', 'storage', 'other' +] + + +class HealthCheck(BaseModel): + status: Literal['pass', 'warn', 'fail'] + latency_ms: Optional[float] = None + detail: Optional[str] = None + + +class HealthResponse(BaseModel): + status: Literal['pass', 'warn', 'fail'] + service: str + version: Optional[str] = None + time: datetime + uptime_s: Optional[float] = None + checks: Dict[str, HealthCheck] = Field(default_factory=dict) + + +class NetObject(BaseModel): + id: str + name: str + slug: Optional[str] = None + type: ObjectType + parent_id: Optional[str] = None + status: ObjectStatus + vendor: Optional[str] = None + model: Optional[str] = None + serial: Optional[str] = None + notes: Optional[str] = None + created_at: datetime + updated_at: datetime + + +class IPAddress(BaseModel): + id: str + address: str + version: Literal[4, 6] + subnet_id: Optional[str] = None + object_id: Optional[str] = None + interface_id: Optional[str] = None + hostname: Optional[str] = None + source: Literal['manual', 'snmp', 'arp', 'lldp', 'nmap', 'import', 'other'] + status: Literal['assigned', 'reserved', 'free', 'unknown'] = 'unknown' + + +class Subnet(BaseModel): + id: str + name: str + cidr: str + gateway: Optional[str] = None + vlan_id: Optional[int] = None + notes: Optional[str] = None + + +class Link(BaseModel): + id: str + source_object_id: str + target_object_id: str + source_interface_id: Optional[str] = None + target_interface_id: Optional[str] = None + type: Literal['physical', 'logical', 'hosted_on', 'member_of', 'uplink', 'other'] + confidence: float = 1.0 + source: Literal['manual', 'snmp', 'lldp', 'arp', 'import', 'inference'] + + +class Job(BaseModel): + id: str + type: str + status: JobStatus + progress: int = 0 + message: Optional[str] = None + error: Optional[str] = None + payload: Optional[Dict[str, Any]] = None + result: Optional[Dict[str, Any]] = None + created_at: datetime + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + + +class DiscoveryProfile(BaseModel): + id: str + name: str + strategy: Literal['fast', 'balanced', 'deep', 'custom'] + use_icmp: bool = True + use_arp: bool = True + use_snmp: bool = False + use_lldp: bool = False + use_portscan: bool = False + notes: Optional[str] = None + + +class DiscoveryRun(BaseModel): + id: str + profile_id: str + subnet_ids: List[str] = Field(default_factory=list) + job_id: Optional[str] = None + status: JobStatus + summary: Optional[Dict[str, Any]] = None + created_at: datetime + started_at: Optional[datetime] = None + finished_at: Optional[datetime] = None + + +class ListResponse(BaseModel, Generic[T]): + count: int + items: List[T] + page: Optional[int] = None + page_size: Optional[int] = None + total: Optional[int] = None + + +class EntityResponse(BaseModel, Generic[T]): + item: T diff --git a/backend/app/services/store.py b/backend/app/services/store.py new file mode 100644 index 0000000..19787fb --- /dev/null +++ b/backend/app/services/store.py @@ -0,0 +1,23 @@ +from datetime import datetime, timezone + +from app.schemas import DiscoveryProfile, DiscoveryRun, Job, NetObject + +NOW = datetime.now(timezone.utc) + +OBJECTS = [ + NetObject(id='obj_1', name='Site HQ', slug='site-hq', type='site', status='active', created_at=NOW, updated_at=NOW), + NetObject(id='obj_2', name='Rack A', slug='rack-a', type='rack', parent_id='obj_1', status='active', created_at=NOW, updated_at=NOW), + NetObject(id='obj_3', name='Proxmox Host', slug='proxmox-host', type='physical_host', parent_id='obj_2', status='active', vendor='Lenovo', model='ThinkCentre', created_at=NOW, updated_at=NOW), +] + +JOBS = [ + Job(id='job_1', type='discovery.scan', status='pending', progress=0, created_at=NOW, payload={'subnet_ids': ['subnet_1'], 'profile_id': 'profile_1'}) +] + +DISCOVERY_PROFILES = [ + DiscoveryProfile(id='profile_1', name='Fast LAN', strategy='fast', use_icmp=True, use_arp=True) +] + +DISCOVERY_RUNS = [ + DiscoveryRun(id='disc_1', profile_id='profile_1', subnet_ids=['subnet_1'], job_id='job_1', status='pending', created_at=NOW) +]