Dateien nach "app" hochladen

This commit is contained in:
2026-07-24 08:38:28 +00:00
parent a4e7bf0ce2
commit 8444296198
7 changed files with 459 additions and 0 deletions
BIN
View File
Binary file not shown.
+36
View File
@@ -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
View File
@@ -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,
}
+118
View File
@@ -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
+75
View File
@@ -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
+54
View File
@@ -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
View File
@@ -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