Dateien nach "app" hochladen
This commit is contained in:
Binary file not shown.
@@ -0,0 +1,36 @@
|
||||
"""Konfiguration, wird aus Umgebungsvariablen / .env geladen."""
|
||||
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||
|
||||
|
||||
class Settings(BaseSettings):
|
||||
model_config = SettingsConfigDict(env_file=".env", env_file_encoding="utf-8", extra="ignore")
|
||||
|
||||
# --- phpipam ---
|
||||
phpipam_base_url: str # z.B. https://ipam.firma.local
|
||||
phpipam_app_id: str # App-ID der phpIPAM API-App (Administration > API)
|
||||
|
||||
# Variante A: statischer App-Token (App-Security-Type "Token" in phpipam)
|
||||
phpipam_app_token: str | None = None
|
||||
|
||||
# Variante B: User-Login, Token wird zur Laufzeit geholt
|
||||
phpipam_username: str | None = None
|
||||
phpipam_password: str | None = None
|
||||
|
||||
phpipam_verify_ssl: bool = True
|
||||
|
||||
# --- Outline ---
|
||||
outline_base_url: str # z.B. https://outline.firma.local
|
||||
outline_api_token: str
|
||||
outline_collection_id: str # Ziel-Collection für die Subnetz-Dokumente
|
||||
outline_parent_document_id: str | None = None # z.B. die "Netze"-Seite; Docs werden darunter angelegt
|
||||
outline_verify_ssl: bool = True
|
||||
|
||||
# --- Sync ---
|
||||
sync_dry_run: bool = False
|
||||
|
||||
# Automatischer Zeitplan (Cron-Syntax, Standard: jede volle Stunde)
|
||||
sync_schedule_enabled: bool = True
|
||||
sync_schedule_cron: str = "0 * * * *"
|
||||
|
||||
|
||||
settings = Settings() # type: ignore[call-arg]
|
||||
+68
@@ -0,0 +1,68 @@
|
||||
"""FastAPI App: phpipam -> Outline Sync."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
from contextlib import asynccontextmanager
|
||||
|
||||
from fastapi import FastAPI, HTTPException
|
||||
|
||||
from .config import settings
|
||||
from .phpipam_client import PhpIpamClient
|
||||
from .scheduler import scheduler, start_scheduler, stop_scheduler
|
||||
from .sync import run_sync
|
||||
|
||||
logging.basicConfig(
|
||||
level=logging.INFO,
|
||||
format="%(asctime)s %(levelname)s %(name)s: %(message)s",
|
||||
)
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
async def lifespan(app: FastAPI):
|
||||
start_scheduler()
|
||||
yield
|
||||
stop_scheduler()
|
||||
|
||||
|
||||
app = FastAPI(title="phpipam-outline-sync", version="0.1.0", lifespan=lifespan)
|
||||
|
||||
|
||||
@app.get("/health")
|
||||
def health() -> dict:
|
||||
return {"status": "ok"}
|
||||
|
||||
|
||||
@app.get("/subnets")
|
||||
def subnets() -> list[dict]:
|
||||
"""Nur zum Testen der phpipam-Verbindung, ohne Outline anzufassen."""
|
||||
try:
|
||||
with PhpIpamClient() as ipam:
|
||||
return ipam.list_subnets()
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=f"phpipam-Fehler: {exc}") from exc
|
||||
|
||||
|
||||
@app.post("/sync")
|
||||
def sync(dry_run: bool | None = None) -> dict:
|
||||
"""Synchronisiert alle Subnetze + Adressen aus phpipam nach Outline.
|
||||
|
||||
?dry_run=true -> nichts wird in Outline geschrieben, nur eine Vorschau.
|
||||
"""
|
||||
try:
|
||||
results = run_sync(dry_run=dry_run)
|
||||
except Exception as exc: # noqa: BLE001
|
||||
raise HTTPException(status_code=502, detail=str(exc)) from exc
|
||||
return {"count": len(results), "results": results}
|
||||
|
||||
|
||||
@app.get("/schedule")
|
||||
def schedule_info() -> dict:
|
||||
"""Zeigt, ob der automatische Zeitplan aktiv ist und wann er als nächstes läuft."""
|
||||
if not settings.sync_schedule_enabled or not scheduler.running:
|
||||
return {"enabled": False, "cron": settings.sync_schedule_cron}
|
||||
job = scheduler.get_job("phpipam-outline-sync")
|
||||
return {
|
||||
"enabled": True,
|
||||
"cron": settings.sync_schedule_cron,
|
||||
"next_run": job.next_run_time.isoformat() if job and job.next_run_time else None,
|
||||
}
|
||||
@@ -0,0 +1,118 @@
|
||||
"""Kleiner Client für die Outline API.
|
||||
|
||||
Doku: https://www.getoutline.com/developers
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
class OutlineClient:
|
||||
def __init__(self) -> None:
|
||||
self._base = f"{settings.outline_base_url.rstrip('/')}/api"
|
||||
self._client = httpx.Client(
|
||||
verify=settings.outline_verify_ssl,
|
||||
timeout=30.0,
|
||||
headers={
|
||||
"Authorization": f"Bearer {settings.outline_api_token}",
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> "OutlineClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.close()
|
||||
|
||||
def _post(self, path: str, json: dict) -> dict:
|
||||
resp = self._client.post(f"{self._base}{path}", json=json)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if "data" not in data:
|
||||
raise RuntimeError(f"Unerwartete Outline-Antwort von {path}: {data}")
|
||||
return data
|
||||
|
||||
def resolve_collection_id(self, collection_id_or_slug: str) -> str:
|
||||
"""Löst eine Collection-ID oder Friendly-URL-Slug in die echte UUID auf.
|
||||
|
||||
`documents.search`/`documents.create` akzeptieren für `collectionId`
|
||||
nur eine vollständige UUID. Die ID in der Browser-Adresszeile ist bei
|
||||
neueren Outline-Versionen aber die gekürzte Friendly-URL-ID, keine
|
||||
UUID. `collections.info` akzeptiert beides, daher lösen wir hier
|
||||
einmal auf.
|
||||
"""
|
||||
data = self._post("/collections.info", {"id": collection_id_or_slug})
|
||||
return data["data"]["id"]
|
||||
|
||||
def resolve_document_id(self, document_id_or_slug: str) -> str:
|
||||
"""Löst eine Dokument-ID oder Friendly-URL-Slug in die echte UUID auf."""
|
||||
data = self._post("/documents.info", {"id": document_id_or_slug})
|
||||
return data["data"]["id"]
|
||||
|
||||
def find_document_by_title(
|
||||
self, title: str, collection_id: str, parent_document_id: str | None = None
|
||||
) -> dict | None:
|
||||
"""Sucht ein bestehendes Dokument mit exakt diesem Titel in der Collection.
|
||||
|
||||
Ist parent_document_id gesetzt, wird zusätzlich geprüft, dass der Treffer
|
||||
auch wirklich unter dieser Elternseite hängt (z.B. "Netze"), damit gleich
|
||||
benannte Dokumente an anderer Stelle in der Collection nicht versehentlich
|
||||
überschrieben werden.
|
||||
"""
|
||||
data = self._post(
|
||||
"/documents.search",
|
||||
{"query": title, "collectionId": collection_id, "limit": 25},
|
||||
)
|
||||
for hit in data["data"]:
|
||||
doc = hit.get("document", hit)
|
||||
if doc.get("title") != title:
|
||||
continue
|
||||
if parent_document_id and doc.get("parentDocumentId") != parent_document_id:
|
||||
continue
|
||||
return doc
|
||||
return None
|
||||
|
||||
def create_document(
|
||||
self, title: str, text: str, collection_id: str, parent_document_id: str | None = None
|
||||
) -> dict:
|
||||
payload = {
|
||||
"title": title,
|
||||
"text": text,
|
||||
"collectionId": collection_id,
|
||||
"publish": True,
|
||||
}
|
||||
if parent_document_id:
|
||||
payload["parentDocumentId"] = parent_document_id
|
||||
data = self._post("/documents.create", payload)
|
||||
return data["data"]
|
||||
|
||||
def update_document(self, document_id: str, title: str, text: str) -> dict:
|
||||
data = self._post(
|
||||
"/documents.update",
|
||||
{"id": document_id, "title": title, "text": text},
|
||||
)
|
||||
return data["data"]
|
||||
|
||||
def upsert_document(
|
||||
self,
|
||||
title: str,
|
||||
text: str,
|
||||
collection_id: str,
|
||||
parent_document_id: str | None = None,
|
||||
) -> tuple[dict, bool]:
|
||||
"""Legt ein Dokument an oder aktualisiert es, falls der Titel bereits existiert.
|
||||
|
||||
Returns: (document, created)
|
||||
"""
|
||||
existing = self.find_document_by_title(title, collection_id, parent_document_id)
|
||||
if existing:
|
||||
doc = self.update_document(existing["id"], title, text)
|
||||
return doc, False
|
||||
doc = self.create_document(title, text, collection_id, parent_document_id)
|
||||
return doc, True
|
||||
@@ -0,0 +1,75 @@
|
||||
"""Kleiner Client für die phpIPAM REST API.
|
||||
|
||||
Doku: https://phpipam.net/api/api_documentation/
|
||||
"""
|
||||
from __future__ import annotations
|
||||
|
||||
import httpx
|
||||
|
||||
from .config import settings
|
||||
|
||||
|
||||
class PhpIpamClient:
|
||||
def __init__(self) -> None:
|
||||
self._base = f"{settings.phpipam_base_url.rstrip('/')}/api/{settings.phpipam_app_id}"
|
||||
self._token: str | None = settings.phpipam_app_token
|
||||
self._client = httpx.Client(verify=settings.phpipam_verify_ssl, timeout=30.0)
|
||||
|
||||
def close(self) -> None:
|
||||
self._client.close()
|
||||
|
||||
def __enter__(self) -> "PhpIpamClient":
|
||||
return self
|
||||
|
||||
def __exit__(self, *exc) -> None:
|
||||
self.close()
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# Auth
|
||||
# ------------------------------------------------------------------
|
||||
def _ensure_token(self) -> str:
|
||||
if self._token:
|
||||
return self._token
|
||||
if not (settings.phpipam_username and settings.phpipam_password):
|
||||
raise RuntimeError(
|
||||
"Weder PHPIPAM_APP_TOKEN noch PHPIPAM_USERNAME/PHPIPAM_PASSWORD gesetzt."
|
||||
)
|
||||
resp = self._client.post(
|
||||
f"{self._base}/user/",
|
||||
auth=(settings.phpipam_username, settings.phpipam_password),
|
||||
)
|
||||
resp.raise_for_status()
|
||||
data = resp.json()
|
||||
if not data.get("success"):
|
||||
raise RuntimeError(f"phpipam Login fehlgeschlagen: {data}")
|
||||
self._token = data["data"]["token"]
|
||||
return self._token
|
||||
|
||||
def _headers(self) -> dict[str, str]:
|
||||
return {"token": self._ensure_token()}
|
||||
|
||||
# ------------------------------------------------------------------
|
||||
# API calls
|
||||
# ------------------------------------------------------------------
|
||||
def _get(self, path: str, params: dict | None = None) -> dict:
|
||||
resp = self._client.get(f"{self._base}{path}", headers=self._headers(), params=params)
|
||||
if resp.status_code == 404:
|
||||
# phpipam liefert 404 z.B. wenn ein Subnetz keine Adressen hat
|
||||
return {"success": False, "data": []}
|
||||
resp.raise_for_status()
|
||||
return resp.json()
|
||||
|
||||
def list_subnets(self) -> list[dict]:
|
||||
"""Alle Subnetze (flach, inkl. verschachtelter unter allen Sections)."""
|
||||
data = self._get("/subnets/")
|
||||
return data.get("data") or []
|
||||
|
||||
def list_addresses(self, subnet_id: int | str) -> list[dict]:
|
||||
"""Alle belegten Adressen eines Subnetzes."""
|
||||
data = self._get(f"/subnets/{subnet_id}/addresses/")
|
||||
return data.get("data") or []
|
||||
|
||||
def get_section_name(self, section_id: int | str) -> str | None:
|
||||
data = self._get(f"/sections/{section_id}/")
|
||||
section = data.get("data")
|
||||
return section.get("name") if section else None
|
||||
@@ -0,0 +1,54 @@
|
||||
"""Hintergrund-Scheduler: führt den Sync automatisch nach Zeitplan aus."""
|
||||
from __future__ import annotations
|
||||
|
||||
import logging
|
||||
|
||||
from apscheduler.schedulers.asyncio import AsyncIOScheduler
|
||||
from apscheduler.triggers.cron import CronTrigger
|
||||
|
||||
from .config import settings
|
||||
from .sync import run_sync
|
||||
|
||||
logger = logging.getLogger("phpipam_outline_sync.scheduler")
|
||||
|
||||
scheduler = AsyncIOScheduler()
|
||||
JOB_ID = "phpipam-outline-sync"
|
||||
|
||||
|
||||
def _run_scheduled_sync() -> None:
|
||||
"""Wird vom Scheduler aufgerufen (läuft in einem Thread, blockiert den
|
||||
Event-Loop also nicht)."""
|
||||
try:
|
||||
results = run_sync()
|
||||
created = sum(1 for r in results if r.get("action") == "created")
|
||||
updated = sum(1 for r in results if r.get("action") == "updated")
|
||||
logger.info(
|
||||
"Geplanter Sync abgeschlossen: %d Subnetze (%d neu, %d aktualisiert)",
|
||||
len(results),
|
||||
created,
|
||||
updated,
|
||||
)
|
||||
except Exception: # noqa: BLE001
|
||||
logger.exception("Geplanter Sync fehlgeschlagen")
|
||||
|
||||
|
||||
def start_scheduler() -> None:
|
||||
if not settings.sync_schedule_enabled:
|
||||
logger.info("Scheduler deaktiviert (SYNC_SCHEDULE_ENABLED=false)")
|
||||
return
|
||||
trigger = CronTrigger.from_crontab(settings.sync_schedule_cron)
|
||||
scheduler.add_job(
|
||||
_run_scheduled_sync,
|
||||
trigger=trigger,
|
||||
id=JOB_ID,
|
||||
replace_existing=True,
|
||||
max_instances=1,
|
||||
coalesce=True,
|
||||
)
|
||||
scheduler.start()
|
||||
logger.info("Scheduler gestartet, Zeitplan: '%s'", settings.sync_schedule_cron)
|
||||
|
||||
|
||||
def stop_scheduler() -> None:
|
||||
if scheduler.running:
|
||||
scheduler.shutdown(wait=False)
|
||||
+108
@@ -0,0 +1,108 @@
|
||||
"""Sync-Logik: liest Subnetze + Adressen aus phpipam und schreibt sie als
|
||||
ein Outline-Dokument pro Subnetz."""
|
||||
from __future__ import annotations
|
||||
|
||||
from .config import settings
|
||||
from .outline_client import OutlineClient
|
||||
from .phpipam_client import PhpIpamClient
|
||||
|
||||
|
||||
def _subnet_title(subnet: dict) -> str:
|
||||
cidr = f"{subnet.get('subnet')}/{subnet.get('mask')}"
|
||||
desc = subnet.get("description") or ""
|
||||
return f"{cidr} – {desc}".rstrip(" –") if desc else cidr
|
||||
|
||||
|
||||
def _subnet_markdown(subnet: dict, addresses: list[dict], section_name: str | None) -> str:
|
||||
cidr = f"{subnet.get('subnet')}/{subnet.get('mask')}"
|
||||
lines: list[str] = []
|
||||
lines.append(f"# {cidr}")
|
||||
lines.append("")
|
||||
lines.append(f"- **Beschreibung:** {subnet.get('description') or '–'}")
|
||||
if section_name:
|
||||
lines.append(f"- **Section:** {section_name}")
|
||||
if subnet.get("vlanId") and subnet.get("vlanId") != "0":
|
||||
lines.append(f"- **VLAN-ID:** {subnet.get('vlanId')}")
|
||||
if subnet.get("location"):
|
||||
lines.append(f"- **Standort:** {subnet.get('location')}")
|
||||
lines.append("")
|
||||
|
||||
if not addresses:
|
||||
lines.append("_Keine belegten Adressen in phpipam hinterlegt._")
|
||||
return "\n".join(lines)
|
||||
|
||||
lines.append("| IP | Hostname | Beschreibung | MAC | Owner |")
|
||||
lines.append("|---|---|---|---|---|")
|
||||
for addr in sorted(addresses, key=lambda a: tuple(int(o) for o in a["ip"].split("."))):
|
||||
lines.append(
|
||||
"| {ip} | {hostname} | {desc} | {mac} | {owner} |".format(
|
||||
ip=addr.get("ip", ""),
|
||||
hostname=addr.get("hostname") or "",
|
||||
desc=(addr.get("description") or "").replace("\n", " "),
|
||||
mac=addr.get("mac") or "",
|
||||
owner=addr.get("owner") or "",
|
||||
)
|
||||
)
|
||||
return "\n".join(lines)
|
||||
|
||||
|
||||
def run_sync(dry_run: bool | None = None) -> list[dict]:
|
||||
"""Führt den Sync durch. Gibt eine Liste von Ergebnis-Infos je Subnetz zurück."""
|
||||
dry_run = settings.sync_dry_run if dry_run is None else dry_run
|
||||
results: list[dict] = []
|
||||
section_cache: dict[str, str | None] = {}
|
||||
|
||||
with PhpIpamClient() as ipam:
|
||||
subnets = ipam.list_subnets()
|
||||
|
||||
outline = None if dry_run else OutlineClient()
|
||||
collection_id = settings.outline_collection_id
|
||||
parent_document_id = settings.outline_parent_document_id
|
||||
try:
|
||||
if outline is not None:
|
||||
collection_id = outline.resolve_collection_id(settings.outline_collection_id)
|
||||
if parent_document_id:
|
||||
parent_document_id = outline.resolve_document_id(parent_document_id)
|
||||
|
||||
for subnet in subnets:
|
||||
section_id = subnet.get("sectionId")
|
||||
if section_id not in section_cache:
|
||||
section_cache[section_id] = ipam.get_section_name(section_id) if section_id else None
|
||||
section_name = section_cache.get(section_id)
|
||||
|
||||
addresses = ipam.list_addresses(subnet["id"])
|
||||
title = _subnet_title(subnet)
|
||||
text = _subnet_markdown(subnet, addresses, section_name)
|
||||
|
||||
if dry_run:
|
||||
results.append(
|
||||
{
|
||||
"subnet_id": subnet["id"],
|
||||
"title": title,
|
||||
"address_count": len(addresses),
|
||||
"action": "dry-run",
|
||||
}
|
||||
)
|
||||
continue
|
||||
|
||||
doc, created = outline.upsert_document( # type: ignore[union-attr]
|
||||
title,
|
||||
text,
|
||||
collection_id,
|
||||
parent_document_id,
|
||||
)
|
||||
results.append(
|
||||
{
|
||||
"subnet_id": subnet["id"],
|
||||
"title": title,
|
||||
"address_count": len(addresses),
|
||||
"action": "created" if created else "updated",
|
||||
"outline_document_id": doc.get("id"),
|
||||
"outline_url": doc.get("url"),
|
||||
}
|
||||
)
|
||||
finally:
|
||||
if outline is not None:
|
||||
outline.close()
|
||||
|
||||
return results
|
||||
Reference in New Issue
Block a user