From 317c7f03408ae745eca94221ce08ed176a229ecd Mon Sep 17 00:00:00 2001 From: Oliver Hofmann Date: Tue, 28 Apr 2026 08:34:45 +0200 Subject: [PATCH] Add Docker production build and update README - Multi-stage Dockerfile: builds frontend, packages with Python backend - admin.py serves frontend/dist as StaticFiles in production - docker-entrypoint.sh runs proxy + admin-api, exits cleanly if either dies - .dockerignore excludes .env, venv, tests, node_modules - Split requirements.txt (prod) / requirements-dev.txt (dev+test) - aiofiles added for StaticFiles support - start.sh: port checks before startup, venv auto-activation, trap cleanup - vite.config.js: clearScreen disabled - README rewritten to reflect current architecture Co-Authored-By: Claude Sonnet 4.6 --- .dockerignore | 11 ++ Dockerfile | 22 ++++ README.md | 239 ++++++++++++++++++++--------------- backend/admin.py | 7 + backend/requirements-dev.txt | 4 + backend/requirements.txt | 4 +- docker-entrypoint.sh | 19 +++ frontend/vite.config.js | 1 + start.sh | 53 ++++++-- 9 files changed, 244 insertions(+), 116 deletions(-) create mode 100644 .dockerignore create mode 100644 Dockerfile create mode 100644 backend/requirements-dev.txt create mode 100644 docker-entrypoint.sh diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000..71eae06 --- /dev/null +++ b/.dockerignore @@ -0,0 +1,11 @@ +.git/ +.venv/ +venv/ +.env +frontend/node_modules/ +frontend/dist/ +backend/__pycache__/ +backend/**/__pycache__/ +backend/*.pyc +backend/test.db +backend/tests/ diff --git a/Dockerfile b/Dockerfile new file mode 100644 index 0000000..8128682 --- /dev/null +++ b/Dockerfile @@ -0,0 +1,22 @@ +FROM node:20-alpine AS frontend-builder +WORKDIR /app +COPY frontend/package*.json frontend/ +RUN npm ci --prefix frontend +COPY frontend/ frontend/ +RUN npm run build --prefix frontend + +FROM python:3.12-slim +WORKDIR /app + +COPY backend/requirements.txt . +RUN pip install --no-cache-dir -r requirements.txt + +COPY backend/ backend/ +COPY --from=frontend-builder /app/frontend/dist frontend/dist + +COPY docker-entrypoint.sh . +RUN chmod +x docker-entrypoint.sh + +EXPOSE 8000 8001 + +CMD ["./docker-entrypoint.sh"] diff --git a/README.md b/README.md index f6e5113..44a6237 100644 --- a/README.md +++ b/README.md @@ -1,122 +1,153 @@ # Ollama Proxy mit API-Keys und Quotas -Ein Reverse-Proxy für Ollama mit API-Key-Authentifizierung und Quota-Management. +Ein Reverse-Proxy für Ollama mit API-Key-Authentifizierung, Quota-Management und Web-Admin-Oberfläche. ## Features - API-Key-Authentifizierung (Bearer Token oder `sk-`-Prefix) +- Optionales Ablaufdatum pro API-Key - Quota-Management mit getrennten Tages- und Monatslimits (Tokens & Requests) -- Token-Zählung via tiktoken (cl100k_base) -- Usage-Tracking mit automatischem täglichem/monatlichem Reset -- Web-Admin-Oberfläche für User- und Quota-Verwaltung +- Token-Zählung via tiktoken, Reset-Grenzen in der Zeitzone Europe/Berlin +- Web-Admin-Oberfläche (API-Keys verwalten, Ollama-Einstellungen, Proxy-Info) - OpenAI-kompatibler `/v1/chat/completions`-Endpunkt ## Sicherheit -- Passwörter mit bcrypt gehasht -- API-Keys als SHA-256-Hash in der DB – Plaintext wird nur einmalig bei Erstellung zurückgegeben -- Admin-Zugriff über `is_admin`-Flag in der DB, nicht über Hardcoded-Namen -- CORS-Origins konfigurierbar via `ALLOWED_ORIGINS` +- Admin-Oberfläche passwortgeschützt (`ADMIN_PASSWORD`) +- Admin-API bindet lokal auf `127.0.0.1` (nicht von außen erreichbar) +- API-Keys als SHA-256-Hash in der DB — Plaintext nur einmalig bei Erstellung - Quota-Check atomar mit `SELECT FOR UPDATE` (kein TOCTOU-Race) - -## Installation & Start - -### Voraussetzungen - -- Python 3.12+ -- PostgreSQL 16+ (oder SQLite für Entwicklung) -- Node.js 18+ (für Frontend) - -### Lokal mit SQLite (Entwicklung) - -```bash -# Backend -cd backend -pip install -r requirements.txt -python init_db.py -python setup_admin.py -uvicorn main:app --reload --port 8000 - -# Admin-API (in neuem Terminal) -uvicorn admin:app --reload --port 8001 - -# Frontend (in neuem Terminal) -cd frontend -npm install -npm run dev -``` - -### Mit PostgreSQL & Docker (Produktion) - -```bash -docker compose up -d -docker compose exec backend python init_db.py -docker compose exec backend python setup_admin.py -``` +- CORS-Origins konfigurierbar via `ALLOWED_ORIGINS` ## Konfiguration -`.env`-Datei im `backend/`-Verzeichnis anlegen: +`.env`-Datei im Projektverzeichnis anlegen (Vorlage: `.env.example`): ```env -DATABASE_URL=postgresql://user:pass@host:5432/db -OLLAMA_URL=http://ollama:11434 -ALLOWED_ORIGINS=https://admin.example.com +ADMIN_PASSWORD=change-me +PROXY_HOST=0.0.0.0 +PROXY_PORT=8000 +ADMIN_PORT=8001 +DATABASE_URL=sqlite:///./test.db +OLLAMA_URL=http://localhost:11434 +DEFAULT_MODEL=llama3 +APP_TZ=Europe/Berlin ``` | Variable | Standard | Beschreibung | |----------|----------|--------------| -| `DATABASE_URL` | PostgreSQL lokal | DB-Verbindungsstring; `sqlite:///` für SQLite | -| `OLLAMA_URL` | `http://localhost:11434` | Adresse der Ollama-Instanz | -| `ALLOWED_ORIGINS` | `http://localhost:5173` | Kommagetrennte CORS-Origins für die Admin-UI | +| `ADMIN_PASSWORD` | — | Passwort für die Admin-Oberfläche (**Pflicht**) | +| `PROXY_HOST` | `0.0.0.0` | Bind-Adresse des Proxys | +| `PROXY_PORT` | `8000` | Port des Proxys | +| `ADMIN_PORT` | `8001` | Port der Admin-API | +| `DATABASE_URL` | `sqlite:///./test.db` | DB-Verbindungsstring | +| `OLLAMA_URL` | `http://localhost:11434` | Adresse der Ollama-Instanz (auch in der UI änderbar) | +| `DEFAULT_MODEL` | `llama3` | Standard-Modell für `/v1/chat/completions` (auch in der UI änderbar) | +| `APP_TZ` | `Europe/Berlin` | Zeitzone für tägliche/monatliche Quota-Resets | +| `ALLOWED_ORIGINS` | `http://localhost:5173` | Kommagetrennte CORS-Origins | -## Proxy-Endpunkte +## Entwicklung (lokal) + +```bash +cp .env.example .env +# ADMIN_PASSWORD in .env setzen + +./start.sh +``` + +Das Script prüft alle Ports auf Belegung, aktiviert automatisch eine vorhandene `.venv`, initialisiert die Datenbank und startet Proxy, Admin-API und Vite-Dev-Server. + +Admin-Oberfläche: `http://localhost:5173` + +### Voraussetzungen + +- Python 3.12+ mit virtualenv +- Node.js 18+ + +```bash +python -m venv .venv +source .venv/bin/activate +pip install -r backend/requirements-dev.txt + +cd frontend && npm install +``` + +## Produktion (Docker) + +### Image bauen + +```bash +docker build -t llm-quota . +``` + +### Container starten + +```bash +docker run -d \ + -p 8000:8000 \ + -p 8001:8001 \ + -e ADMIN_PASSWORD=geheim \ + -e OLLAMA_URL=http://host.docker.internal:11434 \ + -e DATABASE_URL=sqlite:///./data/quota.db \ + -v $(pwd)/data:/app/backend/data \ + --name llm-quota \ + llm-quota +``` + +| Port | Dienst | +|------|--------| +| `8000` | Proxy (für LLM-Clients) | +| `8001` | Admin-API + Admin-Oberfläche | + +Admin-Oberfläche: `http://localhost:8001` + +### Mit PostgreSQL + +```bash +docker run -d \ + -p 8000:8000 \ + -p 8001:8001 \ + -e ADMIN_PASSWORD=geheim \ + -e DATABASE_URL=postgresql://user:pass@db-host:5432/llm_quota \ + -e OLLAMA_URL=http://ollama:11434 \ + llm-quota +``` + +> **Hinweis:** Im Container bindet die Admin-API auf `0.0.0.0`. Port 8001 sollte nicht öffentlich exponiert werden — entweder per Firewall absichern oder hinter einem Reverse-Proxy (nginx, Caddy) betreiben. + +## Proxy-Endpunkte (Port 8000) Alle Endpunkte erfordern einen gültigen API-Key im `Authorization`-Header. ```bash -curl -X POST http://localhost:8000/api/generate \ +curl -X POST http://localhost:8000/api/chat \ -H "Authorization: Bearer sk-xxxxxx" \ -H "Content-Type: application/json" \ - -d '{"model":"llama3","prompt":"Say hello"}' + -d '{"model":"llama3","messages":[{"role":"user","content":"Hallo"}]}' ``` -| Endpunkt | Beschreibung | -|----------|--------------| -| `POST /api/generate` | Ollama generate | -| `POST /api/chat` | Ollama chat | -| `GET /api/tags` | Verfügbare Modelle | -| `GET /v1/models` | Modelle (OpenAI-Format) | -| `POST /v1/chat/completions` | Chat (OpenAI-Format) | - -## Admin-API (Port 8001) - -Alle Endpunkte erfordern einen API-Key eines Nutzers mit `is_admin=true`. - | Endpunkt | Methode | Beschreibung | |----------|---------|--------------| -| `/api/users` | GET | Alle User auflisten | -| `/api/users` | POST | Neuen User anlegen | +| `/api/generate` | POST | Ollama generate | +| `/api/chat` | POST | Ollama chat | +| `/api/tags` | GET | Verfügbare Modelle | +| `/api/versions` | GET | Ollama-Version | +| `/v1/models` | GET | Modelle (OpenAI-Format) | +| `/v1/chat/completions` | POST | Chat (OpenAI-Format) | + +## Admin-API (Port 8001) + +Alle Endpunkte erfordern `Authorization: Bearer `. + +| Endpunkt | Methode | Beschreibung | +|----------|---------|--------------| | `/api/api-keys` | GET | Alle API-Keys auflisten | -| `/api/api-keys` | POST | Neuen API-Key erstellen (Plaintext einmalig in Response) | +| `/api/api-keys` | POST | Neuen API-Key erstellen | | `/api/api-keys/{id}/deactivate` | PUT | API-Key deaktivieren | -| `/api/quotas/{user_id}` | PUT | Quota für User setzen | - -### Quota setzen - -```bash -curl -X PUT http://localhost:8001/api/quotas/1 \ - -H "Authorization: Bearer sk-admin-key" \ - -H "Content-Type: application/json" \ - -d '{ - "daily_tokens": 1000000, - "monthly_tokens": 10000000, - "daily_requests": 1000, - "monthly_requests": 10000 - }' -``` - -`null` für ein Limit bedeutet unbegrenzt. +| `/api/api-keys/{id}/quota` | PATCH | Quota eines Keys aktualisieren | +| `/api/settings` | GET/PUT | Ollama-URL und Standard-Modell | +| `/api/ollama-models` | GET | Verfügbare Modelle von Ollama | +| `/api/proxy-info` | GET | Lokaler Proxy-Endpunkt | ## Tests @@ -130,28 +161,32 @@ python -m pytest tests/ -v ``` llm_quota/ ├── backend/ -│ ├── main.py # Proxy-Server -│ ├── admin.py # Admin-API -│ ├── database.py # DB-Verbindung & Session -│ ├── models.py # SQLAlchemy-Modelle -│ ├── schemas.py # Pydantic-Schemas -│ ├── crud.py # DB-Operationen & Token-Zählung -│ ├── init_db.py # Tabellen anlegen -│ ├── setup_admin.py # Admin-User & API-Key erstellen -│ ├── requirements.txt -│ ├── Dockerfile +│ ├── main.py # Proxy-Server (Port 8000) +│ ├── admin.py # Admin-API + Static-File-Serving (Port 8001) +│ ├── database.py # DB-Verbindung & Session +│ ├── models.py # SQLAlchemy-Modelle (APIKey, Setting, Usage) +│ ├── schemas.py # Pydantic-Schemas +│ ├── crud.py # DB-Operationen, Token-Zählung, Quota-Logik +│ ├── init_db.py # Tabellen anlegen & Settings seeden +│ ├── setup_admin.py # Standard-API-Key erstellen +│ ├── requirements.txt # Produktiv-Dependencies +│ ├── requirements-dev.txt # Test-Dependencies │ └── tests/ -│ ├── conftest.py # Fixtures -│ ├── test_auth.py # Authentifizierungs-Tests -│ └── test_quota.py # Quota- & Token-Tests +│ ├── conftest.py # Fixtures +│ ├── test_auth.py # Authentifizierungs-Tests +│ └── test_quota.py # Quota-, Token- und Ablauf-Tests ├── frontend/ │ └── src/ -│ ├── main.jsx +│ ├── main.jsx # React-Admin-UI │ └── styles.css -├── .gitignore -└── docker-compose.yml +├── Dockerfile +├── docker-entrypoint.sh +├── .dockerignore +├── .env.example +├── start.sh # Entwicklungs-Startscript +└── .gitignore ``` ## Lizenz -MIT \ No newline at end of file +MIT diff --git a/backend/admin.py b/backend/admin.py index c7d201f..5e719f9 100644 --- a/backend/admin.py +++ b/backend/admin.py @@ -1,8 +1,10 @@ import os import secrets import httpx +from pathlib import Path from fastapi import FastAPI, Depends, HTTPException, Request from fastapi.middleware.cors import CORSMiddleware +from fastapi.staticfiles import StaticFiles from sqlalchemy.orm import Session from database import get_db import crud, schemas @@ -119,3 +121,8 @@ async def get_ollama_models(db: Session = Depends(get_db), _ = Depends(require_a except Exception: models = [] return {"models": models} + +# Statisches Frontend ausliefern (nur im Produktivbetrieb, wenn dist/ existiert) +_dist = Path(__file__).parent.parent / "frontend" / "dist" +if _dist.exists(): + app.mount("/", StaticFiles(directory=_dist, html=True), name="frontend") diff --git a/backend/requirements-dev.txt b/backend/requirements-dev.txt new file mode 100644 index 0000000..556a472 --- /dev/null +++ b/backend/requirements-dev.txt @@ -0,0 +1,4 @@ +-r requirements.txt +pytest==8.3.4 +pytest-asyncio==0.25.1 +pytest-cov==6.0.0 diff --git a/backend/requirements.txt b/backend/requirements.txt index 66f0d51..0d20b47 100644 --- a/backend/requirements.txt +++ b/backend/requirements.txt @@ -1,5 +1,6 @@ fastapi==0.115.6 uvicorn[standard]==0.34.0 +aiofiles==24.1.0 httpx==0.28.1 sqlalchemy==2.0.36 alembic==1.14.0 @@ -9,6 +10,3 @@ python-jose[cryptography]==3.3.0 bcrypt==5.0.0 tiktoken==0.9.0 python-dotenv==1.0.1 -pytest==8.3.4 -pytest-asyncio==0.25.1 -pytest-cov==6.0.0 diff --git a/docker-entrypoint.sh b/docker-entrypoint.sh new file mode 100644 index 0000000..4376900 --- /dev/null +++ b/docker-entrypoint.sh @@ -0,0 +1,19 @@ +#!/bin/bash +set -e + +cd /app/backend +python3 init_db.py + +uvicorn main:app \ + --host "${PROXY_HOST:-0.0.0.0}" \ + --port "${PROXY_PORT:-8000}" & +PROXY_PID=$! + +uvicorn admin:app \ + --host "0.0.0.0" \ + --port "${ADMIN_PORT:-8001}" & +ADMIN_PID=$! + +# Beendet den Container wenn einer der Prozesse stirbt +wait -n +kill "$PROXY_PID" "$ADMIN_PID" 2>/dev/null diff --git a/frontend/vite.config.js b/frontend/vite.config.js index dc2bc36..ae67485 100644 --- a/frontend/vite.config.js +++ b/frontend/vite.config.js @@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react' export default defineConfig({ plugins: [react()], + clearScreen: false, server: { proxy: { '/api/api-keys': 'http://localhost:8001', diff --git a/start.sh b/start.sh index 8f19531..cc7d798 100755 --- a/start.sh +++ b/start.sh @@ -7,31 +7,63 @@ if [ -f .env ]; then set +a fi +# Virtuelle Umgebung aktivieren falls vorhanden +if [ -f .venv/bin/activate ]; then + source .venv/bin/activate +elif [ -f venv/bin/activate ]; then + source venv/bin/activate +fi + if [ -z "$ADMIN_PASSWORD" ]; then echo "Fehler: ADMIN_PASSWORD ist nicht gesetzt. Bitte .env befüllen." exit 1 fi +PROXY_HOST=${PROXY_HOST:-0.0.0.0} +PROXY_PORT=${PROXY_PORT:-8000} +ADMIN_PORT=${ADMIN_PORT:-8001} +FRONTEND_PORT=5173 + +PIDS=() + +cleanup() { + echo "Beende alle Prozesse..." + for pid in "${PIDS[@]}"; do + kill "$pid" 2>/dev/null + done + wait 2>/dev/null +} + +port_in_use() { + lsof -iTCP:"$1" -sTCP:LISTEN -t &>/dev/null +} + +# Ports prüfen bevor irgendetwas gestartet wird +for port in "$PROXY_PORT" "$ADMIN_PORT" "$FRONTEND_PORT"; do + if port_in_use "$port"; then + echo "Fehler: Port $port ist bereits belegt." + exit 1 + fi +done + +trap cleanup EXIT INT TERM + # Datenbank initialisieren echo "Initialisiere Datenbank..." cd backend python3 init_db.py cd .. -PROXY_HOST=${PROXY_HOST:-0.0.0.0} -PROXY_PORT=${PROXY_PORT:-8000} -ADMIN_PORT=${ADMIN_PORT:-8001} - # Backend starten echo "Starte Backend (Proxy) auf ${PROXY_HOST}:${PROXY_PORT}..." cd backend python3 -m uvicorn main:app --reload --host "$PROXY_HOST" --port "$PROXY_PORT" & -BACKEND_PID=$! +PIDS+=($!) # Admin-API immer nur lokal erreichbar (Host nicht konfigurierbar) echo "Starte Admin-API auf 127.0.0.1:${ADMIN_PORT}..." python3 -m uvicorn admin:app --reload --host 127.0.0.1 --port "$ADMIN_PORT" & -ADMIN_PID=$! +PIDS+=($!) cd .. # Frontend starten @@ -39,12 +71,11 @@ echo "Starte Frontend..." cd frontend npm install --silent npm run dev & -FRONTEND_PID=$! +PIDS+=($!) cd .. -echo "Backend läuft auf PID: $BACKEND_PID (Port $PROXY_PORT)" -echo "Admin-API läuft auf PID: $ADMIN_PID (Port 8001, nur lokal)" -echo "Frontend läuft auf PID: $FRONTEND_PID" -echo "Admin-Oberfläche: http://localhost:5173" +echo "Backend läuft (Port $PROXY_PORT)" +echo "Admin-API läuft (Port $ADMIN_PORT, nur lokal)" +echo "Admin-Oberfläche: http://localhost:$FRONTEND_PORT" wait