commit a4e7bf0ce2e3f8d727507a0b5779e4d3eada3dea Author: Robert Date: Fri Jul 24 08:38:04 2026 +0000 Dateien nach "/" hochladen diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..1397c69 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,7 @@ +.venv +__pycache__ +*.pyc +.env +.git +.gitignore +README.md diff --git a/.env.example b/.env.example new file mode 100644 index 0000000..4f958cc --- /dev/null +++ b/.env.example @@ -0,0 +1,30 @@ +# --- phpipam --- +PHPIPAM_BASE_URL=https://ipam.firma.local +PHPIPAM_APP_ID=fastapi-sync + +# Variante A: statischer App-Token (empfohlen, App-Security-Type "Token" in phpipam) +PHPIPAM_APP_TOKEN= + +# Variante B: alternativ Benutzername/Passwort (Token wird automatisch geholt) +PHPIPAM_USERNAME= +PHPIPAM_PASSWORD= + +# Auf false setzen, falls phpipam ein selbstsigniertes Zertifikat nutzt (nur intern!) +PHPIPAM_VERIFY_SSL=true + +# --- Outline --- +OUTLINE_BASE_URL=https://outline.firma.local +OUTLINE_API_TOKEN= +OUTLINE_COLLECTION_ID= +# Optional: ID der "Netze"-Seite. Wenn gesetzt, werden alle Subnetz-Dokumente +# als Unterseiten davon angelegt statt lose in der Collection. +OUTLINE_PARENT_DOCUMENT_ID= +OUTLINE_VERIFY_SSL=true + +# --- Sync --- +SYNC_DRY_RUN=false + +# Automatischer Zeitplan (Cron-Syntax: Minute Stunde Tag Monat Wochentag) +# Standard: jede volle Stunde. Auf false setzen, um nur manuell über POST /sync zu syncen. +SYNC_SCHEDULE_ENABLED=true +SYNC_SCHEDULE_CRON=0 * * * * diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..57b24fd --- /dev/null +++ b/Dockerfile @@ -0,0 +1,23 @@ +FROM python:3.12-slim + +WORKDIR /app + +# System-Zertifikate aktuell halten (wichtig falls phpipam/Outline +# selbstsignierte oder interne CA-Zertifikate nutzen) +RUN apt-get update && apt-get install -y --no-install-recommends ca-certificates \ + && rm -rf /var/lib/apt/lists/* + +COPY requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY app ./app + +RUN useradd --create-home --uid 1000 appuser +USER appuser + +EXPOSE 8000 + +HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ + CMD python -c "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)" || exit 1 + +CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"] diff --git a/README.md b/README.md new file mode 100644 index 0000000..a8f9aac --- /dev/null +++ b/README.md @@ -0,0 +1,220 @@ +# phpipam-outline-sync + +Kleine FastAPI-App: liest Subnetze + IP-Adressen aus phpipam und legt pro +Subnetz ein Dokument in Outline an bzw. aktualisiert es. + +## 1. API-Token besorgen + +### phpipam + +1. Als Admin einloggen → **Administration → API**. +2. **Add API** → App-ID vergeben (z.B. `fastapi-sync`, das ist der Wert für + `PHPIPAM_APP_ID`). +3. Bei **App permissions** mind. `Read` setzen (für diesen Sync reicht + Lesezugriff). +4. Bei **App security** gibt es zwei sinnvolle Optionen: + - **SSL with App code token** oder **SSL** → erzeugt einen festen Token, + der direkt als `PHPIPAM_APP_TOKEN` eingetragen wird (kein Login nötig). + - **User token** → kein fester Token, stattdessen trägst du + `PHPIPAM_USERNAME` / `PHPIPAM_PASSWORD` eines phpipam-Users mit + API-Leserechten ein; die App holt sich den Token bei jedem Start selbst. +5. Speichern. + +### Outline + +1. In Outline oben rechts auf den Avatar → **Settings → API & Apps**. +2. **New API key** → Namen vergeben (z.B. "phpipam sync"), erstellen. +3. Token kopieren → `OUTLINE_API_TOKEN`. +4. Die Ziel-Collection öffnen, die ID aus der URL kopieren + (`.../collection/-slug`) → `OUTLINE_COLLECTION_ID`. +5. Optional: die Zielseite "Netze" öffnen, ID aus der URL kopieren + (`.../doc/netze-`) → `OUTLINE_PARENT_DOCUMENT_ID`. Ist das gesetzt, + legt der Sync alle Subnetz-Dokumente als Unterseiten von "Netze" an statt + lose in der Collection. + + **Hinweis:** die ID in der Browser-URL ist bei neueren Outline-Versionen + nur eine gekürzte "Friendly URL"-ID, keine vollständige UUID. Die API + (`documents.search`/`documents.create`) verlangt für `collectionId`/ + `parentDocumentId` aber eine echte UUID. Der Sync löst das selbst auf + (`collections.info`/`documents.info`), du musst dich also nicht darum + kümmern — einfach die ID aus der URL eintragen, egal ob kurz oder lang. + +### Schreibzugriff auf "IT → Netzwerke → Netze" beschränken + +Outline kennt zwei unabhängige Einschränkungsebenen für API-Keys: + +- **Endpoint-Scope am Key selbst**: beim Erstellen des API-Keys kannst du + unter "Scopes" eine Liste erlaubter Endpunkte eintragen, z.B. + `collections.info documents.info documents.search documents.create documents.update`. + (`collections.info`/`documents.info` werden gebraucht, damit der Sync die + ID aus der URL in eine echte UUID auflösen kann — siehe Hinweis oben.) + Das schränkt ein, *welche Aktionen* der Key ausführen darf (kein Löschen, + keine User-/Collection-Verwaltung etc.) — aber **nicht**, *wo* (in welcher + Collection/Seite) er schreiben darf. Das ist rein endpunktbasiert, nicht + ortsbasiert. +- **Ort-Einschränkung über den Benutzer**: ein API-Key erbt immer die Rechte + des Outline-Users, der ihn erstellt hat. Um den Zugriff auf "IT → + Netzwerke → Netze" zu begrenzen, brauchst du einen eigenen Service-User: + 1. Neuen Outline-User anlegen (z.B. `phpipam-sync@firma.local`), **kein** + Workspace-Admin. + 2. Die "IT"-Collection (oder direkt die "Netze"-Seite, falls Outline bei + euch Document-Sharing unterstützt) auf "Privat"/eingeschränkt stellen + und nur diesen User mit "Can edit" hinzufügen. Überall sonst bekommt der + User keinen Zugriff. + 3. Mit diesem User einloggen und dort den API-Key erstellen (Schritt 1–3 + oben), zusätzlich mit dem Endpoint-Scope aus Punkt 1. + +Kombiniert bekommst du: Key kann nur lesen/schreiben/suchen (kein Löschen, +keine Admin-Aktionen) UND kann inhaltlich nur auf das, worauf der +Service-User Zugriff hat. Über `OUTLINE_PARENT_DOCUMENT_ID` sorgt der Code +zusätzlich dafür, dass alle Dokumente tatsächlich unter "Netze" landen statt +irgendwo sonst in der Collection. + +## 2. Setup + +```bash +cd phpipam-outline-sync +python -m venv .venv +source .venv/bin/activate # Windows: .venv\Scripts\activate +pip install -r requirements.txt +cp .env.example .env +``` + +`.env` mit den Werten aus Schritt 1 ausfüllen. + +## 3. Starten + +### Lokal (ohne Docker) + +```bash +uvicorn app.main:app --reload +``` + +### Mit Docker + +```bash +cp .env.example .env # falls noch nicht geschehen, dann ausfüllen +docker compose up -d --build +``` + +Baut das Image (`Dockerfile`, Python 3.12-slim, läuft als Non-Root-User) und +startet den Container mit den Werten aus `.env`. Neustart bei Absturz/Reboot +ist über `restart: unless-stopped` bereits konfiguriert; ein Healthcheck +prüft alle 30s `/health`. + +**Wenn phpipam/Outline selbst in Docker laufen** und du sie über den +Container-Namen statt eine öffentliche URL erreichen willst (z.B. +`PHPIPAM_BASE_URL=http://phpipam:80`), muss der Sync-Container im selben +Docker-Netzwerk hängen: + +```bash +docker network ls # Name des Netzwerks von phpipam/Outline finden +``` + +In `docker-compose.yml` den `networks:`-Block unten einkommentieren, den +echten Netzwerknamen eintragen und beim Service den `networks:`-Eintrag +ebenfalls einkommentieren. Danach zeigen `PHPIPAM_BASE_URL` / +`OUTLINE_BASE_URL` in der `.env` auf die Container-Namen statt auf externe +Hostnamen. + +Ohne Docker-Netzwerk-Trick funktioniert es genauso über die normalen +(externen) URLs von phpipam/Outline — dann einfach `docker compose up -d` +ohne weitere Anpassung. + +### Als Portainer-Stack (Code per Volume, kein Image-Build) + +Für diesen Fall gibt es `docker-compose.portainer.yml`: statt eines eigenen +Images wird das offizielle `python:3.12-slim`-Image verwendet, der Code +kommt per Bind-Mount in den Container, `pip install` läuft bei jedem Start +neu (dank Cache-Volume schnell). Änderungen an den Skripten = Dateien im +Volume ersetzen + Container neu starten, kein Rebuild nötig. + +1. **Zielordner auf dem Docker-Server anlegen**, z.B. `/opt/stacks/phpipam-outline-sync`. + Dort muss am Ende genau das liegen, was auch lokal im Projekt liegt: der + `app/`-Ordner + `requirements.txt` (Dockerfile, docker-compose*.yml, + README, .env werden dort nicht gebraucht). +2. **Dateien übertragen** – je nachdem, was du für Zugriff auf den Server + hast: + - Direkter Host-Zugriff (SSH/SCP/WinSCP): Ordner einfach nach + `/opt/stacks/phpipam-outline-sync` kopieren. + - Nur Portainer-UI: in `docker-compose.portainer.yml` den + Bind-Mount-Block gegen den auskommentierten `app_code`-Named-Volume- + Block tauschen, Stack einmal starten (legt das leere Volume an), + dann in Portainer unter **Volumes → app_code → Browse** die Dateien + hochladen (Portainer ≥ 2.19 hat einen Datei-Browser für Volumes). +3. **Stack in Portainer anlegen**: **Stacks → Add stack** → Name vergeben + (z.B. `phpipam-outline-sync`) → Build method **Web editor** → Inhalt von + `docker-compose.portainer.yml` einfügen. +4. **Umgebungsvariablen setzen**: im Abschnitt **Environment variables** + unten im Stack-Editor auf **Advanced mode** umschalten und den Inhalt von + `.env.example` (ausgefüllt, siehe Schritt 1 oben) reinpasten. Falls der + Zielordner von Schritt 1 vom Default abweicht, zusätzlich + `APP_DIR=/dein/pfad` setzen. +5. **Deploy the stack** klicken. Portainer zieht `python:3.12-slim`, mountet + den Ordner/das Volume, installiert die Abhängigkeiten und startet + uvicorn. Logs dazu findest du im Container-Detail in Portainer. + +Sobald das läuft und du zufrieden bist, kannst du optional auf das +`Dockerfile` + `docker-compose.yml` aus dem Abschnitt oben umsteigen (Code +fest im Image, schnellerer Start, kein pip install bei jedem Neustart) – +funktional identisch, nur ohne den Volume-Umweg. + +Danach ist die App unter http://127.0.0.1:8000 erreichbar, interaktive +Doku unter http://127.0.0.1:8000/docs. Logs: `docker compose logs -f`. +Stoppen: `docker compose down`. + +## 4. Testen + +1. **Nur phpipam-Verbindung prüfen** (schreibt nichts): + ``` + GET http://127.0.0.1:8000/subnets + ``` +2. **Trockenlauf** (zeigt, was übertragen würde, schreibt noch nichts nach + Outline): + ``` + POST http://127.0.0.1:8000/sync?dry_run=true + ``` +3. **Echter Sync**: + ``` + POST http://127.0.0.1:8000/sync + ``` + +Ein Dokument wird pro Subnetz angelegt (Titel = `/`) +mit einer Markdown-Tabelle aller belegten Adressen. Läuft der Sync erneut, +wird ein bereits vorhandenes Dokument mit gleichem Titel aktualisiert statt +dupliziert (Suche über `documents.search`). + +## 5. Automatischer Zeitplan + +Solange die App läuft (egal ob lokal, `docker compose` oder Portainer-Stack), +führt sie den Sync automatisch nach Zeitplan aus — Standard: **jede volle +Stunde** (`SYNC_SCHEDULE_CRON=0 * * * *`). Kein zusätzlicher Cron-Job o.ä. +nötig, das übernimmt die App selbst über einen internen Scheduler +(APScheduler). + +- Zeitplan ändern: `SYNC_SCHEDULE_CRON` in der `.env` bzw. den + Portainer-Umgebungsvariablen anpassen (Standard-Cron-Syntax: `Minute + Stunde Tag Monat Wochentag`, z.B. `*/30 * * * *` für alle 30 Minuten oder + `0 6,18 * * *` für 6 und 18 Uhr). +- Ganz deaktivieren (nur manueller Sync über `POST /sync`): + `SYNC_SCHEDULE_ENABLED=false`. +- Nächsten Lauf prüfen: + ``` + GET http://127.0.0.1:8000/schedule + ``` + liefert z.B. `{"enabled": true, "cron": "0 * * * *", "next_run": "2026-07-24T11:00:00+02:00"}`. +- Geplante Läufe respektieren `SYNC_DRY_RUN` genauso wie manuelle Aufrufe + ohne `?dry_run=`-Parameter. Ergebnis/Fehler landen im Container-Log + (`docker compose logs -f` bzw. Portainer-Container-Logs). + +**Wichtig:** nur einen Container/eine Instanz dieser App gleichzeitig +laufen lassen (kein `docker compose up --scale ...>1`, keine mehreren +Uvicorn-Worker). Sonst würde der Sync pro Instanz einmal pro Stunde laufen +und Outline-Dokumente unnötig oft aktualisieren. + +## Hinweis + +phpipam und Outline laufen in deinem lokalen Netz — ich kann den Sync von +hier aus nicht selbst live testen. Bitte einmal `/subnets` und danach +`/sync?dry_run=true` aufrufen und mir sagen, ob die Ausgabe passt (z.B. falls +Feldnamen in eurer phpipam-Version abweichen). diff --git a/docker-compose.portainer.yml b/docker-compose.portainer.yml new file mode 100644 index 0000000..4013aba --- /dev/null +++ b/docker-compose.portainer.yml @@ -0,0 +1,48 @@ +services: + phpipam-outline-sync: + image: python:3.12-slim + container_name: phpipam-outline-sync + restart: unless-stopped + working_dir: /code + command: sh -c "pip install --no-cache-dir -r requirements.txt && uvicorn app.main:app --host 0.0.0.0 --port 8000" + volumes: + # Hier landet der Projektordner (app/ + requirements.txt), den du im + # nächsten Schritt auf den Server überträgst. Pfad bei Bedarf anpassen + # oder als Portainer-Stack-Variable APP_DIR überschreiben. + - ${APP_DIR:-/opt/stacks/phpipam-outline-sync}:/code + # Cache, damit pip die Pakete nicht bei jedem Neustart neu herunterlädt + - pip_cache:/root/.cache/pip + ports: + - "8000:8000" + environment: + PHPIPAM_BASE_URL: ${PHPIPAM_BASE_URL} + PHPIPAM_APP_ID: ${PHPIPAM_APP_ID} + PHPIPAM_APP_TOKEN: ${PHPIPAM_APP_TOKEN:-} + PHPIPAM_USERNAME: ${PHPIPAM_USERNAME:-} + PHPIPAM_PASSWORD: ${PHPIPAM_PASSWORD:-} + PHPIPAM_VERIFY_SSL: ${PHPIPAM_VERIFY_SSL:-true} + OUTLINE_BASE_URL: ${OUTLINE_BASE_URL} + OUTLINE_API_TOKEN: ${OUTLINE_API_TOKEN} + OUTLINE_COLLECTION_ID: ${OUTLINE_COLLECTION_ID} + OUTLINE_PARENT_DOCUMENT_ID: ${OUTLINE_PARENT_DOCUMENT_ID:-} + OUTLINE_VERIFY_SSL: ${OUTLINE_VERIFY_SSL:-true} + SYNC_DRY_RUN: ${SYNC_DRY_RUN:-false} + SYNC_SCHEDULE_ENABLED: ${SYNC_SCHEDULE_ENABLED:-true} + SYNC_SCHEDULE_CRON: ${SYNC_SCHEDULE_CRON:-0 * * * *} + healthcheck: + test: ["CMD", "python", "-c", "import urllib.request; urllib.request.urlopen('http://127.0.0.1:8000/health', timeout=3)"] + interval: 30s + timeout: 5s + start_period: 15s + retries: 3 + + # Alternative statt Bind-Mount oben, falls du keinen direkten + # Dateisystemzugriff auf den Docker-Host hast und Dateien stattdessen + # über Portainers Volume-Browser hochladen willst: + # volumes: + # - app_code:/code + # - pip_cache:/root/.cache/pip + +volumes: + pip_cache: + # app_code: # nur nötig für die Named-Volume-Alternative oben diff --git a/docker-compose.yml b/docker-compose.yml new file mode 100644 index 0000000..8721a5f --- /dev/null +++ b/docker-compose.yml @@ -0,0 +1,20 @@ +services: + phpipam-outline-sync: + build: . + container_name: phpipam-outline-sync + restart: unless-stopped + env_file: + - .env + ports: + - "8000:8000" + # Falls phpipam/Outline selbst als Docker-Container laufen und du sie über + # ihren Container-Namen statt über eine öffentliche URL erreichen willst, + # dieses Netzwerk mit dem/den bestehenden Netzwerk(en) ersetzen und unten + # in "networks:" als "external: true" eintragen. Siehe README. + # networks: + # - infra + +# networks: +# infra: +# external: true +# name: diff --git a/requirements.txt b/requirements.txt new file mode 100644 index 0000000..c614ed2 --- /dev/null +++ b/requirements.txt @@ -0,0 +1,5 @@ +fastapi>=0.111 +uvicorn[standard]>=0.30 +httpx>=0.27 +pydantic-settings>=2.3 +apscheduler>=3.10