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

239
README.md
View File

@ -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 <ADMIN_PASSWORD>`.
| 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
MIT

View File

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

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

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({
plugins: [react()],
clearScreen: false,
server: {
proxy: {
'/api/api-keys': 'http://localhost:8001',

View File

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