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 <noreply@anthropic.com>
This commit is contained in:
Oliver Hofmann 2026-04-28 08:34:45 +02:00
parent c8235ec274
commit 317c7f0340
9 changed files with 244 additions and 116 deletions

11
.dockerignore Normal file
View File

@ -0,0 +1,11 @@
.git/
.venv/
venv/
.env
frontend/node_modules/
frontend/dist/
backend/__pycache__/
backend/**/__pycache__/
backend/*.pyc
backend/test.db
backend/tests/

22
Dockerfile Normal file
View File

@ -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"]

237
README.md
View File

@ -1,122 +1,153 @@
# Ollama Proxy mit API-Keys und Quotas # 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 ## Features
- API-Key-Authentifizierung (Bearer Token oder `sk-`-Prefix) - API-Key-Authentifizierung (Bearer Token oder `sk-`-Prefix)
- Optionales Ablaufdatum pro API-Key
- Quota-Management mit getrennten Tages- und Monatslimits (Tokens & Requests) - Quota-Management mit getrennten Tages- und Monatslimits (Tokens & Requests)
- Token-Zählung via tiktoken (cl100k_base) - Token-Zählung via tiktoken, Reset-Grenzen in der Zeitzone Europe/Berlin
- Usage-Tracking mit automatischem täglichem/monatlichem Reset - Web-Admin-Oberfläche (API-Keys verwalten, Ollama-Einstellungen, Proxy-Info)
- Web-Admin-Oberfläche für User- und Quota-Verwaltung
- OpenAI-kompatibler `/v1/chat/completions`-Endpunkt - OpenAI-kompatibler `/v1/chat/completions`-Endpunkt
## Sicherheit ## Sicherheit
- Passwörter mit bcrypt gehasht - Admin-Oberfläche passwortgeschützt (`ADMIN_PASSWORD`)
- API-Keys als SHA-256-Hash in der DB Plaintext wird nur einmalig bei Erstellung zurückgegeben - Admin-API bindet lokal auf `127.0.0.1` (nicht von außen erreichbar)
- Admin-Zugriff über `is_admin`-Flag in der DB, nicht über Hardcoded-Namen - API-Keys als SHA-256-Hash in der DB — Plaintext nur einmalig bei Erstellung
- CORS-Origins konfigurierbar via `ALLOWED_ORIGINS`
- Quota-Check atomar mit `SELECT FOR UPDATE` (kein TOCTOU-Race) - Quota-Check atomar mit `SELECT FOR UPDATE` (kein TOCTOU-Race)
- CORS-Origins konfigurierbar via `ALLOWED_ORIGINS`
## 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
```
## Konfiguration ## Konfiguration
`.env`-Datei im `backend/`-Verzeichnis anlegen: `.env`-Datei im Projektverzeichnis anlegen (Vorlage: `.env.example`):
```env ```env
DATABASE_URL=postgresql://user:pass@host:5432/db ADMIN_PASSWORD=change-me
OLLAMA_URL=http://ollama:11434 PROXY_HOST=0.0.0.0
ALLOWED_ORIGINS=https://admin.example.com 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 | | Variable | Standard | Beschreibung |
|----------|----------|--------------| |----------|----------|--------------|
| `DATABASE_URL` | PostgreSQL lokal | DB-Verbindungsstring; `sqlite:///` für SQLite | | `ADMIN_PASSWORD` | — | Passwort für die Admin-Oberfläche (**Pflicht**) |
| `OLLAMA_URL` | `http://localhost:11434` | Adresse der Ollama-Instanz | | `PROXY_HOST` | `0.0.0.0` | Bind-Adresse des Proxys |
| `ALLOWED_ORIGINS` | `http://localhost:5173` | Kommagetrennte CORS-Origins für die Admin-UI | | `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. Alle Endpunkte erfordern einen gültigen API-Key im `Authorization`-Header.
```bash ```bash
curl -X POST http://localhost:8000/api/generate \ curl -X POST http://localhost:8000/api/chat \
-H "Authorization: Bearer sk-xxxxxx" \ -H "Authorization: Bearer sk-xxxxxx" \
-H "Content-Type: application/json" \ -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 | | Endpunkt | Methode | Beschreibung |
|----------|---------|--------------| |----------|---------|--------------|
| `/api/users` | GET | Alle User auflisten | | `/api/generate` | POST | Ollama generate |
| `/api/users` | POST | Neuen User anlegen | | `/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 <ADMIN_PASSWORD>`.
| Endpunkt | Methode | Beschreibung |
|----------|---------|--------------|
| `/api/api-keys` | GET | Alle API-Keys auflisten | | `/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/api-keys/{id}/deactivate` | PUT | API-Key deaktivieren |
| `/api/quotas/{user_id}` | PUT | Quota für User setzen | | `/api/api-keys/{id}/quota` | PATCH | Quota eines Keys aktualisieren |
| `/api/settings` | GET/PUT | Ollama-URL und Standard-Modell |
### Quota setzen | `/api/ollama-models` | GET | Verfügbare Modelle von Ollama |
| `/api/proxy-info` | GET | Lokaler Proxy-Endpunkt |
```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.
## Tests ## Tests
@ -130,26 +161,30 @@ python -m pytest tests/ -v
``` ```
llm_quota/ llm_quota/
├── backend/ ├── backend/
│ ├── main.py # Proxy-Server │ ├── main.py # Proxy-Server (Port 8000)
│ ├── admin.py # Admin-API │ ├── admin.py # Admin-API + Static-File-Serving (Port 8001)
│ ├── database.py # DB-Verbindung & Session │ ├── database.py # DB-Verbindung & Session
│ ├── models.py # SQLAlchemy-Modelle │ ├── models.py # SQLAlchemy-Modelle (APIKey, Setting, Usage)
│ ├── schemas.py # Pydantic-Schemas │ ├── schemas.py # Pydantic-Schemas
│ ├── crud.py # DB-Operationen & Token-Zählung │ ├── crud.py # DB-Operationen, Token-Zählung, Quota-Logik
│ ├── init_db.py # Tabellen anlegen │ ├── init_db.py # Tabellen anlegen & Settings seeden
│ ├── setup_admin.py # Admin-User & API-Key erstellen │ ├── setup_admin.py # Standard-API-Key erstellen
│ ├── requirements.txt │ ├── requirements.txt # Produktiv-Dependencies
│ ├── Dockerfile │ ├── requirements-dev.txt # Test-Dependencies
│ └── tests/ │ └── tests/
│ ├── conftest.py # Fixtures │ ├── conftest.py # Fixtures
│ ├── test_auth.py # Authentifizierungs-Tests │ ├── test_auth.py # Authentifizierungs-Tests
│ └── test_quota.py # Quota- & Token-Tests │ └── test_quota.py # Quota-, Token- und Ablauf-Tests
├── frontend/ ├── frontend/
│ └── src/ │ └── src/
│ ├── main.jsx │ ├── main.jsx # React-Admin-UI
│ └── styles.css │ └── styles.css
├── .gitignore ├── Dockerfile
└── docker-compose.yml ├── docker-entrypoint.sh
├── .dockerignore
├── .env.example
├── start.sh # Entwicklungs-Startscript
└── .gitignore
``` ```
## Lizenz ## Lizenz

View File

@ -1,8 +1,10 @@
import os import os
import secrets import secrets
import httpx import httpx
from pathlib import Path
from fastapi import FastAPI, Depends, HTTPException, Request from fastapi import FastAPI, Depends, HTTPException, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from sqlalchemy.orm import Session from sqlalchemy.orm import Session
from database import get_db from database import get_db
import crud, schemas import crud, schemas
@ -119,3 +121,8 @@ async def get_ollama_models(db: Session = Depends(get_db), _ = Depends(require_a
except Exception: except Exception:
models = [] models = []
return {"models": 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")

View File

@ -0,0 +1,4 @@
-r requirements.txt
pytest==8.3.4
pytest-asyncio==0.25.1
pytest-cov==6.0.0

View File

@ -1,5 +1,6 @@
fastapi==0.115.6 fastapi==0.115.6
uvicorn[standard]==0.34.0 uvicorn[standard]==0.34.0
aiofiles==24.1.0
httpx==0.28.1 httpx==0.28.1
sqlalchemy==2.0.36 sqlalchemy==2.0.36
alembic==1.14.0 alembic==1.14.0
@ -9,6 +10,3 @@ python-jose[cryptography]==3.3.0
bcrypt==5.0.0 bcrypt==5.0.0
tiktoken==0.9.0 tiktoken==0.9.0
python-dotenv==1.0.1 python-dotenv==1.0.1
pytest==8.3.4
pytest-asyncio==0.25.1
pytest-cov==6.0.0

19
docker-entrypoint.sh Normal file
View File

@ -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

View File

@ -3,6 +3,7 @@ import react from '@vitejs/plugin-react'
export default defineConfig({ export default defineConfig({
plugins: [react()], plugins: [react()],
clearScreen: false,
server: { server: {
proxy: { proxy: {
'/api/api-keys': 'http://localhost:8001', '/api/api-keys': 'http://localhost:8001',

View File

@ -7,31 +7,63 @@ if [ -f .env ]; then
set +a set +a
fi 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 if [ -z "$ADMIN_PASSWORD" ]; then
echo "Fehler: ADMIN_PASSWORD ist nicht gesetzt. Bitte .env befüllen." echo "Fehler: ADMIN_PASSWORD ist nicht gesetzt. Bitte .env befüllen."
exit 1 exit 1
fi 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 # Datenbank initialisieren
echo "Initialisiere Datenbank..." echo "Initialisiere Datenbank..."
cd backend cd backend
python3 init_db.py python3 init_db.py
cd .. cd ..
PROXY_HOST=${PROXY_HOST:-0.0.0.0}
PROXY_PORT=${PROXY_PORT:-8000}
ADMIN_PORT=${ADMIN_PORT:-8001}
# Backend starten # Backend starten
echo "Starte Backend (Proxy) auf ${PROXY_HOST}:${PROXY_PORT}..." echo "Starte Backend (Proxy) auf ${PROXY_HOST}:${PROXY_PORT}..."
cd backend cd backend
python3 -m uvicorn main:app --reload --host "$PROXY_HOST" --port "$PROXY_PORT" & python3 -m uvicorn main:app --reload --host "$PROXY_HOST" --port "$PROXY_PORT" &
BACKEND_PID=$! PIDS+=($!)
# Admin-API immer nur lokal erreichbar (Host nicht konfigurierbar) # Admin-API immer nur lokal erreichbar (Host nicht konfigurierbar)
echo "Starte Admin-API auf 127.0.0.1:${ADMIN_PORT}..." 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" & python3 -m uvicorn admin:app --reload --host 127.0.0.1 --port "$ADMIN_PORT" &
ADMIN_PID=$! PIDS+=($!)
cd .. cd ..
# Frontend starten # Frontend starten
@ -39,12 +71,11 @@ echo "Starte Frontend..."
cd frontend cd frontend
npm install --silent npm install --silent
npm run dev & npm run dev &
FRONTEND_PID=$! PIDS+=($!)
cd .. cd ..
echo "Backend läuft auf PID: $BACKEND_PID (Port $PROXY_PORT)" echo "Backend läuft (Port $PROXY_PORT)"
echo "Admin-API läuft auf PID: $ADMIN_PID (Port 8001, nur lokal)" echo "Admin-API läuft (Port $ADMIN_PORT, nur lokal)"
echo "Frontend läuft auf PID: $FRONTEND_PID" echo "Admin-Oberfläche: http://localhost:$FRONTEND_PORT"
echo "Admin-Oberfläche: http://localhost:5173"
wait wait